bmad-viewer 0.1.8 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmad-viewer",
3
- "version": "0.1.8",
3
+ "version": "0.3.0",
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": {
package/public/client.js CHANGED
@@ -5,6 +5,7 @@
5
5
  var contentMap = window.__BMAD_CONTENT__ || {};
6
6
  var wikiWelcomeHtml = '';
7
7
  var wikiBreadcrumbHtml = '';
8
+ var pendingHighlight = null;
8
9
 
9
10
  /* ── Hash Router ── */
10
11
  function parseHash() {
@@ -82,6 +83,11 @@
82
83
  });
83
84
  breadcrumb.innerHTML = crumbs.join(' <span class="breadcrumb__sep">&rsaquo;</span> ');
84
85
  }
86
+
87
+ if (pendingHighlight) {
88
+ highlightAndScroll(contentBody, pendingHighlight);
89
+ pendingHighlight = null;
90
+ }
85
91
  }
86
92
 
87
93
  function loadProjectContent(id) {
@@ -106,6 +112,62 @@
106
112
  ' <span class="breadcrumb__sep">&rsaquo;</span> ' +
107
113
  '<span class="breadcrumb__current">' + escapeText(item.name) + '</span>';
108
114
  }
115
+
116
+ if (pendingHighlight) {
117
+ highlightAndScroll(contentBody, pendingHighlight);
118
+ pendingHighlight = null;
119
+ }
120
+ }
121
+
122
+ function highlightAndScroll(container, query) {
123
+ var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false);
124
+ var q = query.toLowerCase();
125
+ var firstMark = null;
126
+
127
+ var nodesToProcess = [];
128
+ while (walker.nextNode()) {
129
+ var node = walker.currentNode;
130
+ if (node.nodeValue && node.nodeValue.toLowerCase().includes(q)) {
131
+ nodesToProcess.push(node);
132
+ }
133
+ }
134
+
135
+ for (var i = 0; i < nodesToProcess.length; i++) {
136
+ var textNode = nodesToProcess[i];
137
+ var text = textNode.nodeValue;
138
+ var idx = text.toLowerCase().indexOf(q);
139
+ if (idx === -1) continue;
140
+
141
+ var before = text.substring(0, idx);
142
+ var match = text.substring(idx, idx + query.length);
143
+ var after = text.substring(idx + query.length);
144
+
145
+ var mark = document.createElement('mark');
146
+ mark.className = 'search-highlight';
147
+ mark.textContent = match;
148
+
149
+ var parent = textNode.parentNode;
150
+ if (before) parent.insertBefore(document.createTextNode(before), textNode);
151
+ parent.insertBefore(mark, textNode);
152
+ if (after) parent.insertBefore(document.createTextNode(after), textNode);
153
+ parent.removeChild(textNode);
154
+
155
+ if (!firstMark) firstMark = mark;
156
+ }
157
+
158
+ if (firstMark) {
159
+ setTimeout(function () {
160
+ firstMark.scrollIntoView({ behavior: 'smooth', block: 'center' });
161
+ }, 50);
162
+
163
+ // Clear highlights after 4 seconds
164
+ setTimeout(function () {
165
+ container.querySelectorAll('mark.search-highlight').forEach(function (el) {
166
+ var txt = document.createTextNode(el.textContent);
167
+ el.parentNode.replaceChild(txt, el);
168
+ });
169
+ }, 4000);
170
+ }
109
171
  }
110
172
 
111
173
  function showWikiWelcome() {
@@ -190,6 +252,18 @@
190
252
  if (modal) modal.hidden = true;
191
253
  }
192
254
 
255
+ // Cache stripped text for content search
256
+ var textCache = {};
257
+ function getPlainText(item) {
258
+ if (!item._cacheKey) item._cacheKey = item.name + '|' + item.type;
259
+ if (textCache[item._cacheKey] !== undefined) return textCache[item._cacheKey];
260
+ var tmp = document.createElement('div');
261
+ tmp.innerHTML = item.html || '';
262
+ var text = (tmp.textContent || tmp.innerText || '').toLowerCase();
263
+ textCache[item._cacheKey] = text;
264
+ return text;
265
+ }
266
+
193
267
  function handleSearch(query) {
194
268
  var results = document.getElementById('search-results');
195
269
  if (!results) return;
@@ -202,20 +276,21 @@
202
276
  var q = query.toLowerCase();
203
277
  var matches = [];
204
278
 
205
- // Search through content map
279
+ // Search through content map — name, type, module, group, and body content
206
280
  for (var id in contentMap) {
207
281
  var item = contentMap[id];
208
282
  var nameMatch = (item.name || '').toLowerCase().includes(q);
209
283
  var typeMatch = (item.type || '').toLowerCase().includes(q);
210
284
  var moduleMatch = (item.module || '').toLowerCase().includes(q);
211
285
  var groupMatch = (item.group || '').toLowerCase().includes(q);
286
+ var contentMatch = !nameMatch && !typeMatch && !moduleMatch && !groupMatch && getPlainText(item).includes(q);
212
287
 
213
- if (nameMatch || typeMatch || moduleMatch || groupMatch) {
214
- matches.push({ id: id, item: item, score: nameMatch ? 1 : 0 });
288
+ if (nameMatch || typeMatch || moduleMatch || groupMatch || contentMatch) {
289
+ matches.push({ id: id, item: item, score: nameMatch ? 2 : (contentMatch ? 0 : 1), contentMatch: contentMatch });
215
290
  }
216
291
  }
217
292
 
218
- // Sort by name match first
293
+ // Sort: name matches first, then metadata, then content
219
294
  matches.sort(function (a, b) { return b.score - a.score; });
220
295
 
221
296
  if (matches.length === 0) {
@@ -233,15 +308,45 @@
233
308
  grouped[type].push(m);
234
309
  });
235
310
 
311
+ var categoryLabels = {
312
+ 'planning': 'Planning', 'research': 'Research', 'analysis': 'Analysis',
313
+ 'test-arch': 'Test Architecture', 'cis': 'CIS Sessions',
314
+ 'bmb-creation': 'BMB Creations', 'diagram': 'Diagrams',
315
+ 'story': 'Stories', 'agent': 'Agents', 'workflow': 'Workflows',
316
+ 'other': 'Other'
317
+ };
318
+
236
319
  var html = '';
237
320
  for (var type in grouped) {
238
- html += '<div class="search-modal__group-label">' + escapeText(type.charAt(0).toUpperCase() + type.slice(1)) + 's</div>';
321
+ var groupLabel = categoryLabels[type] || (type.charAt(0).toUpperCase() + type.slice(1) + 's');
322
+ html += '<div class="search-modal__group-label">' + escapeText(groupLabel) + '</div>';
239
323
  grouped[type].forEach(function (m) {
240
324
  var view = m.id.startsWith('artifact/') || m.id.startsWith('story/') ? 'project' : 'wiki';
325
+ var subtitle = m.item.module
326
+ ? (m.item.module + (m.item.group ? ' > ' + m.item.group : ''))
327
+ : (categoryLabels[m.item.type] || m.item.type || '');
328
+ var snippet = '';
329
+ if (m.contentMatch) {
330
+ var text = getPlainText(m.item);
331
+ var idx = text.indexOf(q);
332
+ if (idx !== -1) {
333
+ var start = Math.max(0, idx - 30);
334
+ var end = Math.min(text.length, idx + q.length + 50);
335
+ var raw = text.substring(start, end).replace(/\s+/g, ' ');
336
+ snippet = '<span class="search-modal__result-snippet">' +
337
+ (start > 0 ? '...' : '') +
338
+ escapeText(raw.substring(0, idx - start)) +
339
+ '<mark>' + escapeText(raw.substring(idx - start, idx - start + q.length)) + '</mark>' +
340
+ escapeText(raw.substring(idx - start + q.length)) +
341
+ (end < text.length ? '...' : '') +
342
+ '</span>';
343
+ }
344
+ }
241
345
  html +=
242
- '<a class="search-modal__result" href="#' + view + '/' + m.id + '">' +
346
+ '<a class="search-modal__result" href="#' + view + '/' + m.id + '"' + (m.contentMatch ? ' data-content-match="1"' : '') + '>' +
243
347
  '<span class="search-modal__result-title">' + escapeText(m.item.name) + '</span>' +
244
- '<span class="search-modal__result-type">' + escapeText((m.item.module || '') + (m.item.group ? ' > ' + m.item.group : '')) + '</span>' +
348
+ '<span class="search-modal__result-type">' + escapeText(subtitle) + '</span>' +
349
+ snippet +
245
350
  '</a>';
246
351
  });
247
352
  }
@@ -367,7 +472,13 @@
367
472
  if (searchResults) {
368
473
  searchResults.addEventListener('click', function (e) {
369
474
  var result = e.target.closest('.search-modal__result');
370
- if (result) closeSearch();
475
+ if (result) {
476
+ if (result.dataset.contentMatch) {
477
+ var input = document.getElementById('search-input');
478
+ pendingHighlight = input ? input.value : null;
479
+ }
480
+ closeSearch();
481
+ }
371
482
  });
372
483
  }
373
484
 
package/public/styles.css CHANGED
@@ -136,10 +136,14 @@ body{font-family:var(--font-family);background:var(--bg);color:var(--text);line-
136
136
  .search-modal__input{width:100%;padding:16px 20px;border:none;border-bottom:1px solid var(--border);background:transparent;font-size:1.1rem;color:var(--text);outline:none;font-family:var(--font-family)}
137
137
  .search-modal__results{max-height:300px;overflow-y:auto}
138
138
  .search-modal__group-label{padding:8px 20px 4px;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted)}
139
- .search-modal__result{display:flex;align-items:center;justify-content:space-between;padding:10px 20px;cursor:pointer;text-decoration:none;color:var(--text)}
139
+ .search-modal__result{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;padding:10px 20px;cursor:pointer;text-decoration:none;color:var(--text);gap:2px 8px}
140
140
  .search-modal__result:hover{background:var(--accent-soft)}
141
141
  .search-modal__result-title{font-weight:500}
142
142
  .search-modal__result-type{font-size:.75rem;color:var(--text-muted)}
143
+ .search-modal__result-snippet{width:100%;font-size:.75rem;color:var(--text-muted);line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
144
+ .search-modal__result-snippet mark{background:rgba(245,158,11,.3);color:var(--text);border-radius:2px;padding:0 1px}
145
+ mark.search-highlight{background:rgba(245,158,11,.4);color:var(--text);border-radius:3px;padding:1px 2px;animation:highlight-fade 4s ease-out forwards}
146
+ @keyframes highlight-fade{0%,70%{background:rgba(245,158,11,.4)}100%{background:transparent}}
143
147
  .search-modal__no-results{padding:20px;text-align:center;color:var(--text-muted);font-size:.9rem}
144
148
  .search-modal__footer{display:flex;gap:16px;padding:10px 20px;border-top:1px solid var(--border);font-size:.75rem;color:var(--text-muted)}
145
149
  /* Warning Banner */
@@ -1,14 +1,25 @@
1
1
  import { escapeHtml } from '../utils/html-escape.js';
2
2
 
3
+ const CATEGORY_ORDER = [
4
+ { key: 'planning', label: 'Planning', icon: '&#128203;' },
5
+ { key: 'research', label: 'Research', icon: '&#128270;' },
6
+ { key: 'analysis', label: 'Analysis', icon: '&#128161;' },
7
+ { key: 'test-arch', label: 'Test Architecture', icon: '&#128295;' },
8
+ { key: 'cis', label: 'CIS Sessions', icon: '&#10024;' },
9
+ { key: 'bmb-creation', label: 'BMB Creations', icon: '&#128296;' },
10
+ { key: 'diagram', label: 'Diagrams', icon: '&#128202;' },
11
+ { key: 'other', label: 'Other', icon: '&#128196;' },
12
+ ];
13
+
3
14
  /**
4
15
  * Render sidebar navigation tree.
5
16
  * Wiki lens: Modules > Groups > Items
6
- * Project lens: Epics with stories + Artifacts section
17
+ * Project lens: Epics with stories + Artifact categories
7
18
  *
8
- * @param {{modules: Array, artifacts: Array, epics: Array}} props
19
+ * @param {{modules: Array, artifacts: Array, epics: Array, artifactGroups: object}} props
9
20
  * @returns {string} HTML string
10
21
  */
11
- export function SidebarNav({ modules, artifacts, epics }) {
22
+ export function SidebarNav({ modules, artifacts, epics, artifactGroups }) {
12
23
  // Wiki sidebar content
13
24
  const modulesList = (modules || [])
14
25
  .map(
@@ -46,7 +57,7 @@ export function SidebarNav({ modules, artifacts, epics }) {
46
57
  )
47
58
  .join('\n');
48
59
 
49
- // Project sidebar: Sprint Dashboard link + Epics with stories + Artifacts
60
+ // Project sidebar: Sprint Dashboard link + Epics with stories + Categorized Artifacts
50
61
  const epicsList = (epics || [])
51
62
  .map(
52
63
  (epic) => `
@@ -74,16 +85,36 @@ export function SidebarNav({ modules, artifacts, epics }) {
74
85
  )
75
86
  .join('\n');
76
87
 
77
- const artifactsList = (artifacts || [])
78
- .map(
79
- (art) => `
80
- <li class="sidebar-nav__item">
81
- <a href="#project/${escapeHtml(art.id)}" class="sidebar-nav__link sidebar-nav__link--artifact" data-id="${escapeHtml(art.id)}">
82
- <span class="sidebar-nav__type-icon">${getArtifactIcon(art.name)}</span>
83
- ${escapeHtml(art.name || 'Untitled')}
84
- </a>
85
- </li>`,
86
- )
88
+ // Build categorized artifact sections
89
+ const groups = artifactGroups || {};
90
+ const categorySections = CATEGORY_ORDER
91
+ .filter((cat) => groups[cat.key] && groups[cat.key].length > 0)
92
+ .map((cat) => {
93
+ const items = groups[cat.key];
94
+ const itemsHtml = items
95
+ .map(
96
+ (art) => `
97
+ <li class="sidebar-nav__item">
98
+ <a href="#project/${escapeHtml(art.id)}" class="sidebar-nav__link sidebar-nav__link--artifact" data-id="${escapeHtml(art.id)}">
99
+ <span class="sidebar-nav__type-icon">${getArtifactIcon(art.name, cat.key)}</span>
100
+ ${escapeHtml(art.name || 'Untitled')}
101
+ </a>
102
+ </li>`,
103
+ )
104
+ .join('\n');
105
+
106
+ return `
107
+ <li class="sidebar-nav__module">
108
+ <button class="sidebar-nav__toggle" aria-expanded="false">
109
+ <span class="sidebar-nav__arrow">&#9656;</span>
110
+ <span class="sidebar-nav__type-icon">${cat.icon}</span>
111
+ ${escapeHtml(cat.label)} <span class="sidebar-nav__count">${items.length}</span>
112
+ </button>
113
+ <ul class="sidebar-nav__items" hidden>
114
+ ${itemsHtml}
115
+ </ul>
116
+ </li>`;
117
+ })
87
118
  .join('\n');
88
119
 
89
120
  return `<nav class="sidebar-nav" aria-label="BMAD Navigation">
@@ -99,8 +130,8 @@ export function SidebarNav({ modules, artifacts, epics }) {
99
130
  </a>
100
131
  ${epicsList ? `<h2 class="sidebar-nav__heading">Epics</h2>
101
132
  <ul class="sidebar-nav__list">${epicsList}</ul>` : ''}
102
- ${artifactsList ? `<h2 class="sidebar-nav__heading">Artifacts</h2>
103
- <ul class="sidebar-nav__list">${artifactsList}</ul>` : ''}
133
+ ${categorySections ? `<h2 class="sidebar-nav__heading">Artifacts</h2>
134
+ <ul class="sidebar-nav__list">${categorySections}</ul>` : ''}
104
135
  </div>
105
136
  </nav>`;
106
137
  }
@@ -118,6 +149,12 @@ function getTypeIcon(type) {
118
149
  case 'story': return '&#128203;';
119
150
  case 'data': return '&#128202;';
120
151
  case 'testarch': return '&#128295;';
152
+ case 'research': return '&#128270;';
153
+ case 'analysis': return '&#128161;';
154
+ case 'test-arch': return '&#128295;';
155
+ case 'cis': return '&#10024;';
156
+ case 'bmb-creation': return '&#128296;';
157
+ case 'diagram': return '&#128202;';
121
158
  default: return '&#128196;';
122
159
  }
123
160
  }
@@ -134,9 +171,19 @@ function getEpicStatusIcon(status) {
134
171
  }
135
172
 
136
173
  /**
137
- * Get icon for artifact by name.
174
+ * Get icon for artifact by name and category.
138
175
  */
139
- function getArtifactIcon(name) {
176
+ function getArtifactIcon(name, category) {
177
+ if (category) {
178
+ switch (category) {
179
+ case 'research': return '&#128270;';
180
+ case 'analysis': return '&#128161;';
181
+ case 'test-arch': return '&#128295;';
182
+ case 'cis': return '&#10024;';
183
+ case 'bmb-creation': return '&#128296;';
184
+ case 'diagram': return '&#128202;';
185
+ }
186
+ }
140
187
  const lower = (name || '').toLowerCase();
141
188
  if (lower.includes('prd')) return '&#128220;';
142
189
  if (lower.includes('architecture')) return '&#127959;';
@@ -1,5 +1,5 @@
1
1
  import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
2
- import { join, extname, basename } 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
@@ -257,22 +350,27 @@ function buildProjectData(outputPath, aggregator) {
257
350
  }
258
351
  }
259
352
 
260
- // Scan planning artifacts
353
+ // Scan planning artifacts (recursive to catch research/ subdir, and include .html files)
261
354
  const planningDir = join(outputPath, 'planning-artifacts');
262
355
  if (existsSync(planningDir)) {
263
- const files = scanDirectMarkdownFiles(planningDir);
356
+ const files = scanFilesRecursive(planningDir, ['.md', '.html']);
264
357
  for (const file of files) {
265
- const name = basename(file, '.md');
266
- const content = readMarkdownSafe(file, aggregator);
358
+ const ext = extname(file).toLowerCase();
359
+ const name = basename(file, ext);
360
+ const content = ext === '.html'
361
+ ? readHtmlSafe(file)
362
+ : readMarkdownSafe(file, aggregator);
363
+ const type = categorizeArtifact(file, outputPath);
267
364
  project.artifacts.push({
268
365
  id: `artifact/${name}`,
269
366
  name: formatName(name),
270
367
  path: file,
368
+ type,
271
369
  ...content,
272
370
  });
273
371
 
274
372
  // Parse stories from epics.md
275
- if (name === 'epics' && content.raw) {
373
+ if (name === 'epics' && ext === '.md' && content.raw) {
276
374
  const storyContents = parseStoriesFromEpics(content.raw, aggregator);
277
375
  project.storyContents = storyContents;
278
376
  }
@@ -282,7 +380,6 @@ function buildProjectData(outputPath, aggregator) {
282
380
  // Scan implementation artifact story files (direct .md files in impl dir + stories/ subdir)
283
381
  const implDir = join(outputPath, 'implementation-artifacts');
284
382
  if (existsSync(implDir)) {
285
- // Scan direct .md files in implementation-artifacts (story files live here)
286
383
  const implFiles = scanDirectMarkdownFiles(implDir);
287
384
  for (const file of implFiles) {
288
385
  const name = basename(file, '.md');
@@ -296,7 +393,6 @@ function buildProjectData(outputPath, aggregator) {
296
393
  });
297
394
  }
298
395
 
299
- // Also check stories/ subdirectory if it exists
300
396
  const storyDir = join(implDir, 'stories');
301
397
  if (existsSync(storyDir)) {
302
398
  const files = scanDirectMarkdownFiles(storyDir);
@@ -314,6 +410,84 @@ function buildProjectData(outputPath, aggregator) {
314
410
  }
315
411
  }
316
412
 
413
+ // Scan analysis/ directory
414
+ const analysisDir = join(outputPath, 'analysis');
415
+ if (existsSync(analysisDir)) {
416
+ const files = scanDirectFiles(analysisDir, ['.md']);
417
+ for (const file of files) {
418
+ const name = basename(file, '.md');
419
+ const content = readMarkdownSafe(file, aggregator);
420
+ project.artifacts.push({
421
+ id: `artifact/${name}`,
422
+ name: formatName(name),
423
+ path: file,
424
+ type: 'analysis',
425
+ ...content,
426
+ });
427
+ }
428
+ }
429
+
430
+ // Scan excalidraw-diagrams/ directory
431
+ const excalidrawDir = join(outputPath, 'excalidraw-diagrams');
432
+ if (existsSync(excalidrawDir)) {
433
+ const files = scanDirectFiles(excalidrawDir, ['.excalidraw']);
434
+ for (const file of files) {
435
+ const name = basename(file, '.excalidraw');
436
+ const content = readExcalidrawSafe(file);
437
+ project.artifacts.push({
438
+ id: `artifact/${name}`,
439
+ name: formatName(name),
440
+ path: file,
441
+ type: 'diagram',
442
+ ...content,
443
+ });
444
+ }
445
+ }
446
+
447
+ // Scan bmb-creations/ directory (recursive, .md + .yaml)
448
+ const bmbDir = join(outputPath, 'bmb-creations');
449
+ if (existsSync(bmbDir)) {
450
+ const files = scanFilesRecursive(bmbDir, ['.md', '.yaml']);
451
+ for (const file of files) {
452
+ const ext = extname(file).toLowerCase();
453
+ const name = basename(file, ext);
454
+ const content = ext === '.yaml'
455
+ ? readYamlSafe(file)
456
+ : readMarkdownSafe(file, aggregator);
457
+ project.artifacts.push({
458
+ id: `artifact/${name}`,
459
+ name: formatName(name),
460
+ path: file,
461
+ type: 'bmb-creation',
462
+ ...content,
463
+ });
464
+ }
465
+ }
466
+
467
+ // Scan root-level files in _bmad-output/ (CIS sessions, test-arch outputs, etc.)
468
+ const rootFiles = scanDirectFiles(outputPath, ['.md']);
469
+ for (const file of rootFiles) {
470
+ const name = basename(file, '.md');
471
+ const content = readMarkdownSafe(file, aggregator);
472
+ const type = categorizeArtifact(file, outputPath);
473
+ project.artifacts.push({
474
+ id: `artifact/${name}`,
475
+ name: formatName(name),
476
+ path: file,
477
+ type,
478
+ ...content,
479
+ });
480
+ }
481
+
482
+ // Build artifactGroups by category (excluding stories which are shown under epics)
483
+ project.artifactGroups = {};
484
+ for (const art of project.artifacts) {
485
+ if (art.type === 'story') continue;
486
+ const cat = art.type || 'other';
487
+ if (!project.artifactGroups[cat]) project.artifactGroups[cat] = [];
488
+ project.artifactGroups[cat].push(art);
489
+ }
490
+
317
491
  return project;
318
492
  }
319
493
 
@@ -430,6 +604,27 @@ function loadConfig(bmadPath, aggregator) {
430
604
  return config;
431
605
  }
432
606
 
607
+ /**
608
+ * Recursively find all files with a specific filename within a directory.
609
+ */
610
+ function findNamedFilesRecursive(dir, targetName) {
611
+ const found = [];
612
+ try {
613
+ const entries = readdirSync(dir, { withFileTypes: true });
614
+ for (const entry of entries) {
615
+ const fullPath = join(dir, entry.name);
616
+ if (entry.isDirectory()) {
617
+ found.push(...findNamedFilesRecursive(fullPath, targetName));
618
+ } else if (entry.isFile() && entry.name === targetName) {
619
+ found.push(fullPath);
620
+ }
621
+ }
622
+ } catch {
623
+ // Ignore access errors
624
+ }
625
+ return found.sort();
626
+ }
627
+
433
628
  /**
434
629
  * Scan a directory for direct .md files (non-recursive).
435
630
  */
@@ -448,6 +643,152 @@ function scanDirectMarkdownFiles(dir) {
448
643
  return files.sort();
449
644
  }
450
645
 
646
+ /**
647
+ * Scan a directory for direct files matching given extensions (non-recursive).
648
+ */
649
+ function scanDirectFiles(dir, extensions) {
650
+ const files = [];
651
+ try {
652
+ const entries = readdirSync(dir, { withFileTypes: true });
653
+ for (const entry of entries) {
654
+ if (entry.isFile() && extensions.includes(extname(entry.name).toLowerCase())) {
655
+ files.push(join(dir, entry.name));
656
+ }
657
+ }
658
+ } catch {
659
+ // Ignore
660
+ }
661
+ return files.sort();
662
+ }
663
+
664
+ /**
665
+ * Recursively scan a directory for files matching given extensions.
666
+ */
667
+ function scanFilesRecursive(dir, extensions) {
668
+ const files = [];
669
+ try {
670
+ const entries = readdirSync(dir, { withFileTypes: true });
671
+ for (const entry of entries) {
672
+ const fullPath = join(dir, entry.name);
673
+ if (entry.isDirectory()) {
674
+ files.push(...scanFilesRecursive(fullPath, extensions));
675
+ } else if (entry.isFile() && extensions.includes(extname(entry.name).toLowerCase())) {
676
+ files.push(fullPath);
677
+ }
678
+ }
679
+ } catch {
680
+ // Ignore
681
+ }
682
+ return files.sort();
683
+ }
684
+
685
+ /**
686
+ * Read an Excalidraw file and produce an HTML viewer using the Excalidraw React component via CDN.
687
+ */
688
+ function readExcalidrawSafe(filePath) {
689
+ try {
690
+ const raw = readFileSync(filePath, 'utf8');
691
+ const sceneData = JSON.parse(raw);
692
+
693
+ // Escape the JSON for safe embedding in HTML
694
+ const escapedJson = JSON.stringify(sceneData)
695
+ .replace(/&/g, '&amp;')
696
+ .replace(/</g, '&lt;')
697
+ .replace(/>/g, '&gt;')
698
+ .replace(/"/g, '&quot;');
699
+
700
+ const html = `<div class="excalidraw-viewer" style="width:100%;height:70vh;border:1px solid var(--border-color,#ddd);border-radius:8px;overflow:hidden;">
701
+ <iframe style="width:100%;height:100%;border:none;" srcdoc="<!DOCTYPE html>
702
+ <html>
703
+ <head>
704
+ <meta charset='utf-8'>
705
+ <style>
706
+ * { margin:0; padding:0; box-sizing:border-box; }
707
+ html, body, #root { width:100%; height:100%; }
708
+ .excalidraw .App-menu_top .buttonList { display:none; }
709
+ </style>
710
+ </head>
711
+ <body>
712
+ <div id='root'></div>
713
+ <script src='https://unpkg.com/react@18/umd/react.production.min.js'><\/script>
714
+ <script src='https://unpkg.com/react-dom@18/umd/react-dom.production.min.js'><\/script>
715
+ <script src='https://unpkg.com/@excalidraw/excalidraw/dist/excalidraw.production.min.js'><\/script>
716
+ <script>
717
+ var scene = JSON.parse(decodeURIComponent(&quot;${encodeURIComponent(JSON.stringify(sceneData))}&quot;));
718
+ var App = function() {
719
+ return React.createElement(ExcalidrawLib.Excalidraw, {
720
+ initialData: { elements: scene.elements || [], appState: { viewBackgroundColor: scene.appState?.viewBackgroundColor || '#ffffff', theme: 'light' }, files: scene.files || {} },
721
+ viewModeEnabled: true,
722
+ zenModeEnabled: true,
723
+ gridModeEnabled: false,
724
+ UIOptions: { canvasActions: { changeViewBackgroundColor: false, clearCanvas: false, export: false, loadScene: false, saveToActiveFile: false, toggleTheme: false, saveAsImage: false } }
725
+ });
726
+ };
727
+ ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));
728
+ <\/script>
729
+ </body>
730
+ </html>"></iframe></div>`;
731
+
732
+ return { html, frontmatter: null, raw };
733
+ } catch {
734
+ return { html: '<p>Error loading Excalidraw file</p>', frontmatter: null, raw: '' };
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Read an HTML file and embed it in an isolated iframe.
740
+ */
741
+ function readHtmlSafe(filePath) {
742
+ try {
743
+ const raw = readFileSync(filePath, 'utf8');
744
+ // Escape for srcdoc attribute
745
+ const escaped = raw
746
+ .replace(/&/g, '&amp;')
747
+ .replace(/"/g, '&quot;');
748
+ const html = `<div class="html-artifact-viewer" style="width:100%;height:80vh;border:1px solid var(--border-color,#ddd);border-radius:8px;overflow:hidden;">
749
+ <iframe style="width:100%;height:100%;border:none;" srcdoc="${escaped}"></iframe></div>`;
750
+ return { html, frontmatter: null, raw };
751
+ } catch {
752
+ return { html: '<p>Error loading HTML file</p>', frontmatter: null, raw: '' };
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Read a YAML file and render it as formatted code block.
758
+ */
759
+ function readYamlSafe(filePath) {
760
+ try {
761
+ const raw = readFileSync(filePath, 'utf8');
762
+ const escaped = raw.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
763
+ const html = `<pre><code class="language-yaml">${escaped}</code></pre>`;
764
+ return { html, frontmatter: null, raw };
765
+ } catch {
766
+ return { html: '<p>Error loading YAML file</p>', frontmatter: null, raw: '' };
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Categorize an artifact based on its path relative to _bmad-output.
772
+ */
773
+ function categorizeArtifact(filePath, outputPath) {
774
+ const rel = relative(outputPath, filePath).replace(/\\/g, '/');
775
+
776
+ if (rel.startsWith('planning-artifacts/research/')) return 'research';
777
+ if (rel.startsWith('planning-artifacts/')) return 'planning';
778
+ if (rel.startsWith('implementation-artifacts/')) return 'story';
779
+ if (rel.startsWith('analysis/')) return 'analysis';
780
+ if (rel.startsWith('excalidraw-diagrams/')) return 'diagram';
781
+ if (rel.startsWith('bmb-creations/')) return 'bmb-creation';
782
+
783
+ // Root-level files — categorize by filename prefix
784
+ const name = basename(filePath).toLowerCase();
785
+ if (/^(test-design|test-review|atdd-|automation-|traceability-|gate-decision-|nfr-)/.test(name)) return 'test-arch';
786
+ if (/^(design-thinking-|innovation-strategy-|problem-solution-|story-)/.test(name)) return 'cis';
787
+ if (/^brainstorming-/.test(name)) return 'analysis';
788
+
789
+ return 'other';
790
+ }
791
+
451
792
  /**
452
793
  * Format a kebab-case name to Title Case.
453
794
  */
@@ -18,6 +18,7 @@ export function renderDashboard(dataModel) {
18
18
  modules: wiki.modules,
19
19
  artifacts: project.artifacts,
20
20
  epics: project.epics,
21
+ artifactGroups: project.artifactGroups,
21
22
  });
22
23
 
23
24
  // Build content data JSON for client-side rendering