bmad-viewer 0.3.4 → 0.3.6
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.
- package/.claude/launch.json +11 -0
- package/.claude/settings.local.json +12 -0
- package/package.json +2 -2
- package/public/client.js +21 -1
- package/public/styles.css +13 -5
- package/src/components/kanban-card.js +9 -4
- package/src/data/data-model.js +178 -15
- package/src/server/http-server.js +2 -1
- package/src/server/renderer.js +49 -14
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"mcp__Claude_Preview__preview_start",
|
|
5
|
+
"Read(//c/code/claude-ideas/notify/**)",
|
|
6
|
+
"Bash(npm install:*)",
|
|
7
|
+
"Bash(grep -cP '^#{3,4} Story \\\\d+\\\\.\\\\d+' C:/code/claude-ideas/notify/_bmad-output/planning-artifacts/epics-and-stories.md)",
|
|
8
|
+
"Bash(node -e \":*)",
|
|
9
|
+
"Bash(timeout 5 node src/server/http-server.js --path C:/code/claude-ideas/notify)"
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bmad-viewer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Visual dashboard for BMAD (Boring Maintainable Agile Development) projects. Wiki browser + sprint status viewer with live reload.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"url": "https://github.com/CamiloValderramaGonzalez/bmad-viewer/issues"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"bmad-viewer": "^0.3.
|
|
30
|
+
"bmad-viewer": "^0.3.4",
|
|
31
31
|
"chokidar": "^4.0.1",
|
|
32
32
|
"fuse.js": "^7.0.0",
|
|
33
33
|
"js-yaml": "^4.1.0",
|
package/public/client.js
CHANGED
|
@@ -500,19 +500,39 @@
|
|
|
500
500
|
|
|
501
501
|
/* ── Path Config Panel ── */
|
|
502
502
|
function initPathConfig() {
|
|
503
|
+
var panel = document.getElementById('path-config-panel');
|
|
504
|
+
if (!panel) return;
|
|
505
|
+
|
|
506
|
+
// Toggle collapsed/expanded
|
|
507
|
+
var toggle = document.getElementById('path-config-toggle');
|
|
508
|
+
if (toggle) {
|
|
509
|
+
toggle.addEventListener('click', function () {
|
|
510
|
+
panel.classList.toggle('path-config-panel--collapsed');
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Load current overrides into inputs
|
|
515
|
+
fetch('/api/get-paths').then(function (r) { return r.json(); }).then(function (data) {
|
|
516
|
+
if (data.customOutputPath) { var el = document.getElementById('custom-output-path'); if (el) el.value = data.customOutputPath; }
|
|
517
|
+
if (data.customEpicsPath) { var el = document.getElementById('custom-epics-path'); if (el) el.value = data.customEpicsPath; }
|
|
518
|
+
if (data.customSprintStatusPath) { var el = document.getElementById('custom-sprint-status-path'); if (el) el.value = data.customSprintStatusPath; }
|
|
519
|
+
}).catch(function () {});
|
|
520
|
+
|
|
503
521
|
var btn = document.getElementById('apply-paths-btn');
|
|
504
522
|
if (!btn) return;
|
|
505
523
|
|
|
506
524
|
btn.addEventListener('click', function () {
|
|
507
525
|
var epicsInput = document.getElementById('custom-epics-path');
|
|
508
526
|
var outputInput = document.getElementById('custom-output-path');
|
|
527
|
+
var sprintInput = document.getElementById('custom-sprint-status-path');
|
|
509
528
|
var status = document.getElementById('path-config-status');
|
|
510
529
|
var payload = {};
|
|
511
530
|
|
|
512
531
|
if (outputInput && outputInput.value.trim()) payload.outputPath = outputInput.value.trim();
|
|
513
532
|
if (epicsInput && epicsInput.value.trim()) payload.epicsPath = epicsInput.value.trim();
|
|
533
|
+
if (sprintInput && sprintInput.value.trim()) payload.sprintStatusPath = sprintInput.value.trim();
|
|
514
534
|
|
|
515
|
-
if (!payload.outputPath && !payload.epicsPath) {
|
|
535
|
+
if (!payload.outputPath && !payload.epicsPath && !payload.sprintStatusPath) {
|
|
516
536
|
if (status) { status.textContent = 'Enter at least one path'; status.className = 'path-config-panel__status path-config-panel__status--err'; }
|
|
517
537
|
return;
|
|
518
538
|
}
|
package/public/styles.css
CHANGED
|
@@ -150,13 +150,17 @@ mark.search-highlight{background:rgba(245,158,11,.4);color:var(--text);border-ra
|
|
|
150
150
|
.warning-banner{background:rgba(245,158,11,.1);border-bottom:1px solid var(--warning);padding:8px var(--gap)}
|
|
151
151
|
.warning-banner__item{font-size:.85rem;color:var(--text)}
|
|
152
152
|
/* Path config panel */
|
|
153
|
-
.path-config-panel{background:var(--accent-soft);border:1px solid var(--border);border-radius:var(--radius);padding:
|
|
154
|
-
.path-config-
|
|
155
|
-
.path-config-
|
|
156
|
-
.path-config-
|
|
153
|
+
.path-config-panel{background:var(--accent-soft);border:1px solid var(--border);border-radius:var(--radius);padding:16px 24px;margin-bottom:20px}
|
|
154
|
+
.path-config-panel__toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}
|
|
155
|
+
.path-config-panel__toggle h3{font-size:.95rem;margin:0}
|
|
156
|
+
.path-config-panel__arrow{font-size:.75rem;color:var(--text-muted);transition:transform .2s}
|
|
157
|
+
.path-config-panel--collapsed .path-config-panel__fields{display:none}
|
|
158
|
+
.path-config-panel--collapsed .path-config-panel__arrow{transform:rotate(0deg)}
|
|
159
|
+
.path-config-panel:not(.path-config-panel--collapsed) .path-config-panel__arrow{transform:rotate(90deg)}
|
|
160
|
+
.path-config-panel__fields{display:flex;flex-direction:column;gap:12px;margin-top:12px}
|
|
157
161
|
.path-config-panel__label{display:flex;flex-direction:column;gap:4px}
|
|
158
162
|
.path-config-panel__label>span:first-child{font-size:.85rem;font-weight:600}
|
|
159
|
-
.path-config-panel__input{font-family:var(--font-mono);font-size:.85rem;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);width:100
|
|
163
|
+
.path-config-panel__input{font-family:var(--font-mono);font-size:.85rem;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);width:100%;box-sizing:border-box}
|
|
160
164
|
.path-config-panel__input:focus{outline:none;border-color:var(--primary)}
|
|
161
165
|
.path-config-panel__hint{font-size:.75rem;color:var(--text-muted)}
|
|
162
166
|
.path-config-panel__btn{align-self:flex-start;padding:8px 20px;background:var(--primary);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:.85rem;font-weight:600;transition:background .15s}
|
|
@@ -165,6 +169,10 @@ mark.search-highlight{background:rgba(245,158,11,.4);color:var(--text);border-ra
|
|
|
165
169
|
.path-config-panel__status{font-size:.85rem;color:var(--text-muted);align-self:center}
|
|
166
170
|
.path-config-panel__status--ok{color:var(--success)}
|
|
167
171
|
.path-config-panel__status--err{color:var(--warning)}
|
|
172
|
+
/* Bug & pending cards in kanban */
|
|
173
|
+
.kanban-card--bug{border-left:3px solid #e74c3c}
|
|
174
|
+
.kanban-card--global{border-left:3px solid var(--warning)}
|
|
175
|
+
.kanban-card__detail{color:var(--text-muted);font-size:.8rem;margin-bottom:4px}
|
|
168
176
|
/* Responsive */
|
|
169
177
|
@media(max-width:768px){.app-layout{grid-template-columns:1fr}.app-layout__sidebar{max-height:none;position:static;border-right:none;border-bottom:1px solid var(--border)}.kanban{grid-template-columns:1fr}.stats-box{grid-template-columns:repeat(2,1fr)}}
|
|
170
178
|
@media(max-width:440px){.header-bar__shortcut{display:none}.stats-box{grid-template-columns:1fr}.lens-tabs__tab{padding:8px 12px;font-size:.85rem}}
|
|
@@ -6,12 +6,17 @@ import { Badge } from './badge.js';
|
|
|
6
6
|
* @param {{id: string, title: string, status: string, epic: string}} props
|
|
7
7
|
* @returns {string} HTML string
|
|
8
8
|
*/
|
|
9
|
-
export function KanbanCard({ id, title, status, epic }) {
|
|
10
|
-
|
|
9
|
+
export function KanbanCard({ id, title, status, epic, cardType, detail }) {
|
|
10
|
+
const typeClass = cardType === 'bug' ? ' kanban-card--bug' : cardType === 'global' ? ' kanban-card--global' : '';
|
|
11
|
+
const tag = cardType ? 'div' : 'a';
|
|
12
|
+
const href = cardType ? '' : ` href="#project/story/${escapeHtml(id)}"`;
|
|
13
|
+
const label = cardType === 'bug' ? escapeHtml(epic) : `Epic ${escapeHtml(epic)}`;
|
|
14
|
+
return `<${tag}${href} class="kanban-card kanban-card--${escapeHtml(status)}${typeClass}" data-id="${escapeHtml(id)}">
|
|
11
15
|
<h4 class="kanban-card__title">${escapeHtml(title)}</h4>
|
|
16
|
+
${detail ? `<div class="kanban-card__detail">${escapeHtml(detail)}</div>` : ''}
|
|
12
17
|
<div class="kanban-card__meta">
|
|
13
|
-
${Badge({ status: epic, text:
|
|
18
|
+
${Badge({ status: epic, text: label })}
|
|
14
19
|
${Badge({ status })}
|
|
15
20
|
</div>
|
|
16
|
-
|
|
21
|
+
</${tag}>`;
|
|
17
22
|
}
|
package/src/data/data-model.js
CHANGED
|
@@ -8,7 +8,7 @@ import { ErrorAggregator } from '../utils/error-aggregator.js';
|
|
|
8
8
|
* Build the complete in-memory data model from a BMAD project.
|
|
9
9
|
*
|
|
10
10
|
* @param {string} bmadDir - Project root containing _bmad/
|
|
11
|
-
* @param {{customEpicsPath?: string, customOutputPath?: string}} [options] - Optional overrides
|
|
11
|
+
* @param {{customEpicsPath?: string, customOutputPath?: string, customSprintStatusPath?: string}} [options] - Optional overrides
|
|
12
12
|
* @returns {{wiki: object, project: object, config: object, aggregator: ErrorAggregator}}
|
|
13
13
|
*/
|
|
14
14
|
export function buildDataModel(bmadDir, options) {
|
|
@@ -17,7 +17,7 @@ export function buildDataModel(bmadDir, options) {
|
|
|
17
17
|
const outputPath = options?.customOutputPath || join(bmadDir, '_bmad-output');
|
|
18
18
|
|
|
19
19
|
const wiki = buildWikiData(bmadPath, aggregator);
|
|
20
|
-
const project = buildProjectData(outputPath, aggregator, options
|
|
20
|
+
const project = buildProjectData(outputPath, aggregator, options);
|
|
21
21
|
const config = loadConfig(bmadPath, aggregator);
|
|
22
22
|
|
|
23
23
|
return { wiki, project, config, aggregator };
|
|
@@ -281,15 +281,18 @@ function readMarkdownSafe(filePath, aggregator) {
|
|
|
281
281
|
* Build project data from _bmad-output directory.
|
|
282
282
|
* @param {string} outputPath
|
|
283
283
|
* @param {ErrorAggregator} aggregator
|
|
284
|
-
* @param {string} [
|
|
284
|
+
* @param {{customEpicsPath?: string, customSprintStatusPath?: string}} [options]
|
|
285
285
|
*/
|
|
286
|
-
function buildProjectData(outputPath, aggregator,
|
|
286
|
+
function buildProjectData(outputPath, aggregator, options) {
|
|
287
|
+
const customEpicsPath = options?.customEpicsPath;
|
|
287
288
|
const project = {
|
|
288
289
|
sprintStatus: null,
|
|
289
290
|
stories: { total: 0, pending: 0, inProgress: 0, done: 0 },
|
|
290
291
|
storyList: [],
|
|
291
292
|
epics: [],
|
|
292
293
|
artifacts: [],
|
|
294
|
+
bugs: [],
|
|
295
|
+
pendingItems: [],
|
|
293
296
|
};
|
|
294
297
|
|
|
295
298
|
if (!existsSync(outputPath)) {
|
|
@@ -297,23 +300,33 @@ function buildProjectData(outputPath, aggregator, customEpicsPath) {
|
|
|
297
300
|
return project;
|
|
298
301
|
}
|
|
299
302
|
|
|
300
|
-
// Parse sprint-status
|
|
303
|
+
// Parse sprint-status (yaml or md)
|
|
301
304
|
const sprintStatusPaths = [
|
|
302
305
|
join(outputPath, 'implementation-artifacts', 'sprint-status.yaml'),
|
|
306
|
+
join(outputPath, 'implementation-artifacts', 'sprint-status.md'),
|
|
303
307
|
join(outputPath, 'sprint-status.yaml'),
|
|
308
|
+
join(outputPath, 'sprint-status.md'),
|
|
309
|
+
join(outputPath, 'planning-artifacts', 'sprint-status.yaml'),
|
|
310
|
+
join(outputPath, 'planning-artifacts', 'sprint-status.md'),
|
|
304
311
|
];
|
|
312
|
+
if (options?.customSprintStatusPath) {
|
|
313
|
+
sprintStatusPaths.unshift(options.customSprintStatusPath);
|
|
314
|
+
}
|
|
305
315
|
|
|
306
316
|
for (const statusPath of sprintStatusPaths) {
|
|
307
317
|
if (existsSync(statusPath)) {
|
|
308
|
-
|
|
309
|
-
|
|
318
|
+
let raw = '';
|
|
319
|
+
try { raw = readFileSync(statusPath, 'utf8'); } catch { /* ignore */ }
|
|
310
320
|
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
321
|
+
// Try YAML format first (only report errors for .yaml files)
|
|
322
|
+
const result = parseYaml(statusPath);
|
|
323
|
+
const ext = extname(statusPath).toLowerCase();
|
|
324
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
325
|
+
aggregator.addResult(statusPath, result);
|
|
326
|
+
}
|
|
315
327
|
|
|
316
328
|
if (result.data?.development_status) {
|
|
329
|
+
const epicNames = parseEpicNamesFromComments(raw);
|
|
317
330
|
project.sprintStatus = result.data;
|
|
318
331
|
const status = result.data.development_status;
|
|
319
332
|
const epicMap = {};
|
|
@@ -352,8 +365,13 @@ function buildProjectData(outputPath, aggregator, customEpicsPath) {
|
|
|
352
365
|
}
|
|
353
366
|
|
|
354
367
|
project.epics = Object.values(epicMap).sort((a, b) => Number(a.num) - Number(b.num));
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Fallback: try markdown table format (### Epic N: Name + | N.M | desc | status |)
|
|
372
|
+
if (raw && parseSprintStatusMarkdown(raw, project)) {
|
|
373
|
+
break;
|
|
355
374
|
}
|
|
356
|
-
break;
|
|
357
375
|
}
|
|
358
376
|
}
|
|
359
377
|
|
|
@@ -364,6 +382,10 @@ function buildProjectData(outputPath, aggregator, customEpicsPath) {
|
|
|
364
382
|
for (const file of files) {
|
|
365
383
|
const ext = extname(file).toLowerCase();
|
|
366
384
|
const name = basename(file, ext);
|
|
385
|
+
|
|
386
|
+
// Skip sprint-status files (already parsed above)
|
|
387
|
+
if (name === 'sprint-status') continue;
|
|
388
|
+
|
|
367
389
|
const content = ext === '.html'
|
|
368
390
|
? readHtmlSafe(file)
|
|
369
391
|
: readMarkdownSafe(file, aggregator);
|
|
@@ -381,15 +403,49 @@ function buildProjectData(outputPath, aggregator, customEpicsPath) {
|
|
|
381
403
|
const storyContents = parseStoriesFromEpics(content.raw, aggregator);
|
|
382
404
|
project.storyContents = storyContents;
|
|
383
405
|
|
|
384
|
-
// Build epics from markdown when no sprint-status.yaml was found
|
|
385
406
|
if (project.epics.length === 0) {
|
|
407
|
+
// No sprint-status found — build epics entirely from markdown
|
|
386
408
|
project.epics = parseEpicsFromMarkdown(content.raw, storyContents);
|
|
387
|
-
// Build story stats from epics.md stories
|
|
388
409
|
for (const epic of project.epics) {
|
|
389
410
|
project.stories.total += epic.stories.length;
|
|
390
|
-
project.stories.pending += epic.stories.length;
|
|
411
|
+
project.stories.pending += epic.stories.length;
|
|
391
412
|
project.storyList.push(...epic.stories);
|
|
392
413
|
}
|
|
414
|
+
} else {
|
|
415
|
+
// Merge: add stories from epics.md that are missing in sprint-status
|
|
416
|
+
const mdEpics = parseEpicsFromMarkdown(content.raw, storyContents);
|
|
417
|
+
const existingIds = new Set(project.storyList.map(s => `${s.epic}-${s.id.split('-')[1]}`));
|
|
418
|
+
for (const mdEpic of mdEpics) {
|
|
419
|
+
let sprintEpic = project.epics.find(e => e.num === mdEpic.num);
|
|
420
|
+
if (!sprintEpic) {
|
|
421
|
+
// Entire epic missing from sprint-status — add with all stories as backlog
|
|
422
|
+
sprintEpic = { ...mdEpic, stories: [], status: 'backlog' };
|
|
423
|
+
for (const story of mdEpic.stories) {
|
|
424
|
+
story.status = 'backlog';
|
|
425
|
+
sprintEpic.stories.push(story);
|
|
426
|
+
project.storyList.push(story);
|
|
427
|
+
project.stories.total++;
|
|
428
|
+
project.stories.pending++;
|
|
429
|
+
}
|
|
430
|
+
project.epics.push(sprintEpic);
|
|
431
|
+
project.epics.sort((a, b) => Number(a.num) - Number(b.num));
|
|
432
|
+
} else {
|
|
433
|
+
if (!sprintEpic.name || sprintEpic.name === `Epic ${sprintEpic.num}`) {
|
|
434
|
+
sprintEpic.name = mdEpic.name;
|
|
435
|
+
}
|
|
436
|
+
// Add only stories missing from sprint-status
|
|
437
|
+
for (const story of mdEpic.stories) {
|
|
438
|
+
const storyKey = `${mdEpic.num}-${story.id.split('-')[1]}`;
|
|
439
|
+
if (!existingIds.has(storyKey)) {
|
|
440
|
+
story.status = 'backlog';
|
|
441
|
+
sprintEpic.stories.push(story);
|
|
442
|
+
project.storyList.push(story);
|
|
443
|
+
project.stories.total++;
|
|
444
|
+
project.stories.pending++;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
393
449
|
}
|
|
394
450
|
}
|
|
395
451
|
}
|
|
@@ -618,6 +674,113 @@ function parseEpicsFromMarkdown(raw, storyContents) {
|
|
|
618
674
|
return Object.values(epicMap).sort((a, b) => Number(a.num) - Number(b.num));
|
|
619
675
|
}
|
|
620
676
|
|
|
677
|
+
/**
|
|
678
|
+
* Parse sprint-status from markdown table format.
|
|
679
|
+
* Expects ### Epic N: Name headers followed by tables with | N.M | description | status |
|
|
680
|
+
* @returns {boolean} true if data was found
|
|
681
|
+
*/
|
|
682
|
+
function parseSprintStatusMarkdown(raw, project) {
|
|
683
|
+
const epicHeaderRegex = /^#{2,3}\s*Epic\s+(\d+):\s*(.+)$/gm;
|
|
684
|
+
const storyRowRegex = /^\|\s*(\d+)\.(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/gm;
|
|
685
|
+
|
|
686
|
+
// First pass: find all epic headers
|
|
687
|
+
const epicMap = {};
|
|
688
|
+
let match;
|
|
689
|
+
while ((match = epicHeaderRegex.exec(raw)) !== null) {
|
|
690
|
+
const epicNum = match[1];
|
|
691
|
+
epicMap[epicNum] = {
|
|
692
|
+
id: `epic-${epicNum}`,
|
|
693
|
+
num: epicNum,
|
|
694
|
+
name: match[2].trim(),
|
|
695
|
+
status: 'in-progress',
|
|
696
|
+
stories: [],
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (Object.keys(epicMap).length === 0) return false;
|
|
701
|
+
|
|
702
|
+
// Second pass: find all story rows
|
|
703
|
+
while ((match = storyRowRegex.exec(raw)) !== null) {
|
|
704
|
+
const epicNum = match[1];
|
|
705
|
+
const storyNum = match[2];
|
|
706
|
+
const title = match[3].trim();
|
|
707
|
+
const rawStatus = match[4].trim();
|
|
708
|
+
|
|
709
|
+
// Normalize status from emoji/text to our standard values
|
|
710
|
+
const status = normalizeMarkdownStatus(rawStatus);
|
|
711
|
+
|
|
712
|
+
project.stories.total++;
|
|
713
|
+
if (status === 'backlog' || status === 'ready-for-dev') project.stories.pending++;
|
|
714
|
+
else if (status === 'in-progress') project.stories.inProgress++;
|
|
715
|
+
else if (status === 'done' || status === 'review') project.stories.done++;
|
|
716
|
+
|
|
717
|
+
const story = {
|
|
718
|
+
id: `${epicNum}-${storyNum}-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, '')}`,
|
|
719
|
+
title,
|
|
720
|
+
status,
|
|
721
|
+
epic: epicNum,
|
|
722
|
+
};
|
|
723
|
+
project.storyList.push(story);
|
|
724
|
+
|
|
725
|
+
if (!epicMap[epicNum]) {
|
|
726
|
+
epicMap[epicNum] = { id: `epic-${epicNum}`, num: epicNum, name: `Epic ${epicNum}`, status: 'in-progress', stories: [] };
|
|
727
|
+
}
|
|
728
|
+
epicMap[epicNum].stories.push(story);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Determine epic status from stories
|
|
732
|
+
for (const epic of Object.values(epicMap)) {
|
|
733
|
+
if (epic.stories.length === 0) continue;
|
|
734
|
+
const allDone = epic.stories.every(s => s.status === 'done' || s.status === 'review');
|
|
735
|
+
const anyInProgress = epic.stories.some(s => s.status === 'in-progress');
|
|
736
|
+
if (allDone) epic.status = 'done';
|
|
737
|
+
else if (anyInProgress) epic.status = 'in-progress';
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
project.epics = Object.values(epicMap).sort((a, b) => Number(a.num) - Number(b.num));
|
|
741
|
+
|
|
742
|
+
// Parse bugs table: | BUG-XXX | desc | epic | status |
|
|
743
|
+
const bugRowRegex = /^\|\s*(BUG-\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/gm;
|
|
744
|
+
while ((match = bugRowRegex.exec(raw)) !== null) {
|
|
745
|
+
project.bugs.push({
|
|
746
|
+
id: match[1],
|
|
747
|
+
description: match[2].trim(),
|
|
748
|
+
epic: match[3].trim(),
|
|
749
|
+
status: normalizeMarkdownStatus(match[4].trim()),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Parse pendientes globales: - [ ] text or - [✅] text or - [x] text
|
|
754
|
+
const pendingRegex = /^-\s*\[([^\]]*)\]\s*\*{0,2}(.+?)(?:\*{0,2}\s*[-–—]\s*(.+))?$/gm;
|
|
755
|
+
while ((match = pendingRegex.exec(raw)) !== null) {
|
|
756
|
+
const check = match[1].trim();
|
|
757
|
+
const done = check === '✅' || check.toLowerCase() === 'x';
|
|
758
|
+
project.pendingItems.push({
|
|
759
|
+
title: match[2].replace(/\*{1,2}/g, '').trim(),
|
|
760
|
+
detail: match[3]?.trim() || '',
|
|
761
|
+
done,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return project.stories.total > 0;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Normalize markdown status text/emojis to standard status values.
|
|
770
|
+
*/
|
|
771
|
+
function normalizeMarkdownStatus(raw) {
|
|
772
|
+
const lower = raw.toLowerCase();
|
|
773
|
+
// Strip parenthetical notes for primary status detection
|
|
774
|
+
const primary = lower.replace(/\(.*?\)/g, '').trim();
|
|
775
|
+
if (primary.includes('done') || primary.includes('completado') || raw.includes('✅')) return 'done';
|
|
776
|
+
if (primary.includes('in-progress') || primary.includes('in progress') || primary.includes('en progreso') || raw.includes('🔄')) return 'in-progress';
|
|
777
|
+
if (primary.includes('review') || primary.includes('revisión') || primary.includes('revision')) return 'review';
|
|
778
|
+
if (primary.includes('parcial')) return 'in-progress';
|
|
779
|
+
if (primary.includes('pendiente') || primary.includes('pending') || primary.includes('backlog')) return 'backlog';
|
|
780
|
+
if (raw.includes('⏳')) return 'in-progress';
|
|
781
|
+
return 'backlog';
|
|
782
|
+
}
|
|
783
|
+
|
|
621
784
|
/**
|
|
622
785
|
* Parse epic names from YAML comments like "# Epic 1: Fundación del Proyecto"
|
|
623
786
|
*/
|
|
@@ -22,7 +22,7 @@ export async function startServer({ port, bmadDir, open }) {
|
|
|
22
22
|
const actualPort = await findAvailablePort(port);
|
|
23
23
|
|
|
24
24
|
// Custom path overrides (can be set via API)
|
|
25
|
-
const overrides = { customEpicsPath: null, customOutputPath: null };
|
|
25
|
+
const overrides = { customEpicsPath: null, customOutputPath: null, customSprintStatusPath: null };
|
|
26
26
|
|
|
27
27
|
// Build initial data model
|
|
28
28
|
let dataModel = buildDataModel(bmadDir, overrides);
|
|
@@ -45,6 +45,7 @@ export async function startServer({ port, bmadDir, open }) {
|
|
|
45
45
|
const data = JSON.parse(body);
|
|
46
46
|
if (data.epicsPath !== undefined) overrides.customEpicsPath = data.epicsPath || null;
|
|
47
47
|
if (data.outputPath !== undefined) overrides.customOutputPath = data.outputPath || null;
|
|
48
|
+
if (data.sprintStatusPath !== undefined) overrides.customSprintStatusPath = data.sprintStatusPath || null;
|
|
48
49
|
dataModel = buildDataModel(bmadDir, overrides);
|
|
49
50
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
50
51
|
res.end(JSON.stringify({ ok: true, epics: dataModel.project.epics.length, stories: dataModel.project.stories.total }));
|
package/src/server/renderer.js
CHANGED
|
@@ -44,32 +44,67 @@ export function renderDashboard(dataModel) {
|
|
|
44
44
|
const inProgress = storyList.filter((s) => s.status === 'in-progress');
|
|
45
45
|
const done = storyList.filter((s) => s.status === 'done' || s.status === 'review');
|
|
46
46
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
<div class="path-config-
|
|
50
|
-
<h3
|
|
51
|
-
<
|
|
47
|
+
const noData = project.epics.length === 0 && project.stories.total === 0;
|
|
48
|
+
const configPanel = `<div class="path-config-panel${noData ? '' : ' path-config-panel--collapsed'}" id="path-config-panel">
|
|
49
|
+
<div class="path-config-panel__toggle" id="path-config-toggle">
|
|
50
|
+
<h3>${noData ? 'No project data found' : 'Custom paths'}</h3>
|
|
51
|
+
<span class="path-config-panel__arrow" id="path-config-arrow">${noData ? '' : '▶'}</span>
|
|
52
52
|
</div>
|
|
53
|
-
<
|
|
53
|
+
${noData ? '<p class="path-config-panel__hint" style="margin-bottom:12px">Could not auto-detect epics or sprint status. Specify custom paths below.</p>' : ''}
|
|
54
|
+
<div class="path-config-panel__fields" id="path-config-fields">
|
|
54
55
|
<label class="path-config-panel__label">
|
|
55
56
|
<span>Output folder</span>
|
|
56
|
-
<input type="text" id="custom-output-path" class="path-config-panel__input" placeholder="e.g.
|
|
57
|
+
<input type="text" id="custom-output-path" class="path-config-panel__input" placeholder="e.g. /project/_bmad-output" />
|
|
57
58
|
<span class="path-config-panel__hint">Folder containing planning-artifacts, implementation-artifacts, etc.</span>
|
|
58
59
|
</label>
|
|
59
60
|
<label class="path-config-panel__label">
|
|
60
61
|
<span>Epics file</span>
|
|
61
|
-
<input type="text" id="custom-epics-path" class="path-config-panel__input" placeholder="e.g.
|
|
62
|
+
<input type="text" id="custom-epics-path" class="path-config-panel__input" placeholder="e.g. /project/docs/epics.md" />
|
|
62
63
|
<span class="path-config-panel__hint">Markdown file with epic/story definitions (## Epic N: / ### Story N.M:)</span>
|
|
63
64
|
</label>
|
|
65
|
+
<label class="path-config-panel__label">
|
|
66
|
+
<span>Sprint status file</span>
|
|
67
|
+
<input type="text" id="custom-sprint-status-path" class="path-config-panel__input" placeholder="e.g. /project/sprint-status.yaml" />
|
|
68
|
+
<span class="path-config-panel__hint">YAML file (.yaml or .md) with development_status section</span>
|
|
69
|
+
</label>
|
|
64
70
|
<button class="path-config-panel__btn" id="apply-paths-btn">Apply</button>
|
|
65
71
|
<span class="path-config-panel__status" id="path-config-status"></span>
|
|
66
72
|
</div>
|
|
67
|
-
</div
|
|
68
|
-
|
|
73
|
+
</div>`;
|
|
74
|
+
|
|
75
|
+
// Mix bugs and pendientes into kanban columns as cards
|
|
76
|
+
const pendingGlobal = (project.pendingItems || []).filter(i => !i.done).map(i => ({
|
|
77
|
+
id: `global-${i.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
|
78
|
+
title: i.title,
|
|
79
|
+
status: 'backlog',
|
|
80
|
+
epic: 'Global',
|
|
81
|
+
detail: i.detail,
|
|
82
|
+
cardType: 'global',
|
|
83
|
+
}));
|
|
84
|
+
const doneGlobal = (project.pendingItems || []).filter(i => i.done).map(i => ({
|
|
85
|
+
id: `global-${i.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
|
86
|
+
title: i.title,
|
|
87
|
+
status: 'done',
|
|
88
|
+
epic: 'Global',
|
|
89
|
+
cardType: 'global',
|
|
90
|
+
}));
|
|
91
|
+
const bugCards = (project.bugs || []).map(b => ({
|
|
92
|
+
id: b.id.toLowerCase(),
|
|
93
|
+
title: b.description,
|
|
94
|
+
status: b.status,
|
|
95
|
+
epic: b.id,
|
|
96
|
+
cardType: 'bug',
|
|
97
|
+
}));
|
|
98
|
+
const doneBugs = bugCards.filter(b => b.status === 'done');
|
|
99
|
+
const activeBugs = bugCards.filter(b => b.status !== 'done');
|
|
100
|
+
|
|
101
|
+
const allPending = [...pending, ...pendingGlobal, ...activeBugs.filter(b => b.status === 'backlog')];
|
|
102
|
+
const allInProgress = [...inProgress, ...activeBugs.filter(b => b.status === 'in-progress')];
|
|
103
|
+
const allDone = [...done, ...doneBugs, ...doneGlobal];
|
|
69
104
|
|
|
70
105
|
const projectContent = `<div id="project-view" hidden>
|
|
71
106
|
<div id="project-dashboard">
|
|
72
|
-
${
|
|
107
|
+
${configPanel}
|
|
73
108
|
${StatsBox({
|
|
74
109
|
total: project.stories.total,
|
|
75
110
|
pending: project.stories.pending,
|
|
@@ -78,9 +113,9 @@ export function renderDashboard(dataModel) {
|
|
|
78
113
|
})}
|
|
79
114
|
${ProgressBar({ completed: project.stories.done, total: project.stories.total })}
|
|
80
115
|
<div class="kanban">
|
|
81
|
-
${KanbanColumn({ title: 'Pending', stories:
|
|
82
|
-
${KanbanColumn({ title: 'In Progress', stories:
|
|
83
|
-
${KanbanColumn({ title: 'Done', stories:
|
|
116
|
+
${KanbanColumn({ title: 'Pending', stories: allPending })}
|
|
117
|
+
${KanbanColumn({ title: 'In Progress', stories: allInProgress })}
|
|
118
|
+
${KanbanColumn({ title: 'Done', stories: allDone })}
|
|
84
119
|
</div>
|
|
85
120
|
</div>
|
|
86
121
|
<main class="content-area" id="project-content-area" hidden>
|