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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmad-viewer",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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": {
@@ -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
- * Scans each module (core, bmm, bmb, cis) for agents and workflows.
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
- const moduleNames = ['core', 'bmm', 'bmb', 'cis'];
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 moduleNames) {
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
- // Scan agents
42
- const agentsPath = join(modPath, 'agents');
43
- if (existsSync(agentsPath) && statSync(agentsPath).isDirectory()) {
44
- const agentFiles = scanDirectMarkdownFiles(agentsPath);
45
- if (agentFiles.length > 0) {
46
- const items = agentFiles.map((filePath) => {
47
- const name = basename(filePath, '.md');
48
- const id = `${modName}/agents/${name}`;
49
- const content = readMarkdownSafe(filePath, aggregator);
50
- return { id, name: formatName(name), type: 'agent', path: filePath, ...content };
51
- });
52
- moduleData.groups.push({ name: 'Agents', type: 'agents', items });
53
- allItems.push(...items);
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
- // Scan workflows
58
- const workflowsPath = join(modPath, 'workflows');
59
- if (existsSync(workflowsPath) && statSync(workflowsPath).isDirectory()) {
60
- const workflowItems = scanWorkflows(workflowsPath, modName, aggregator);
61
- if (workflowItems.length > 0) {
62
- moduleData.groups.push({ name: 'Workflows', type: 'workflows', items: workflowItems });
63
- allItems.push(...workflowItems);
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
- // Scan other resource directories
68
- const otherDirs = ['tasks', 'resources', 'data', 'teams', 'testarch'];
69
- for (const dirName of otherDirs) {
70
- const dirPath = join(modPath, dirName);
71
- if (existsSync(dirPath) && statSync(dirPath).isDirectory()) {
72
- const files = scanDirectMarkdownFiles(dirPath);
73
- if (files.length > 0) {
74
- const items = files.map((filePath) => {
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}/${dirName}/${name}`;
114
+ const id = `${modName}/agents/${name}`;
77
115
  const content = readMarkdownSafe(filePath, aggregator);
78
- return { id, name: formatName(name), type: dirName, path: filePath, ...content };
79
- });
80
- moduleData.groups.push({ name: formatName(dirName), type: dirName, items });
81
- allItems.push(...items);
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
  */