@lucasviola/specwiki 1.0.1 → 1.0.2

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.
@@ -1,10 +1,7 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- const BMAD_HELP_PATH = "_bmad/_config/bmad-help.csv";
4
1
  const CATEGORY_PATH_PREFIXES = {
5
- "agent-skills": ".agents/skills/",
6
- "cursor-skills": ".cursor/skills/",
7
2
  "cursor-rules": ".cursor/rules/",
3
+ "cursor-skills": ".cursor/skills/",
4
+ "agent-skills": ".agents/skills/",
8
5
  "bmad-output": "_bmad-output/",
9
6
  specs: "specs/",
10
7
  spec: "spec/",
@@ -14,595 +11,314 @@ const CATEGORY_PATH_PREFIXES = {
14
11
  plans: "docs/plans/",
15
12
  requirements: "requirements/",
16
13
  github: ".github/",
14
+ root: "",
17
15
  };
18
16
  const FOLDER_LABELS = {
17
+ planning: "Planning",
19
18
  "planning-artifacts": "Planning",
20
19
  "implementation-artifacts": "Implementation Stories",
20
+ "implementation-stories": "Implementation Stories",
21
+ "epic-context": "Epic Context",
21
22
  discovery: "Discovery",
22
23
  research: "Research",
23
- prd: "PRD",
24
- architecture: "Architecture",
25
- readiness: "Readiness",
26
- epics: "Epics",
27
- };
28
- const PHASE_GROUPS = {
29
- "1-analysis": { key: "phase-analysis", label: "Analysis", sortOrder: 1 },
30
- "2-planning": { key: "phase-planning", label: "Planning", sortOrder: 2 },
31
- "3-solutioning": {
32
- key: "phase-solutioning",
33
- label: "Solutioning",
34
- sortOrder: 3,
35
- },
36
- "4-implementation": {
37
- key: "phase-implementation",
38
- label: "Implementation",
39
- sortOrder: 4,
40
- },
41
- };
42
- const YOUR_TEAM_GROUP = {
43
- key: "your-team",
44
- label: "Your team",
45
- sortOrder: 0,
46
- };
47
- const CORE_UTILITIES_GROUP = {
48
- key: "core-utilities",
49
- label: "Core utilities",
50
- sortOrder: 5,
51
- };
52
- const DEPRECATED_GROUP = {
53
- key: "deprecated",
54
- label: "Deprecated",
55
- sortOrder: 6,
24
+ other: "Other",
56
25
  };
57
- const UNCATALOGUED_GROUP = {
58
- key: "uncatalogued",
59
- label: "Uncatalogued",
60
- sortOrder: 7,
61
- };
62
- const PROJECT_DOCS_GROUP = {
63
- key: "project-docs",
64
- label: "Project docs",
65
- sortOrder: 0,
66
- };
67
- export async function loadNavGroupingContext(projectRoot) {
68
- if (!projectRoot) {
69
- return { bmadCatalog: null };
70
- }
71
- const csvPath = path.join(projectRoot, BMAD_HELP_PATH);
72
- try {
73
- const csvContent = await fs.readFile(csvPath, "utf-8");
74
- const catalog = await parseBmadHelpCsv(csvContent, projectRoot);
75
- return { bmadCatalog: catalog };
76
- }
77
- catch {
78
- return { bmadCatalog: null };
79
- }
80
- }
81
- export function buildCategoryNavSubgroups(category, pages, context, activePageSlug) {
82
- const assignments = pages.map((page) => ({
83
- page,
84
- assignment: assignPageGroups(page, category, context),
85
- }));
86
- const hasGrouping = assignments.some(({ assignment }) => assignment.segments.length > 0);
87
- if (!hasGrouping) {
88
- return {
89
- subgroups: [],
90
- pages: sortNavPages(assignments.map(({ page, assignment }) => toNavPage(page, assignment.displayTitle))),
91
- };
92
- }
93
- const tree = buildTreeFromAssignments(assignments);
94
- const flattened = flattenSingletonSubgroups(tree);
95
- const promoted = promoteSingletonPages(flattened);
96
- const withState = applyNavSubgroupState(promoted.subgroups, category, activePageSlug);
97
- if (withState.length === 0 && promoted.pages.length > 0) {
98
- return {
99
- subgroups: [],
100
- pages: sortNavPages(promoted.pages),
101
- };
102
- }
103
- if (withState.length === 1 && withState[0].pages.length === 0) {
104
- return {
105
- subgroups: [],
106
- pages: sortNavPages([
107
- ...promoted.pages,
108
- ...withState[0].subgroups.flatMap(collectPages),
109
- ]),
110
- };
111
- }
26
+ const STORY_FILENAME_PATTERN = /^(\d+)-(\d+)-/;
27
+ function storyNumbers(sourcePath) {
28
+ const filename = sourcePath.split("/").pop() ?? sourcePath;
29
+ const match = filename.match(STORY_FILENAME_PATTERN);
112
30
  return {
113
- subgroups: withState,
114
- pages: sortNavPages(promoted.pages),
31
+ epic: parseInt(match?.[1] ?? "0", 10),
32
+ story: parseInt(match?.[2] ?? "0", 10),
115
33
  };
116
34
  }
117
- export function resolveNavSubgroupLabel(page, category, context) {
118
- const assignment = assignPageGroups(page, category, context);
119
- if (assignment.segments.length === 0) {
120
- return undefined;
121
- }
122
- return assignment.segments.map((segment) => segment.label).join(" › ");
35
+ let subgroupOrderCounter = 0;
36
+ export async function loadNavGroupingContext(_projectRoot) {
37
+ return { loaded: false };
123
38
  }
124
- function assignPageGroups(page, category, context) {
125
- if (category === "agent-skills" && context.bmadCatalog) {
126
- const bmadAssignment = assignAgentSkillGroups(page, context.bmadCatalog);
127
- if (bmadAssignment) {
128
- return bmadAssignment;
39
+ export function buildCategoryNavSubgroups(pages, options) {
40
+ subgroupOrderCounter = 0;
41
+ const rootPages = [];
42
+ const rootChildren = new Map();
43
+ for (const page of pages) {
44
+ const navPage = toNavPage(page);
45
+ const segments = resolveSubgroupSegments(page.sourcePath, options.categoryKey);
46
+ if (segments.length === 0) {
47
+ rootPages.push(navPage);
48
+ continue;
129
49
  }
50
+ insertIntoTree(rootChildren, segments, navPage);
130
51
  }
131
- if (category === "bmad-output") {
132
- const bmadOutputAssignment = assignBmadOutputGroups(page);
133
- if (bmadOutputAssignment) {
134
- return bmadOutputAssignment;
135
- }
52
+ if (options.categoryKey === "bmad-output") {
53
+ sortImplementationStoryPages(rootChildren);
136
54
  }
137
- return assignPathSegmentGroups(page, category);
55
+ const subgroups = finalizeSubgroups(rootChildren, options);
56
+ const promotedPages = [...rootPages, ...subgroups.rootPromoted];
57
+ const visibleSubgroups = subgroups.subgroups;
58
+ const hasSubgroups = visibleSubgroups.length > 0;
59
+ return {
60
+ pages: promotedPages,
61
+ subgroups: visibleSubgroups,
62
+ hasSubgroups,
63
+ };
138
64
  }
139
- function assignAgentSkillGroups(page, catalog) {
140
- const skillFolder = extractSkillFolder(page.sourcePath);
141
- if (!skillFolder) {
142
- return null;
143
- }
144
- const agent = catalog.agents.get(skillFolder);
145
- if (agent) {
146
- const displayTitle = formatAgentTitle(agent);
147
- return {
148
- segments: [YOUR_TEAM_GROUP],
149
- pageSortKey: `00-${agent.name.toLowerCase()}`,
150
- displayTitle,
151
- };
152
- }
153
- const entry = catalog.skills.get(skillFolder);
154
- if (!entry) {
155
- return {
156
- segments: [UNCATALOGUED_GROUP],
157
- pageSortKey: `99-${page.title.toLowerCase()}`,
158
- };
159
- }
160
- if (entry.deprecated) {
161
- return {
162
- segments: [DEPRECATED_GROUP],
163
- pageSortKey: `98-${entry.displayName.toLowerCase()}`,
164
- displayTitle: entry.displayName,
165
- };
166
- }
167
- const segment = resolveSkillPhaseGroup(entry);
65
+ function toNavPage(page) {
168
66
  return {
169
- segments: [segment],
170
- pageSortKey: `${String(segment.sortOrder).padStart(2, "0")}-${entry.displayName.toLowerCase()}`,
171
- displayTitle: entry.displayName,
67
+ title: page.title,
68
+ slug: page.slug,
69
+ href: `${page.slug}.html`,
70
+ sourcePath: page.sourcePath,
172
71
  };
173
72
  }
174
- function resolveSkillPhaseGroup(entry) {
175
- if (entry.module === "Core" || entry.phase === "anytime") {
176
- return CORE_UTILITIES_GROUP;
73
+ function normalizePath(sourcePath) {
74
+ return sourcePath.replace(/\\/g, "/");
75
+ }
76
+ function resolveSubgroupSegments(sourcePath, categoryKey) {
77
+ const normalized = normalizePath(sourcePath);
78
+ if (categoryKey === "bmad-output") {
79
+ const bmadSegments = resolveBmadOutputSegments(normalized);
80
+ if (bmadSegments !== null) {
81
+ return bmadSegments;
82
+ }
177
83
  }
178
- return PHASE_GROUPS[entry.phase] ?? UNCATALOGUED_GROUP;
84
+ return resolveL0Segments(normalized, categoryKey);
179
85
  }
180
- function assignBmadOutputGroups(page) {
181
- const normalized = page.sourcePath.replace(/\\/g, "/");
86
+ function resolveBmadOutputSegments(normalized) {
182
87
  if (!normalized.startsWith("_bmad-output/")) {
183
88
  return null;
184
89
  }
185
- const remainder = normalized.slice("_bmad-output/".length);
186
- const basename = path.posix.basename(remainder);
187
- if (remainder.startsWith("planning-artifacts/")) {
188
- const relativeDir = path.posix.dirname(remainder);
189
- const dirParts = relativeDir.split("/").filter(Boolean);
190
- const planning = {
191
- key: "planning",
192
- label: "Planning",
193
- sortOrder: 0,
194
- };
195
- if (dirParts.length <= 1) {
196
- return {
197
- segments: [planning],
198
- pageSortKey: basename,
199
- };
200
- }
201
- const subKey = dirParts.slice(1).join("/");
202
- const subLabel = labelForPathSegment(dirParts[1]);
203
- return {
204
- segments: [
205
- planning,
206
- {
207
- key: `planning-${subKey}`,
208
- label: subLabel,
209
- sortOrder: 0,
210
- },
211
- ],
212
- pageSortKey: basename,
213
- };
214
- }
215
- if (remainder.startsWith("implementation-artifacts/")) {
216
- const implementation = {
217
- key: "implementation",
218
- label: "Implementation Stories",
219
- sortOrder: 1,
220
- };
221
- if (basename.startsWith("epic-")) {
222
- return {
223
- segments: [
224
- {
225
- key: "epic-context",
226
- label: "Epic Context",
227
- sortOrder: 2,
228
- },
229
- ],
230
- pageSortKey: basename,
231
- };
90
+ const relative = normalized.slice("_bmad-output/".length);
91
+ const dirParts = relative.split("/");
92
+ const filename = dirParts.pop() ?? "";
93
+ if (dirParts[0] === "planning-artifacts" || dirParts[0] === "planning") {
94
+ if (dirParts[0] === "planning-artifacts") {
95
+ const childKey = dirParts[1] ?? "other";
96
+ return ["planning", childKey];
232
97
  }
233
- const epicMatch = basename.match(/^(\d+)-(\d+)-/);
234
- if (epicMatch) {
235
- const epicNumber = epicMatch[1];
236
- return {
237
- segments: [
238
- implementation,
239
- {
240
- key: `epic-${epicNumber}`,
241
- label: `Epic ${epicNumber}`,
242
- sortOrder: Number.parseInt(epicNumber, 10),
243
- },
244
- ],
245
- pageSortKey: basename,
246
- };
98
+ if (dirParts.length === 1) {
99
+ return ["planning"];
247
100
  }
248
- return {
249
- segments: [
250
- implementation,
251
- {
252
- key: "other-stories",
253
- label: "Other Stories",
254
- sortOrder: 999,
255
- },
256
- ],
257
- pageSortKey: basename,
258
- };
101
+ return ["planning", dirParts[1]];
259
102
  }
260
- return assignPathSegmentGroups(page, "bmad-output");
261
- }
262
- function assignPathSegmentGroups(page, category) {
263
- const normalized = page.sourcePath.replace(/\\/g, "/");
264
- const prefix = CATEGORY_PATH_PREFIXES[category];
265
- let remainder = normalized;
266
- if (prefix && normalized.startsWith(prefix)) {
267
- remainder = normalized.slice(prefix.length);
268
- }
269
- const dirname = path.posix.dirname(remainder);
270
- if (dirname === ".") {
271
- if (category === "other" && normalized.includes("/")) {
272
- const topSegment = normalized.split("/")[0];
273
- return {
274
- segments: [
275
- {
276
- key: topSegment,
277
- label: labelForPathSegment(topSegment),
278
- sortOrder: 0,
279
- },
280
- ],
281
- pageSortKey: normalized,
282
- };
103
+ if (dirParts[0] === "implementation-artifacts") {
104
+ if (dirParts.length === 1 && filename.startsWith("epic-")) {
105
+ return ["epic-context"];
283
106
  }
284
- if (category === "other" || category === "root") {
285
- return {
286
- segments: category === "other" ? [PROJECT_DOCS_GROUP] : [],
287
- pageSortKey: page.title.toLowerCase(),
288
- };
107
+ const storyMatch = filename.match(STORY_FILENAME_PATTERN);
108
+ if (storyMatch) {
109
+ const epicNum = storyMatch[1];
110
+ return ["implementation-stories", `epic-${epicNum}`];
289
111
  }
290
- return {
291
- segments: [],
292
- pageSortKey: page.title.toLowerCase(),
293
- };
112
+ if (dirParts.length > 1) {
113
+ return resolveL0Segments(normalized, "bmad-output");
114
+ }
115
+ return ["other"];
294
116
  }
295
- const dirParts = dirname.split("/").filter(Boolean);
296
- const segments = dirParts.slice(0, 2).map((part, index) => ({
297
- key: dirParts.slice(0, index + 1).join("/"),
298
- label: labelForPathSegment(part),
299
- sortOrder: 0,
300
- }));
301
- return {
302
- segments,
303
- pageSortKey: normalized,
304
- };
117
+ return null;
305
118
  }
306
- function buildTreeFromAssignments(assignments) {
307
- const roots = new Map();
308
- for (const { page, assignment } of assignments) {
309
- if (assignment.segments.length === 0) {
310
- const rootKey = "__root_pages__";
311
- const root = roots.get(rootKey) ?? createMutableSubgroup(rootKey, "", 0);
312
- root.pages.push(toNavPage(page, assignment.displayTitle));
313
- roots.set(rootKey, root);
314
- continue;
315
- }
316
- let level = roots;
317
- let parent;
318
- for (const [index, segment] of assignment.segments.entries()) {
319
- const existing = level.get(segment.key);
320
- const node = existing ??
321
- createMutableSubgroup(segment.key, segment.label, segment.sortOrder);
322
- if (!existing) {
323
- level.set(segment.key, node);
324
- if (parent) {
325
- parent.children.set(segment.key, node);
326
- }
327
- }
328
- if (index === assignment.segments.length - 1) {
329
- node.pages.push(toNavPage(page, assignment.displayTitle));
330
- node.pageSortKeys.push(assignment.pageSortKey);
331
- }
332
- parent = node;
333
- level = node.children;
119
+ function resolveL0Segments(normalized, categoryKey) {
120
+ const prefix = CATEGORY_PATH_PREFIXES[categoryKey];
121
+ if (categoryKey === "other") {
122
+ const parts = normalized.split("/");
123
+ if (parts.length <= 1) {
124
+ return [];
334
125
  }
126
+ return parts.slice(0, Math.min(2, parts.length - 1));
335
127
  }
336
- const topLevel = [...roots.values()].filter((node) => node.key !== "__root_pages__");
337
- const rootPages = roots.get("__root_pages__")?.pages.sort(compareNavPages) ?? [];
338
- const subgroups = sortMutableSubgroups(topLevel).map(finalizeSubgroup);
339
- if (rootPages.length > 0) {
340
- return [
341
- ...subgroups,
342
- {
343
- key: "ungrouped",
344
- label: "Ungrouped",
345
- anchor: "ungrouped",
346
- pageCount: rootPages.length,
347
- collapsible: rootPages.length > 1,
348
- open: false,
349
- active: false,
350
- pages: rootPages,
351
- subgroups: [],
352
- hasNestedSubgroups: false,
353
- },
354
- ];
128
+ if (prefix === undefined) {
129
+ return [];
355
130
  }
356
- return subgroups;
357
- }
358
- function createMutableSubgroup(key, label, sortOrder) {
359
- return {
360
- key,
361
- label,
362
- sortOrder,
363
- pages: [],
364
- pageSortKeys: [],
365
- children: new Map(),
366
- };
367
- }
368
- function finalizeSubgroup(node) {
369
- const subgroups = sortMutableSubgroups([...node.children.values()]).map(finalizeSubgroup);
370
- const pages = sortNavPages(node.pages);
371
- const pageCount = pages.length + subgroups.reduce((sum, child) => sum + child.pageCount, 0);
372
- return {
373
- key: node.key,
374
- label: node.label,
375
- anchor: slugify(node.key),
376
- pageCount,
377
- collapsible: pageCount > 1 || subgroups.length > 0,
378
- open: false,
379
- active: false,
380
- pages,
381
- subgroups,
382
- hasNestedSubgroups: subgroups.length > 0,
383
- };
384
- }
385
- function flattenSingletonSubgroups(subgroups) {
386
- const flattened = [];
387
- for (const subgroup of subgroups) {
388
- const nested = flattenSingletonSubgroups(subgroup.subgroups);
389
- let current = {
390
- ...subgroup,
391
- subgroups: nested,
392
- hasNestedSubgroups: nested.length > 0,
393
- };
394
- if (current.subgroups.length === 1 &&
395
- current.pages.length === 0 &&
396
- current.subgroups[0]) {
397
- const child = current.subgroups[0];
398
- current = {
399
- ...child,
400
- key: `${current.key}-${child.key}`,
401
- label: `${current.label} › ${child.label}`,
402
- anchor: slugify(`${current.key}-${child.key}`),
403
- };
131
+ if (prefix === "") {
132
+ const parts = normalized.split("/");
133
+ if (parts.length <= 1) {
134
+ return [];
404
135
  }
405
- if (current.pages.length === 0 && current.subgroups.length === 0) {
406
- continue;
407
- }
408
- flattened.push(current);
136
+ return parts.slice(0, Math.min(2, parts.length - 1));
137
+ }
138
+ if (!normalized.startsWith(prefix)) {
139
+ return [];
409
140
  }
410
- return flattened;
141
+ const relative = normalized.slice(prefix.length);
142
+ const dirParts = relative.split("/");
143
+ dirParts.pop();
144
+ if (dirParts.length === 0) {
145
+ return [];
146
+ }
147
+ return dirParts.slice(0, 2);
411
148
  }
412
- function promoteSingletonPages(subgroups) {
413
- const promotedPages = [];
414
- const kept = [];
415
- for (const subgroup of subgroups) {
416
- const nested = promoteSingletonPages(subgroup.subgroups);
417
- const pages = sortNavPages([...subgroup.pages, ...nested.pages]);
418
- const children = nested.subgroups;
419
- const pageCount = pages.length + children.reduce((sum, child) => sum + child.pageCount, 0);
420
- if (pages.length === 1 && children.length === 0) {
421
- promotedPages.push(pages[0]);
422
- continue;
149
+ function insertIntoTree(root, segments, page) {
150
+ const capped = segments.slice(0, 2);
151
+ let current = root;
152
+ let path = [];
153
+ for (let i = 0; i < capped.length; i++) {
154
+ const segment = capped[i];
155
+ path = [...path, segment];
156
+ const key = path.join("/");
157
+ if (!current.has(segment)) {
158
+ current.set(segment, {
159
+ key,
160
+ label: humanizeFolderLabel(segment, path),
161
+ pages: [],
162
+ children: new Map(),
163
+ order: subgroupOrderCounter++,
164
+ });
423
165
  }
424
- if (pages.length === 0 && children.length === 0) {
425
- continue;
166
+ const node = current.get(segment);
167
+ if (i === capped.length - 1) {
168
+ node.pages.push(page);
426
169
  }
427
- kept.push({
428
- ...subgroup,
429
- pages,
430
- subgroups: children,
431
- pageCount,
432
- collapsible: pageCount > 1 || children.length > 0,
433
- hasNestedSubgroups: children.length > 0,
434
- });
435
- }
436
- return {
437
- subgroups: kept,
438
- pages: promotedPages,
439
- };
440
- }
441
- function applyNavSubgroupState(subgroups, category, activePageSlug) {
442
- return subgroups.map((subgroup) => {
443
- const nested = applyNavSubgroupState(subgroup.subgroups, category, activePageSlug);
444
- const containsActivePage = subgroup.pages.some((page) => page.slug === activePageSlug) ||
445
- nested.some((child) => child.active || child.open);
446
- return {
447
- ...subgroup,
448
- subgroups: nested,
449
- hasNestedSubgroups: nested.length > 0,
450
- active: containsActivePage,
451
- open: containsActivePage,
452
- };
453
- });
454
- }
455
- function collectPages(subgroup) {
456
- return [
457
- ...subgroup.pages,
458
- ...subgroup.subgroups.flatMap((child) => collectPages(child)),
459
- ];
460
- }
461
- function sortMutableSubgroups(nodes) {
462
- return [...nodes].sort((left, right) => {
463
- if (left.sortOrder !== right.sortOrder) {
464
- return left.sortOrder - right.sortOrder;
170
+ else {
171
+ current = node.children;
465
172
  }
466
- return left.label.localeCompare(right.label);
467
- });
468
- }
469
- function sortNavPages(pages) {
470
- return [...pages].sort((left, right) => left.title.localeCompare(right.title));
471
- }
472
- function compareNavPages(left, right) {
473
- return left.title.localeCompare(right.title);
474
- }
475
- function toNavPage(page, displayTitle) {
476
- return {
477
- title: displayTitle ?? page.title,
478
- slug: page.slug,
479
- href: `${page.slug}.html`,
480
- sourcePath: page.sourcePath,
481
- };
482
- }
483
- function extractSkillFolder(sourcePath) {
484
- const normalized = sourcePath.replace(/\\/g, "/");
485
- const match = normalized.match(/^\.agents\/skills\/([^/]+)\/SKILL\.md$/);
486
- return match?.[1] ?? null;
173
+ }
487
174
  }
488
- function labelForPathSegment(segment) {
175
+ function humanizeFolderLabel(segment, path) {
176
+ if (segment.startsWith("epic-")) {
177
+ const num = segment.slice("epic-".length);
178
+ return `Epic ${num}`;
179
+ }
489
180
  if (FOLDER_LABELS[segment]) {
490
181
  return FOLDER_LABELS[segment];
491
182
  }
183
+ if (path[0] === "planning" && path.length === 2) {
184
+ return (FOLDER_LABELS[segment] ??
185
+ segment
186
+ .split("-")
187
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
188
+ .join(" "));
189
+ }
492
190
  return segment
493
191
  .split("-")
494
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
192
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
495
193
  .join(" ");
496
194
  }
497
- function formatAgentTitle(agent) {
498
- const icon = agent.icon.trim();
499
- if (icon.length > 0) {
500
- return `${icon} ${agent.name} — ${agent.title}`;
195
+ function sortImplementationStoryPages(root) {
196
+ const implNode = root.get("implementation-stories");
197
+ if (!implNode) {
198
+ return;
501
199
  }
502
- return `${agent.name} ${agent.title}`;
503
- }
504
- function slugify(value) {
505
- return value
506
- .toLowerCase()
507
- .replace(/[^a-z0-9]+/g, "-")
508
- .replace(/^-+|-+$/g, "");
509
- }
510
- async function parseBmadHelpCsv(csvContent, projectRoot) {
511
- const skills = new Map();
512
- const lines = csvContent.split(/\r?\n/).filter(Boolean);
513
- if (lines.length <= 1) {
514
- return { skills, agents: new Map() };
515
- }
516
- for (const line of lines.slice(1)) {
517
- const row = parseCsvLine(line);
518
- if (row.length < 8) {
519
- continue;
200
+ const epicEntries = [...implNode.children.entries()].sort(([, a], [, b]) => {
201
+ const epicA = parseInt(a.key.match(/epic-(\d+)/)?.[1] ?? "0", 10);
202
+ const epicB = parseInt(b.key.match(/epic-(\d+)/)?.[1] ?? "0", 10);
203
+ if (epicA !== epicB) {
204
+ return epicA - epicB;
520
205
  }
521
- const module = row[0]?.trim() ?? "";
522
- const skill = row[1]?.trim() ?? "";
523
- const displayName = row[2]?.trim() ?? "";
524
- const description = row[4]?.trim() ?? "";
525
- const phase = row[7]?.trim() ?? "";
526
- if (!skill || skill === "_meta" || !displayName) {
527
- continue;
206
+ return a.order - b.order;
207
+ });
208
+ implNode.children = new Map(epicEntries);
209
+ for (const [, epicNode] of epicEntries) {
210
+ epicNode.pages.sort((a, b) => {
211
+ const numsA = storyNumbers(a.sourcePath);
212
+ const numsB = storyNumbers(b.sourcePath);
213
+ if (numsA.epic !== numsB.epic) {
214
+ return numsA.epic - numsB.epic;
215
+ }
216
+ return numsA.story - numsB.story;
217
+ });
218
+ }
219
+ }
220
+ function finalizeSubgroups(nodes, options) {
221
+ const result = [];
222
+ const rootPromoted = [];
223
+ const sortedNodes = [...nodes.values()].sort((a, b) => a.order - b.order);
224
+ for (const node of sortedNodes) {
225
+ const finalized = finalizeNode(node, options);
226
+ rootPromoted.push(...finalized.promoted);
227
+ if (finalized.subgroup) {
228
+ result.push(finalized.subgroup);
528
229
  }
529
- if (!skills.has(skill)) {
530
- skills.set(skill, {
531
- skill,
532
- displayName,
533
- phase,
534
- module,
535
- deprecated: /deprecated/i.test(description),
536
- });
230
+ }
231
+ return { subgroups: result, rootPromoted };
232
+ }
233
+ function finalizeNode(node, options) {
234
+ const promoted = [...node.pages];
235
+ const childSubgroups = [];
236
+ const sortedChildren = [...node.children.values()].sort((a, b) => a.order - b.order);
237
+ for (const child of sortedChildren) {
238
+ const finalizedChild = finalizeNode(child, options);
239
+ promoted.push(...finalizedChild.promoted);
240
+ if (finalizedChild.subgroup) {
241
+ childSubgroups.push(finalizedChild.subgroup);
537
242
  }
538
243
  }
539
- const agents = await loadAgentEntries(projectRoot, skills);
540
- return { skills, agents };
244
+ const totalPageCount = promoted.length + childSubgroups.reduce((sum, sg) => sum + sg.pageCount, 0);
245
+ if (totalPageCount === 0) {
246
+ return { promoted: [] };
247
+ }
248
+ if (totalPageCount === 1 && promoted.length === 1) {
249
+ return { promoted };
250
+ }
251
+ const containsActive = nodeOrChildrenContainActive(promoted, childSubgroups, options);
252
+ const collapsible = totalPageCount > 1;
253
+ const subgroup = {
254
+ key: node.key,
255
+ label: node.label,
256
+ pageCount: totalPageCount,
257
+ collapsible,
258
+ open: containsActive,
259
+ pages: promoted,
260
+ };
261
+ if (childSubgroups.length > 0) {
262
+ subgroup.subgroups = childSubgroups;
263
+ applyIndexOpenState(subgroup, options);
264
+ }
265
+ if (options.indexBuild && collapsible && !containsActive) {
266
+ subgroup.open = false;
267
+ }
268
+ return { promoted: [], subgroup };
541
269
  }
542
- async function loadAgentEntries(projectRoot, skills) {
543
- const agents = new Map();
544
- for (const skillFolder of skills.keys()) {
545
- if (!skillFolder.startsWith("bmad-agent-")) {
546
- continue;
270
+ function applyIndexOpenState(subgroup, options) {
271
+ if (!options.indexBuild) {
272
+ return;
273
+ }
274
+ for (const child of subgroup.subgroups ?? []) {
275
+ const childActive = subgroupContainsActiveInChild(child, options);
276
+ if (child.collapsible && !childActive) {
277
+ child.open = false;
547
278
  }
548
- const customizePath = path.join(projectRoot, ".agents/skills", skillFolder, "customize.toml");
549
- try {
550
- const toml = await fs.readFile(customizePath, "utf-8");
551
- const agent = parseAgentCustomize(toml, skillFolder);
552
- if (agent) {
553
- agents.set(skillFolder, agent);
554
- }
279
+ applyIndexOpenState(child, options);
280
+ }
281
+ }
282
+ function nodeOrChildrenContainActive(pages, childSubgroups, options) {
283
+ if (!options.activePageSlug && !options.activeSourcePath) {
284
+ return false;
285
+ }
286
+ for (const page of pages) {
287
+ if (page.slug === options.activePageSlug ||
288
+ page.sourcePath === options.activeSourcePath) {
289
+ return true;
555
290
  }
556
- catch {
557
- continue;
291
+ }
292
+ for (const child of childSubgroups) {
293
+ if (subgroupContainsActiveInChild(child, options)) {
294
+ return true;
558
295
  }
559
296
  }
560
- return agents;
297
+ return false;
561
298
  }
562
- function parseAgentCustomize(toml, skillFolder) {
563
- if (!/^\[agent\]/m.test(toml)) {
564
- return null;
299
+ function subgroupContainsActiveInChild(subgroup, options) {
300
+ if (!options.activePageSlug && !options.activeSourcePath) {
301
+ return false;
565
302
  }
566
- const agentBlock = extractTomlSection(toml, "agent");
567
- const name = readTomlString(agentBlock, "name") ?? skillFolder;
568
- const title = readTomlString(agentBlock, "title") ?? name;
569
- const icon = readTomlString(agentBlock, "icon") ?? "";
570
- return { skill: skillFolder, name, title, icon };
571
- }
572
- function extractTomlSection(content, section) {
573
- const pattern = new RegExp(`^\\[${section}\\][\\s\\S]*?(?=^\\[[^\\]]+\\]|$)`, "m");
574
- const match = content.match(pattern);
575
- return match?.[0] ?? "";
576
- }
577
- function readTomlString(section, key) {
578
- const pattern = new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m");
579
- const match = section.match(pattern);
580
- return match?.[1];
581
- }
582
- function parseCsvLine(line) {
583
- const values = [];
584
- let current = "";
585
- let inQuotes = false;
586
- for (let index = 0; index < line.length; index += 1) {
587
- const char = line[index];
588
- if (char === '"') {
589
- if (inQuotes && line[index + 1] === '"') {
590
- current += '"';
591
- index += 1;
592
- }
593
- else {
594
- inQuotes = !inQuotes;
595
- }
596
- continue;
303
+ for (const page of subgroup.pages) {
304
+ if (page.slug === options.activePageSlug ||
305
+ page.sourcePath === options.activeSourcePath) {
306
+ return true;
597
307
  }
598
- if (char === "," && !inQuotes) {
599
- values.push(current);
600
- current = "";
601
- continue;
308
+ }
309
+ for (const child of subgroup.subgroups ?? []) {
310
+ if (subgroupContainsActiveInChild(child, options)) {
311
+ return true;
602
312
  }
603
- current += char;
604
313
  }
605
- values.push(current);
606
- return values;
314
+ return false;
315
+ }
316
+ /** Exported for unit tests — maps category keys to path strip prefixes. */
317
+ export function categoryPathPrefix(categoryKey) {
318
+ return CATEGORY_PATH_PREFIXES[categoryKey];
319
+ }
320
+ /** Exported for unit tests — humanizes a folder segment. */
321
+ export function humanizeSegment(segment) {
322
+ return humanizeFolderLabel(segment, [segment]);
607
323
  }
608
324
  //# sourceMappingURL=nav-grouping.js.map