bmad-viewer 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/data-model.js +196 -41
package/package.json
CHANGED
package/src/data/data-model.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join, extname, basename, relative } from 'node:path';
|
|
2
|
+
import { join, extname, basename, relative, dirname } from 'node:path';
|
|
3
3
|
import { parseYaml } from '../parsers/parse-yaml.js';
|
|
4
4
|
import { parseMarkdownContent } from '../parsers/parse-markdown.js';
|
|
5
5
|
import { ErrorAggregator } from '../utils/error-aggregator.js';
|
|
@@ -24,65 +24,146 @@ export function buildDataModel(bmadDir) {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Build wiki catalog data by scanning _bmad directory structure.
|
|
27
|
-
*
|
|
27
|
+
* Dynamically discovers modules and supports both:
|
|
28
|
+
* - New structure: SKILL.md as entry point in skill directories
|
|
29
|
+
* - Old structure: agents/ and workflows/ subdirectories with direct .md files
|
|
28
30
|
*/
|
|
29
31
|
function buildWikiData(bmadPath, aggregator) {
|
|
30
32
|
const modules = [];
|
|
31
33
|
const allItems = [];
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
// Dynamically discover module directories (skip underscore-prefixed like _config)
|
|
36
|
+
let moduleDirs = [];
|
|
37
|
+
try {
|
|
38
|
+
const entries = readdirSync(bmadPath, { withFileTypes: true });
|
|
39
|
+
moduleDirs = entries
|
|
40
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('_'))
|
|
41
|
+
.map(e => e.name)
|
|
42
|
+
.sort();
|
|
43
|
+
} catch {
|
|
44
|
+
return { modules, allItems };
|
|
45
|
+
}
|
|
34
46
|
|
|
35
|
-
for (const modName of
|
|
47
|
+
for (const modName of moduleDirs) {
|
|
36
48
|
const modPath = join(bmadPath, modName);
|
|
37
|
-
if (!existsSync(modPath) || !statSync(modPath).isDirectory()) continue;
|
|
38
|
-
|
|
39
49
|
const moduleData = { id: modName, name: modName.toUpperCase(), groups: [] };
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
const groupMap = {};
|
|
51
|
+
|
|
52
|
+
// --- New structure: find SKILL.md files recursively ---
|
|
53
|
+
const skillFiles = findNamedFilesRecursive(modPath, 'SKILL.md');
|
|
54
|
+
const skillDirs = new Set(skillFiles.map(f => dirname(f)));
|
|
55
|
+
|
|
56
|
+
for (const filePath of skillFiles) {
|
|
57
|
+
const rel = relative(modPath, filePath).replace(/\\/g, '/');
|
|
58
|
+
const parts = rel.split('/'); // e.g. ["skill-name","SKILL.md"] or ["category","skill-name","SKILL.md"]
|
|
59
|
+
const skillDirName = parts[parts.length - 2];
|
|
60
|
+
const groupName = parts.length > 2 ? parts[0] : null;
|
|
61
|
+
const groupKey = groupName ?? '__root__';
|
|
62
|
+
const displayGroup = groupName ? formatName(groupName) : 'Skills';
|
|
63
|
+
|
|
64
|
+
const id = `${modName}/${rel.replace('/SKILL.md', '')}`;
|
|
65
|
+
const content = readMarkdownSafe(filePath, aggregator);
|
|
66
|
+
const type = inferSkillType(groupName, skillDirName);
|
|
67
|
+
const item = { id, name: formatName(skillDirName), type, path: filePath, ...content };
|
|
68
|
+
|
|
69
|
+
if (!groupMap[groupKey]) {
|
|
70
|
+
groupMap[groupKey] = { name: displayGroup, type: groupKey === '__root__' ? 'skill' : groupKey, items: [], _sortKey: groupName ?? '' };
|
|
54
71
|
}
|
|
72
|
+
groupMap[groupKey].items.push(item);
|
|
73
|
+
allItems.push(item);
|
|
55
74
|
}
|
|
56
75
|
|
|
57
|
-
//
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
// --- New structure: workflow.md files in dirs without SKILL.md ---
|
|
77
|
+
const workflowFiles = findNamedFilesRecursive(modPath, 'workflow.md')
|
|
78
|
+
.filter(f => !skillDirs.has(dirname(f)));
|
|
79
|
+
|
|
80
|
+
for (const filePath of workflowFiles) {
|
|
81
|
+
const rel = relative(modPath, filePath).replace(/\\/g, '/');
|
|
82
|
+
const parts = rel.split('/');
|
|
83
|
+
const skillDirName = parts[parts.length - 2];
|
|
84
|
+
const groupName = parts.length > 2 ? parts[0] : null;
|
|
85
|
+
// Use same groupKey as SKILL.md items so they merge into the same group
|
|
86
|
+
const groupKey = groupName ?? '__root__';
|
|
87
|
+
const displayGroup = groupName ? formatName(groupName) : 'Workflows';
|
|
88
|
+
|
|
89
|
+
const id = `${modName}/${rel.replace('/workflow.md', '')}`;
|
|
90
|
+
const content = readMarkdownSafe(filePath, aggregator);
|
|
91
|
+
const item = { id, name: formatName(skillDirName), type: 'workflow', path: filePath, ...content };
|
|
92
|
+
|
|
93
|
+
if (!groupMap[groupKey]) {
|
|
94
|
+
groupMap[groupKey] = { name: displayGroup, type: 'workflows', items: [], _sortKey: groupName ?? '' };
|
|
64
95
|
}
|
|
96
|
+
groupMap[groupKey].items.push(item);
|
|
97
|
+
allItems.push(item);
|
|
65
98
|
}
|
|
66
99
|
|
|
67
|
-
//
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
// --- Old structure (backward compat): only used if new-style SKILL.md scan found nothing ---
|
|
101
|
+
const newStyleFound = skillFiles.length > 0 || workflowFiles.length > 0;
|
|
102
|
+
if (!newStyleFound) {
|
|
103
|
+
// agents/ with direct .md files
|
|
104
|
+
const agentsPath = join(modPath, 'agents');
|
|
105
|
+
if (existsSync(agentsPath) && statSync(agentsPath).isDirectory()) {
|
|
106
|
+
const agentFiles = scanDirectMarkdownFiles(agentsPath);
|
|
107
|
+
if (agentFiles.length > 0) {
|
|
108
|
+
const groupKey = '__legacy_agents__';
|
|
109
|
+
if (!groupMap[groupKey]) {
|
|
110
|
+
groupMap[groupKey] = { name: 'Agents', type: 'agents', items: [], _sortKey: 'agents' };
|
|
111
|
+
}
|
|
112
|
+
for (const filePath of agentFiles) {
|
|
75
113
|
const name = basename(filePath, '.md');
|
|
76
|
-
const id = `${modName}/${
|
|
114
|
+
const id = `${modName}/agents/${name}`;
|
|
77
115
|
const content = readMarkdownSafe(filePath, aggregator);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
116
|
+
const item = { id, name: formatName(name), type: 'agent', path: filePath, ...content };
|
|
117
|
+
groupMap[groupKey].items.push(item);
|
|
118
|
+
allItems.push(item);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// workflows/ directory
|
|
124
|
+
const workflowsPath = join(modPath, 'workflows');
|
|
125
|
+
if (existsSync(workflowsPath) && statSync(workflowsPath).isDirectory()) {
|
|
126
|
+
const workflowItems = scanWorkflows(workflowsPath, modName, aggregator);
|
|
127
|
+
if (workflowItems.length > 0) {
|
|
128
|
+
const groupKey = '__legacy_workflows__';
|
|
129
|
+
if (!groupMap[groupKey]) {
|
|
130
|
+
groupMap[groupKey] = { name: 'Workflows', type: 'workflows', items: [], _sortKey: 'workflows' };
|
|
131
|
+
}
|
|
132
|
+
groupMap[groupKey].items.push(...workflowItems);
|
|
133
|
+
allItems.push(...workflowItems);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// other resource dirs
|
|
138
|
+
const otherDirs = ['tasks', 'resources', 'data', 'teams', 'testarch'];
|
|
139
|
+
for (const dirName of otherDirs) {
|
|
140
|
+
const dirPath = join(modPath, dirName);
|
|
141
|
+
if (existsSync(dirPath) && statSync(dirPath).isDirectory()) {
|
|
142
|
+
const files = scanDirectMarkdownFiles(dirPath);
|
|
143
|
+
if (files.length > 0) {
|
|
144
|
+
const groupKey = `__legacy_${dirName}__`;
|
|
145
|
+
if (!groupMap[groupKey]) {
|
|
146
|
+
groupMap[groupKey] = { name: formatName(dirName), type: dirName, items: [], _sortKey: dirName };
|
|
147
|
+
}
|
|
148
|
+
for (const filePath of files) {
|
|
149
|
+
const name = basename(filePath, '.md');
|
|
150
|
+
const id = `${modName}/${dirName}/${name}`;
|
|
151
|
+
const content = readMarkdownSafe(filePath, aggregator);
|
|
152
|
+
const item = { id, name: formatName(name), type: dirName, path: filePath, ...content };
|
|
153
|
+
groupMap[groupKey].items.push(item);
|
|
154
|
+
allItems.push(item);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
82
157
|
}
|
|
83
158
|
}
|
|
84
159
|
}
|
|
85
160
|
|
|
161
|
+
// Sort groups alphabetically and attach to module
|
|
162
|
+
moduleData.groups = Object.values(groupMap)
|
|
163
|
+
.filter(g => g.items.length > 0)
|
|
164
|
+
.sort((a, b) => a._sortKey.localeCompare(b._sortKey))
|
|
165
|
+
.map(({ _sortKey, ...g }) => g);
|
|
166
|
+
|
|
86
167
|
if (moduleData.groups.length > 0) {
|
|
87
168
|
modules.push(moduleData);
|
|
88
169
|
}
|
|
@@ -91,6 +172,18 @@ function buildWikiData(bmadPath, aggregator) {
|
|
|
91
172
|
return { modules, allItems };
|
|
92
173
|
}
|
|
93
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Infer the type of a skill based on its group/category name and skill name.
|
|
177
|
+
*/
|
|
178
|
+
function inferSkillType(groupName, skillName) {
|
|
179
|
+
const g = (groupName ?? '').toLowerCase();
|
|
180
|
+
const s = skillName.toLowerCase();
|
|
181
|
+
if (g.includes('agent') || s.includes('agent')) return 'agent';
|
|
182
|
+
if (g.includes('workflow') || g.includes('workflows')) return 'workflow';
|
|
183
|
+
if (g.includes('skill') || g.includes('skills')) return 'skill';
|
|
184
|
+
return 'skill';
|
|
185
|
+
}
|
|
186
|
+
|
|
94
187
|
/**
|
|
95
188
|
* Scan workflows directory. Workflows can be:
|
|
96
189
|
* - Direct .md files
|
|
@@ -276,10 +369,21 @@ function buildProjectData(outputPath, aggregator) {
|
|
|
276
369
|
...content,
|
|
277
370
|
});
|
|
278
371
|
|
|
279
|
-
// Parse stories from epics.md
|
|
372
|
+
// Parse stories and epics from epics.md
|
|
280
373
|
if (name === 'epics' && ext === '.md' && content.raw) {
|
|
281
374
|
const storyContents = parseStoriesFromEpics(content.raw, aggregator);
|
|
282
375
|
project.storyContents = storyContents;
|
|
376
|
+
|
|
377
|
+
// Build epics from markdown when no sprint-status.yaml was found
|
|
378
|
+
if (project.epics.length === 0) {
|
|
379
|
+
project.epics = parseEpicsFromMarkdown(content.raw, storyContents);
|
|
380
|
+
// Build story stats from epics.md stories
|
|
381
|
+
for (const epic of project.epics) {
|
|
382
|
+
project.stories.total += epic.stories.length;
|
|
383
|
+
project.stories.pending += epic.stories.length; // all backlog by default
|
|
384
|
+
project.storyList.push(...epic.stories);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
283
387
|
}
|
|
284
388
|
}
|
|
285
389
|
}
|
|
@@ -446,6 +550,36 @@ function parseStoriesFromEpics(raw, aggregator) {
|
|
|
446
550
|
return storyMap;
|
|
447
551
|
}
|
|
448
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Parse epics and their stories from epics.md markdown.
|
|
555
|
+
* Epics follow the pattern: ## Epic N: Title
|
|
556
|
+
* Stories follow the pattern: ### Story N.M: Title
|
|
557
|
+
*/
|
|
558
|
+
function parseEpicsFromMarkdown(raw, storyContents) {
|
|
559
|
+
const epicRegex = /^## Epic (\d+):\s*(.+)$/gm;
|
|
560
|
+
const epicMap = {};
|
|
561
|
+
let match;
|
|
562
|
+
|
|
563
|
+
while ((match = epicRegex.exec(raw)) !== null) {
|
|
564
|
+
const num = match[1];
|
|
565
|
+
const name = match[2].trim();
|
|
566
|
+
epicMap[num] = { id: `epic-${num}`, num, name, status: 'backlog', stories: [] };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Assign stories to their epics
|
|
570
|
+
for (const [key, storyData] of Object.entries(storyContents)) {
|
|
571
|
+
const epicNum = key.split('-')[0];
|
|
572
|
+
const story = { id: key, title: storyData.title, status: 'backlog', epic: epicNum };
|
|
573
|
+
if (epicMap[epicNum]) {
|
|
574
|
+
epicMap[epicNum].stories.push(story);
|
|
575
|
+
} else {
|
|
576
|
+
epicMap[epicNum] = { id: `epic-${epicNum}`, num: epicNum, name: `Epic ${epicNum}`, status: 'backlog', stories: [story] };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return Object.values(epicMap).sort((a, b) => Number(a.num) - Number(b.num));
|
|
581
|
+
}
|
|
582
|
+
|
|
449
583
|
/**
|
|
450
584
|
* Parse epic names from YAML comments like "# Epic 1: Fundación del Proyecto"
|
|
451
585
|
*/
|
|
@@ -511,6 +645,27 @@ function loadConfig(bmadPath, aggregator) {
|
|
|
511
645
|
return config;
|
|
512
646
|
}
|
|
513
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Recursively find all files with a specific filename within a directory.
|
|
650
|
+
*/
|
|
651
|
+
function findNamedFilesRecursive(dir, targetName) {
|
|
652
|
+
const found = [];
|
|
653
|
+
try {
|
|
654
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
655
|
+
for (const entry of entries) {
|
|
656
|
+
const fullPath = join(dir, entry.name);
|
|
657
|
+
if (entry.isDirectory()) {
|
|
658
|
+
found.push(...findNamedFilesRecursive(fullPath, targetName));
|
|
659
|
+
} else if (entry.isFile() && entry.name === targetName) {
|
|
660
|
+
found.push(fullPath);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
} catch {
|
|
664
|
+
// Ignore access errors
|
|
665
|
+
}
|
|
666
|
+
return found.sort();
|
|
667
|
+
}
|
|
668
|
+
|
|
514
669
|
/**
|
|
515
670
|
* Scan a directory for direct .md files (non-recursive).
|
|
516
671
|
*/
|