@lucasviola/specwiki 1.0.1 → 1.1.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.
@@ -1,10 +1,19 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- const BMAD_HELP_PATH = "_bmad/_config/bmad-help.csv";
1
+ import { loadNavGroupingContext, } from "./nav-grouping-catalog.js";
2
+ export { loadNavGroupingContext };
3
+ const HYBRID_GROUPS = [
4
+ { key: "your-team", label: "Your team" },
5
+ { key: "analysis", label: "Analysis" },
6
+ { key: "planning", label: "Planning" },
7
+ { key: "solutioning", label: "Solutioning" },
8
+ { key: "implementation", label: "Implementation" },
9
+ { key: "core-utilities", label: "Core utilities" },
10
+ { key: "deprecated", label: "Deprecated" },
11
+ { key: "uncatalogued", label: "Uncatalogued" },
12
+ ];
4
13
  const CATEGORY_PATH_PREFIXES = {
5
- "agent-skills": ".agents/skills/",
6
- "cursor-skills": ".cursor/skills/",
7
14
  "cursor-rules": ".cursor/rules/",
15
+ "cursor-skills": ".cursor/skills/",
16
+ "agent-skills": ".agents/skills/",
8
17
  "bmad-output": "_bmad-output/",
9
18
  specs: "specs/",
10
19
  spec: "spec/",
@@ -14,595 +23,469 @@ const CATEGORY_PATH_PREFIXES = {
14
23
  plans: "docs/plans/",
15
24
  requirements: "requirements/",
16
25
  github: ".github/",
26
+ root: "",
17
27
  };
18
28
  const FOLDER_LABELS = {
29
+ planning: "Planning",
19
30
  "planning-artifacts": "Planning",
20
31
  "implementation-artifacts": "Implementation Stories",
32
+ "implementation-stories": "Implementation Stories",
33
+ "epic-context": "Epic Context",
21
34
  discovery: "Discovery",
22
35
  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,
56
- };
57
- const UNCATALOGUED_GROUP = {
58
- key: "uncatalogued",
59
- label: "Uncatalogued",
60
- sortOrder: 7,
36
+ other: "Other",
61
37
  };
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 };
38
+ const STORY_FILENAME_PATTERN = /^(\d+)-(\d+)-/;
39
+ function storyNumbers(sourcePath) {
40
+ const filename = sourcePath.split("/").pop() ?? sourcePath;
41
+ const match = filename.match(STORY_FILENAME_PATTERN);
42
+ return {
43
+ epic: parseInt(match?.[1] ?? "0", 10),
44
+ story: parseInt(match?.[2] ?? "0", 10),
45
+ };
46
+ }
47
+ let subgroupOrderCounter = 0;
48
+ export function buildCategoryNavSubgroups(pages, options) {
49
+ subgroupOrderCounter = 0;
50
+ if (options.categoryKey === "agent-skills" &&
51
+ options.context?.loaded === true) {
52
+ return buildHybridAgentSkillsGrouping(pages, options);
70
53
  }
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 };
54
+ const rootPages = [];
55
+ const rootChildren = new Map();
56
+ for (const page of pages) {
57
+ const navPage = toNavPage(page);
58
+ const segments = resolveSubgroupSegments(page.sourcePath, options.categoryKey);
59
+ if (segments.length === 0) {
60
+ rootPages.push(navPage);
61
+ continue;
62
+ }
63
+ insertIntoTree(rootChildren, segments, navPage);
76
64
  }
77
- catch {
78
- return { bmadCatalog: null };
65
+ if (options.categoryKey === "bmad-output") {
66
+ sortImplementationStoryPages(rootChildren);
79
67
  }
68
+ const subgroups = finalizeSubgroups(rootChildren, options);
69
+ const promotedPages = [...rootPages, ...subgroups.rootPromoted];
70
+ const visibleSubgroups = subgroups.subgroups;
71
+ const hasSubgroups = visibleSubgroups.length > 0;
72
+ return {
73
+ pages: promotedPages,
74
+ subgroups: visibleSubgroups,
75
+ hasSubgroups,
76
+ };
80
77
  }
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
- };
78
+ function buildHybridAgentSkillsGrouping(pages, options) {
79
+ const skillsById = options.context?.skillsById ?? new Map();
80
+ /** phase → skillId ("" for orphans) → wiki pages */
81
+ const buckets = new Map();
82
+ for (const page of pages) {
83
+ const skillId = agentSkillIdFromPath(page.sourcePath);
84
+ const entry = skillId ? skillsById.get(skillId) : undefined;
85
+ const groupKey = resolveHybridGroupKey(entry);
86
+ const skillKey = skillId ?? "";
87
+ let phaseSkills = buckets.get(groupKey);
88
+ if (!phaseSkills) {
89
+ phaseSkills = new Map();
90
+ buckets.set(groupKey, phaseSkills);
91
+ }
92
+ const list = phaseSkills.get(skillKey) ?? [];
93
+ list.push(page);
94
+ phaseSkills.set(skillKey, list);
102
95
  }
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
- ]),
96
+ const rootChildren = new Map();
97
+ for (const group of HYBRID_GROUPS) {
98
+ const phaseSkills = buckets.get(group.key);
99
+ if (!phaseSkills || phaseSkills.size === 0) {
100
+ continue;
101
+ }
102
+ const phaseNode = {
103
+ key: group.key,
104
+ label: group.label,
105
+ pages: [],
106
+ children: new Map(),
107
+ order: subgroupOrderCounter++,
110
108
  };
109
+ const skillEntries = [...phaseSkills.entries()].sort(([idA, pagesA], [idB, pagesB]) => {
110
+ const labelA = resolveHybridSkillLabel(idA, pagesA[0], skillsById.get(idA));
111
+ const labelB = resolveHybridSkillLabel(idB, pagesB[0], skillsById.get(idB));
112
+ return labelA.localeCompare(labelB, undefined, { sensitivity: "base" });
113
+ });
114
+ for (const [skillKey, skillPages] of skillEntries) {
115
+ const entry = skillKey ? skillsById.get(skillKey) : undefined;
116
+ // Paths outside .agents/skills/ stay as direct Uncatalogued leaves.
117
+ if (!skillKey) {
118
+ for (const page of skillPages) {
119
+ phaseNode.pages.push(toNavPage(page));
120
+ }
121
+ continue;
122
+ }
123
+ const skillLabel = resolveHybridSkillLabel(skillKey, skillPages[0], entry);
124
+ // Single-page skills: L4 title on the phase leaf (no L2 wrapper).
125
+ if (skillPages.length === 1) {
126
+ phaseNode.pages.push(toNavPage(skillPages[0], resolveAgentSkillTitle(skillPages[0], entry)));
127
+ continue;
128
+ }
129
+ const nestedPages = skillPages
130
+ .map((page) => toNavPage(page))
131
+ .sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }));
132
+ phaseNode.children.set(skillKey, {
133
+ key: `${group.key}/${skillKey}`,
134
+ label: skillLabel,
135
+ pages: nestedPages,
136
+ children: new Map(),
137
+ order: subgroupOrderCounter++,
138
+ });
139
+ }
140
+ phaseNode.pages.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }));
141
+ rootChildren.set(group.key, phaseNode);
111
142
  }
143
+ const subgroups = finalizeSubgroups(rootChildren, options);
112
144
  return {
113
- subgroups: withState,
114
- pages: sortNavPages(promoted.pages),
145
+ pages: subgroups.rootPromoted,
146
+ subgroups: subgroups.subgroups,
147
+ hasSubgroups: subgroups.subgroups.length > 0,
115
148
  };
116
149
  }
117
- export function resolveNavSubgroupLabel(page, category, context) {
118
- const assignment = assignPageGroups(page, category, context);
119
- if (assignment.segments.length === 0) {
120
- return undefined;
150
+ function resolveHybridSkillLabel(skillId, page, entry) {
151
+ if (!skillId) {
152
+ return page.title;
121
153
  }
122
- return assignment.segments.map((segment) => segment.label).join(" ");
123
- }
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;
129
- }
154
+ if (entry?.isAgent && entry.agentName && entry.agentTitle) {
155
+ return resolveAgentSkillTitle(page, entry);
130
156
  }
131
- if (category === "bmad-output") {
132
- const bmadOutputAssignment = assignBmadOutputGroups(page);
133
- if (bmadOutputAssignment) {
134
- return bmadOutputAssignment;
135
- }
157
+ if (entry?.displayName) {
158
+ return entry.displayName;
136
159
  }
137
- return assignPathSegmentGroups(page, category);
160
+ // Stable label for uncatalogued multi-page skills (not first-page wiki title).
161
+ return humanizeFolderLabel(skillId, [skillId]);
138
162
  }
139
- function assignAgentSkillGroups(page, catalog) {
140
- const skillFolder = extractSkillFolder(page.sourcePath);
141
- if (!skillFolder) {
163
+ function agentSkillIdFromPath(sourcePath) {
164
+ const normalized = normalizePath(sourcePath);
165
+ const prefix = ".agents/skills/";
166
+ if (!normalized.startsWith(prefix)) {
142
167
  return null;
143
168
  }
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
- };
169
+ const skillId = normalized.slice(prefix.length).split("/")[0];
170
+ return skillId || null;
171
+ }
172
+ function resolveHybridGroupKey(entry) {
173
+ if (entry?.isAgent) {
174
+ return "your-team";
152
175
  }
153
- const entry = catalog.skills.get(skillFolder);
154
- if (!entry) {
155
- return {
156
- segments: [UNCATALOGUED_GROUP],
157
- pageSortKey: `99-${page.title.toLowerCase()}`,
158
- };
176
+ if (entry?.description?.toLowerCase().includes("deprecated")) {
177
+ return "deprecated";
159
178
  }
160
- if (entry.deprecated) {
161
- return {
162
- segments: [DEPRECATED_GROUP],
163
- pageSortKey: `98-${entry.displayName.toLowerCase()}`,
164
- displayTitle: entry.displayName,
165
- };
179
+ switch (entry?.phase) {
180
+ case "1-analysis":
181
+ return "analysis";
182
+ case "2-planning":
183
+ return "planning";
184
+ case "3-solutioning":
185
+ return "solutioning";
186
+ case "4-implementation":
187
+ return "implementation";
188
+ default:
189
+ break;
190
+ }
191
+ if (entry?.module === "Core" || entry?.phase === "anytime") {
192
+ return "core-utilities";
193
+ }
194
+ if (entry?.inCsv) {
195
+ return "core-utilities";
196
+ }
197
+ return "uncatalogued";
198
+ }
199
+ function resolveAgentSkillTitle(page, entry) {
200
+ if (entry?.isAgent && entry.agentName && entry.agentTitle) {
201
+ return entry.agentIcon
202
+ ? `${entry.agentIcon} ${entry.agentName} — ${entry.agentTitle}`
203
+ : `${entry.agentName} — ${entry.agentTitle}`;
166
204
  }
167
- const segment = resolveSkillPhaseGroup(entry);
205
+ if (entry?.displayName) {
206
+ return entry.displayName;
207
+ }
208
+ return page.title;
209
+ }
210
+ function toNavPage(page, titleOverride) {
168
211
  return {
169
- segments: [segment],
170
- pageSortKey: `${String(segment.sortOrder).padStart(2, "0")}-${entry.displayName.toLowerCase()}`,
171
- displayTitle: entry.displayName,
212
+ title: titleOverride ?? page.title,
213
+ slug: page.slug,
214
+ href: `${page.slug}.html`,
215
+ sourcePath: page.sourcePath,
172
216
  };
173
217
  }
174
- function resolveSkillPhaseGroup(entry) {
175
- if (entry.module === "Core" || entry.phase === "anytime") {
176
- return CORE_UTILITIES_GROUP;
218
+ function normalizePath(sourcePath) {
219
+ return sourcePath.replace(/\\/g, "/");
220
+ }
221
+ function resolveSubgroupSegments(sourcePath, categoryKey) {
222
+ const normalized = normalizePath(sourcePath);
223
+ if (categoryKey === "bmad-output") {
224
+ const bmadSegments = resolveBmadOutputSegments(normalized);
225
+ if (bmadSegments !== null) {
226
+ return bmadSegments;
227
+ }
177
228
  }
178
- return PHASE_GROUPS[entry.phase] ?? UNCATALOGUED_GROUP;
229
+ return resolveL0Segments(normalized, categoryKey);
179
230
  }
180
- function assignBmadOutputGroups(page) {
181
- const normalized = page.sourcePath.replace(/\\/g, "/");
231
+ function resolveBmadOutputSegments(normalized) {
182
232
  if (!normalized.startsWith("_bmad-output/")) {
183
233
  return null;
184
234
  }
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
- };
235
+ const relative = normalized.slice("_bmad-output/".length);
236
+ const dirParts = relative.split("/");
237
+ const filename = dirParts.pop() ?? "";
238
+ if (dirParts[0] === "planning-artifacts" || dirParts[0] === "planning") {
239
+ if (dirParts[0] === "planning-artifacts") {
240
+ const childKey = dirParts[1] ?? "other";
241
+ return ["planning", childKey];
200
242
  }
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
- };
243
+ if (dirParts.length === 1) {
244
+ return ["planning"];
232
245
  }
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
- };
247
- }
248
- return {
249
- segments: [
250
- implementation,
251
- {
252
- key: "other-stories",
253
- label: "Other Stories",
254
- sortOrder: 999,
255
- },
256
- ],
257
- pageSortKey: basename,
258
- };
246
+ return ["planning", dirParts[1]];
259
247
  }
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
- };
248
+ if (dirParts[0] === "implementation-artifacts") {
249
+ if (dirParts.length === 1 && filename.startsWith("epic-")) {
250
+ return ["epic-context"];
283
251
  }
284
- if (category === "other" || category === "root") {
285
- return {
286
- segments: category === "other" ? [PROJECT_DOCS_GROUP] : [],
287
- pageSortKey: page.title.toLowerCase(),
288
- };
252
+ const storyMatch = filename.match(STORY_FILENAME_PATTERN);
253
+ if (storyMatch) {
254
+ const epicNum = storyMatch[1];
255
+ return ["implementation-stories", `epic-${epicNum}`];
289
256
  }
290
- return {
291
- segments: [],
292
- pageSortKey: page.title.toLowerCase(),
293
- };
294
- }
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
- };
305
- }
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;
257
+ if (dirParts.length > 1) {
258
+ return resolveL0Segments(normalized, "bmad-output");
334
259
  }
260
+ return ["other"];
335
261
  }
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
- ];
355
- }
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
- };
262
+ return null;
384
263
  }
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
- };
264
+ function resolveL0Segments(normalized, categoryKey) {
265
+ const prefix = CATEGORY_PATH_PREFIXES[categoryKey];
266
+ if (categoryKey === "other") {
267
+ const parts = normalized.split("/");
268
+ if (parts.length <= 1) {
269
+ return [];
404
270
  }
405
- if (current.pages.length === 0 && current.subgroups.length === 0) {
406
- continue;
271
+ return parts.slice(0, Math.min(2, parts.length - 1));
272
+ }
273
+ if (prefix === undefined) {
274
+ return [];
275
+ }
276
+ if (prefix === "") {
277
+ const parts = normalized.split("/");
278
+ if (parts.length <= 1) {
279
+ return [];
407
280
  }
408
- flattened.push(current);
281
+ return parts.slice(0, Math.min(2, parts.length - 1));
409
282
  }
410
- return flattened;
283
+ if (!normalized.startsWith(prefix)) {
284
+ return [];
285
+ }
286
+ const relative = normalized.slice(prefix.length);
287
+ const dirParts = relative.split("/");
288
+ dirParts.pop();
289
+ if (dirParts.length === 0) {
290
+ return [];
291
+ }
292
+ return dirParts.slice(0, 2);
411
293
  }
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;
294
+ function insertIntoTree(root, segments, page) {
295
+ const capped = segments.slice(0, 2);
296
+ let current = root;
297
+ let path = [];
298
+ for (let i = 0; i < capped.length; i++) {
299
+ const segment = capped[i];
300
+ path = [...path, segment];
301
+ const key = path.join("/");
302
+ if (!current.has(segment)) {
303
+ current.set(segment, {
304
+ key,
305
+ label: humanizeFolderLabel(segment, path),
306
+ pages: [],
307
+ children: new Map(),
308
+ order: subgroupOrderCounter++,
309
+ });
423
310
  }
424
- if (pages.length === 0 && children.length === 0) {
425
- continue;
311
+ const node = current.get(segment);
312
+ if (i === capped.length - 1) {
313
+ node.pages.push(page);
426
314
  }
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;
315
+ else {
316
+ current = node.children;
465
317
  }
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;
318
+ }
487
319
  }
488
- function labelForPathSegment(segment) {
320
+ function humanizeFolderLabel(segment, path) {
321
+ if (segment.startsWith("epic-")) {
322
+ const num = segment.slice("epic-".length);
323
+ return `Epic ${num}`;
324
+ }
489
325
  if (FOLDER_LABELS[segment]) {
490
326
  return FOLDER_LABELS[segment];
491
327
  }
328
+ if (path[0] === "planning" && path.length === 2) {
329
+ return (FOLDER_LABELS[segment] ??
330
+ segment
331
+ .split("-")
332
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
333
+ .join(" "));
334
+ }
492
335
  return segment
493
336
  .split("-")
494
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
337
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
495
338
  .join(" ");
496
339
  }
497
- function formatAgentTitle(agent) {
498
- const icon = agent.icon.trim();
499
- if (icon.length > 0) {
500
- return `${icon} ${agent.name} — ${agent.title}`;
340
+ function sortImplementationStoryPages(root) {
341
+ const implNode = root.get("implementation-stories");
342
+ if (!implNode) {
343
+ return;
501
344
  }
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;
345
+ const epicEntries = [...implNode.children.entries()].sort(([, a], [, b]) => {
346
+ const epicA = parseInt(a.key.match(/epic-(\d+)/)?.[1] ?? "0", 10);
347
+ const epicB = parseInt(b.key.match(/epic-(\d+)/)?.[1] ?? "0", 10);
348
+ if (epicA !== epicB) {
349
+ return epicA - epicB;
520
350
  }
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;
351
+ return a.order - b.order;
352
+ });
353
+ implNode.children = new Map(epicEntries);
354
+ for (const [, epicNode] of epicEntries) {
355
+ epicNode.pages.sort((a, b) => {
356
+ const numsA = storyNumbers(a.sourcePath);
357
+ const numsB = storyNumbers(b.sourcePath);
358
+ if (numsA.epic !== numsB.epic) {
359
+ return numsA.epic - numsB.epic;
360
+ }
361
+ return numsA.story - numsB.story;
362
+ });
363
+ }
364
+ }
365
+ function finalizeSubgroups(nodes, options) {
366
+ const result = [];
367
+ const rootPromoted = [];
368
+ const sortedNodes = [...nodes.values()].sort((a, b) => a.order - b.order);
369
+ for (const node of sortedNodes) {
370
+ const finalized = finalizeNode(node, options);
371
+ rootPromoted.push(...finalized.promoted);
372
+ if (finalized.subgroup) {
373
+ result.push(finalized.subgroup);
528
374
  }
529
- if (!skills.has(skill)) {
530
- skills.set(skill, {
531
- skill,
532
- displayName,
533
- phase,
534
- module,
535
- deprecated: /deprecated/i.test(description),
536
- });
375
+ }
376
+ return { subgroups: result, rootPromoted };
377
+ }
378
+ function finalizeNode(node, options) {
379
+ const promoted = [...node.pages];
380
+ const childSubgroups = [];
381
+ const sortedChildren = [...node.children.values()].sort((a, b) => a.order - b.order);
382
+ for (const child of sortedChildren) {
383
+ const finalizedChild = finalizeNode(child, options);
384
+ promoted.push(...finalizedChild.promoted);
385
+ if (finalizedChild.subgroup) {
386
+ childSubgroups.push(finalizedChild.subgroup);
537
387
  }
538
388
  }
539
- const agents = await loadAgentEntries(projectRoot, skills);
540
- return { skills, agents };
389
+ const totalPageCount = promoted.length + childSubgroups.reduce((sum, sg) => sum + sg.pageCount, 0);
390
+ if (totalPageCount === 0) {
391
+ return { promoted: [] };
392
+ }
393
+ if (totalPageCount === 1 && promoted.length === 1) {
394
+ return { promoted };
395
+ }
396
+ const containsActive = nodeOrChildrenContainActive(promoted, childSubgroups, options);
397
+ const collapsible = totalPageCount > 1;
398
+ const subgroup = {
399
+ key: node.key,
400
+ label: node.label,
401
+ pageCount: totalPageCount,
402
+ collapsible,
403
+ open: containsActive,
404
+ pages: promoted,
405
+ };
406
+ if (childSubgroups.length > 0) {
407
+ subgroup.subgroups = childSubgroups;
408
+ applyIndexOpenState(subgroup, options);
409
+ }
410
+ if (options.indexBuild && collapsible && !containsActive) {
411
+ subgroup.open = false;
412
+ }
413
+ return { promoted: [], subgroup };
541
414
  }
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;
415
+ function applyIndexOpenState(subgroup, options) {
416
+ if (!options.indexBuild) {
417
+ return;
418
+ }
419
+ for (const child of subgroup.subgroups ?? []) {
420
+ const childActive = subgroupContainsActiveInChild(child, options);
421
+ if (child.collapsible && !childActive) {
422
+ child.open = false;
547
423
  }
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
- }
424
+ applyIndexOpenState(child, options);
425
+ }
426
+ }
427
+ function nodeOrChildrenContainActive(pages, childSubgroups, options) {
428
+ if (!options.activePageSlug && !options.activeSourcePath) {
429
+ return false;
430
+ }
431
+ for (const page of pages) {
432
+ if (page.slug === options.activePageSlug ||
433
+ page.sourcePath === options.activeSourcePath) {
434
+ return true;
555
435
  }
556
- catch {
557
- continue;
436
+ }
437
+ for (const child of childSubgroups) {
438
+ if (subgroupContainsActiveInChild(child, options)) {
439
+ return true;
558
440
  }
559
441
  }
560
- return agents;
442
+ return false;
561
443
  }
562
- function parseAgentCustomize(toml, skillFolder) {
563
- if (!/^\[agent\]/m.test(toml)) {
564
- return null;
444
+ function subgroupContainsActiveInChild(subgroup, options) {
445
+ if (!options.activePageSlug && !options.activeSourcePath) {
446
+ return false;
565
447
  }
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];
448
+ for (const page of subgroup.pages) {
449
+ if (page.slug === options.activePageSlug ||
450
+ page.sourcePath === options.activeSourcePath) {
451
+ return true;
452
+ }
453
+ }
454
+ for (const child of subgroup.subgroups ?? []) {
455
+ if (subgroupContainsActiveInChild(child, options)) {
456
+ return true;
457
+ }
458
+ }
459
+ return false;
581
460
  }
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;
461
+ /**
462
+ * Walk finalized category subgroups and return the ordered ancestor trail
463
+ * (outer → inner) for the active page. Does not include the page itself.
464
+ */
465
+ export function resolveActiveSubgroupTrail(subgroups, options) {
466
+ const trail = [];
467
+ function walk(nodes) {
468
+ for (const node of nodes) {
469
+ if (!subgroupContainsActiveInChild(node, options)) {
470
+ continue;
592
471
  }
593
- else {
594
- inQuotes = !inQuotes;
472
+ trail.push({ key: node.key, label: node.label });
473
+ if (node.subgroups?.length) {
474
+ walk(node.subgroups);
595
475
  }
596
- continue;
476
+ return true;
597
477
  }
598
- if (char === "," && !inQuotes) {
599
- values.push(current);
600
- current = "";
601
- continue;
602
- }
603
- current += char;
478
+ return false;
604
479
  }
605
- values.push(current);
606
- return values;
480
+ walk(subgroups);
481
+ return trail;
482
+ }
483
+ /** Exported for unit tests — maps category keys to path strip prefixes. */
484
+ export function categoryPathPrefix(categoryKey) {
485
+ return CATEGORY_PATH_PREFIXES[categoryKey];
486
+ }
487
+ /** Exported for unit tests — humanizes a folder segment. */
488
+ export function humanizeSegment(segment) {
489
+ return humanizeFolderLabel(segment, [segment]);
607
490
  }
608
491
  //# sourceMappingURL=nav-grouping.js.map