From a5c79c1d2bd09fa7c404c724f474a829caadf075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Thu, 7 May 2026 14:37:20 -0300 Subject: [PATCH 1/5] feat: support alphanumeric epic/story IDs (DI, HK, DevOps/Infra) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full support for alphanumeric epic and story IDs alongside existing numeric IDs. Enables projects like agenda-clubber to use meaningful ID prefixes (e.g. DevOps/Infra → di, Housekeeping → hk). Changes: - Parser regex updates: accept [a-z][a-z0-9_/-]* as epic/story prefix - Normalization: lowercase + slash→hyphen (DevOps/Infra → devops-infra) - Backfill correlation: link stories to epics via epic.stories[] when filename prefix doesn't match epic ID (e.g. di.1 → devops-infra) - UI sort: use localeCompare({ numeric: true }) for natural ordering - Story-file discovery: accept alpha-N-title.md in addition to N-N-title.md New tests: 30+ test cases covering alphanumeric parsing, normalization, backfill logic, and mixed numeric+alpha epics. Documentation: added "Epic and Story Naming Conventions" section to README with examples and explanation of automatic epic-story linking. Backward compatible: all existing numeric-only projects parse unchanged. Co-Authored-By: Claude Haiku 4.5 --- README.md | 50 ++++++++++++ src/components/dashboard/epics-list.tsx | 4 +- .../dashboard/sprint-summary-card.tsx | 9 +-- src/lib/bmad/__tests__/correlate.test.ts | 52 ++++++++++++ .../bmad/__tests__/parse-epic-file.test.ts | 47 +++++++++++ src/lib/bmad/__tests__/parse-epics.test.ts | 59 ++++++++++++++ .../__tests__/parse-sprint-status.test.ts | 79 +++++++++++++++++++ src/lib/bmad/__tests__/parse-story.test.ts | 28 +++++++ src/lib/bmad/__tests__/parser.test.ts | 70 ++++++++++++++++ src/lib/bmad/correlate.ts | 19 +++-- src/lib/bmad/parse-epic-file.ts | 34 +++++--- src/lib/bmad/parse-epics.ts | 27 ++++--- src/lib/bmad/parse-sprint-status.ts | 29 ++++--- src/lib/bmad/parse-story.ts | 18 ++++- src/lib/bmad/parser.ts | 1 + src/lib/bmad/utils.ts | 4 + 16 files changed, 480 insertions(+), 50 deletions(-) create mode 100644 src/lib/bmad/__tests__/parser.test.ts diff --git a/README.md b/README.md index 2f7ff9a..4d73918 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A web dashboard - https://mybmad.hichem.cloud/ - to visualize and track [BMAD (B - [Project Structure](#project-structure) - [Available Scripts](#available-scripts) - [Production Deployment](#production-deployment-docker) +- [Epic and Story Naming Conventions](#epic-and-story-naming-conventions) - [Documentation](#documentation) - [Contributing](#contributing) - [License](#license) @@ -168,6 +169,55 @@ The stack includes: --- +## Epic and Story Naming Conventions + +MyBMAD supports both **numeric** and **alphanumeric** epic/story IDs: + +### Epic IDs + +| Format | Example heading | Parsed ID | +|--------|----------------|-----------| +| Numeric | `## Epic 1: Foundation` or `## 1: Foundation` | `1` | +| Single-word | `## Epic Housekeeping: Cleanup` | `housekeeping` | +| Multi-word | `## Epic DevOps/Infra: Pipeline` | `devops-infra` | + +> **Note:** The `Epic` keyword is **required** for alphanumeric IDs to avoid false positives (e.g. `## Introduction:` is not parsed as an epic). + +### Story IDs and Filenames + +| Filename pattern | Parsed ID | Epic ID | +|-----------------|-----------|---------| +| `1-2-setup.md` | `1.2` | `1` | +| `di-1-pipeline.md` | `di.1` | `di` | +| `hk-3-cleanup.md` | `hk.3` | `hk` | +| `story-5.md` (legacy) | `5` | — | + +Alphanumeric prefixes are normalized to **lowercase** (`DI`, `DevOps`, `Housekeeping` → `di`, `devops`, `housekeeping`). Slashes are converted to hyphens (`DevOps/Infra` → `devops-infra`). + +### Sprint-Status Keys + +```yaml +development_status: + epic-1: done # numeric epic + epic-devops-infra: done # alphanumeric epic + 1-1-project-setup: done # numeric story (epicId: 1) + di-1-pipeline-setup: in-progress # alphanumeric story (epicId: di) +``` + +### Linking Alphanumeric Stories to Epics + +When a story's filename prefix (e.g. `di`) doesn't directly match an epic ID (e.g. `devops-infra`), the dashboard uses story references declared in the epic body to bridge the gap: + +```markdown +## Epic DevOps/Infra: Pipeline Quality +- Story DI.1 - First task +- Story DI.2 - Second task +``` + +Stories referenced via `Story DI.1` or `S DI.1` inside an epic section are automatically associated with that epic, regardless of filename prefix. + +--- + ## Documentation | Document | Description | diff --git a/src/components/dashboard/epics-list.tsx b/src/components/dashboard/epics-list.tsx index 75b58af..5512268 100644 --- a/src/components/dashboard/epics-list.tsx +++ b/src/components/dashboard/epics-list.tsx @@ -34,8 +34,8 @@ export function EpicsList({ epics, owner, repo }: EpicsListProps) { ); } - const sorted = [...epics].sort( - (a, b) => (parseInt(a.id, 10) || 0) - (parseInt(b.id, 10) || 0), + const sorted = [...epics].sort((a, b) => + a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: "base" }), ); return ( diff --git a/src/components/dashboard/sprint-summary-card.tsx b/src/components/dashboard/sprint-summary-card.tsx index 549992a..626c6b7 100644 --- a/src/components/dashboard/sprint-summary-card.tsx +++ b/src/components/dashboard/sprint-summary-card.tsx @@ -53,12 +53,9 @@ export function SprintSummaryCard({ byEpic.set(epicKey, entry); } - // Sort epics numerically - const sortedEpics = [...byEpic.entries()].sort((a, b) => { - const numA = parseInt(a[0], 10) || 0; - const numB = parseInt(b[0], 10) || 0; - return numA - numB; - }); + const sortedEpics = [...byEpic.entries()].sort((a, b) => + a[0].localeCompare(b[0], undefined, { numeric: true, sensitivity: "base" }), + ); return ( diff --git a/src/lib/bmad/__tests__/correlate.test.ts b/src/lib/bmad/__tests__/correlate.test.ts index 7c01ca2..71b1cfe 100644 --- a/src/lib/bmad/__tests__/correlate.test.ts +++ b/src/lib/bmad/__tests__/correlate.test.ts @@ -138,3 +138,55 @@ describe("correlate", () => { expect(result.epics[0].status).toBe("done"); }); }); + +describe("correlate — alphanumeric IDs and backfill", () => { + it("backfills epicId for a story whose epicId doesn't match any epic.id", () => { + // Epic has id "devops-infra" and lists story "di.1" + // Story file produces epicId "di" (from filename di-1-task.md) + const stories = [makeStory({ id: "di.1", epicId: "di", status: "done" })]; + const epics = [makeEpic({ id: "devops-infra", title: "Pipeline Quality", stories: ["di.1"] })]; + + const result = correlate(null, epics, stories); + expect(result.stories[0].epicId).toBe("devops-infra"); + expect(result.stories[0].epicTitle).toBe("Pipeline Quality"); + }); + + it("assigns correct progress to alphanumeric epic via backfill", () => { + const stories = [ + makeStory({ id: "di.1", epicId: "di", status: "done" }), + makeStory({ id: "di.2", epicId: "di", status: "backlog" }), + ]; + const epics = [makeEpic({ id: "devops-infra", stories: ["di.1", "di.2"] })]; + + const result = correlate(null, epics, stories); + expect(result.epics[0].totalStories).toBe(2); + expect(result.epics[0].completedStories).toBe(1); + expect(result.epics[0].progressPercent).toBe(50); + }); + + it("mixes numeric and alphanumeric epics without interference", () => { + const stories = [ + makeStory({ id: "1.1", epicId: "1", status: "done" }), + makeStory({ id: "di.1", epicId: "di", status: "in-progress" }), + ]; + const epics = [ + makeEpic({ id: "1", title: "Foundation", stories: ["1.1"] }), + makeEpic({ id: "devops-infra", title: "DevOps", stories: ["di.1"] }), + ]; + + const result = correlate(null, epics, stories); + expect(result.epics[0].id).toBe("1"); + expect(result.epics[0].completedStories).toBe(1); + expect(result.epics[1].id).toBe("devops-infra"); + expect(result.epics[1].status).toBe("in-progress"); + }); + + it("does not backfill when epicId already matches an epic", () => { + const stories = [makeStory({ id: "1.1", epicId: "1", status: "backlog" })]; + const epics = [makeEpic({ id: "1", stories: ["1.1"] })]; + + const result = correlate(null, epics, stories); + // epicId should remain "1" (the matching epic), not get replaced + expect(result.stories[0].epicId).toBe("1"); + }); +}); diff --git a/src/lib/bmad/__tests__/parse-epic-file.test.ts b/src/lib/bmad/__tests__/parse-epic-file.test.ts index c9d2d8f..df6b22d 100644 --- a/src/lib/bmad/__tests__/parse-epic-file.test.ts +++ b/src/lib/bmad/__tests__/parse-epic-file.test.ts @@ -140,3 +140,50 @@ title: Test expect(epic!.stories).toEqual(["1.1", "1.2", "1.3"]); }); }); + +describe("parseEpicFile — alphanumeric IDs", () => { + it("extracts id from alphanumeric filename", () => { + const epic = parseEpicFile("Some content", "epic-housekeeping.md"); + expect(epic).not.toBeNull(); + expect(epic!.id).toBe("housekeeping"); + }); + + it("extracts id from hyphenated alphanumeric filename", () => { + const epic = parseEpicFile("Some content", "epic-devops-infra.md"); + expect(epic).not.toBeNull(); + expect(epic!.id).toBe("devops-infra"); + }); + + it("extracts id from alphanumeric heading with Epic keyword", () => { + const content = `## Epic HK: Housekeeping\n\nResolve tech debt.`; + const epic = parseEpicFile(content, "unknown.md"); + expect(epic).not.toBeNull(); + expect(epic!.id).toBe("hk"); + expect(epic!.title).toBe("Housekeeping"); + }); + + it("extracts id from heading with slash (normalizes to hyphen)", () => { + const content = `## Epic DevOps/Infra: Pipeline Quality\n\nAutomation foundation.`; + const epic = parseEpicFile(content, "unknown.md"); + expect(epic).not.toBeNull(); + expect(epic!.id).toBe("devops-infra"); + expect(epic!.title).toBe("Pipeline Quality"); + }); + + it("captures alphanumeric story refs (e.g. DI.1, HK.2)", () => { + const content = `--- +id: devops-infra +title: Pipeline +--- +- Story DI.1 - First +- Story DI.2 - Second +`; + const epic = parseEpicFile(content, "epic-devops-infra.md"); + expect(epic!.stories).toEqual(["di.1", "di.2"]); + }); + + it("regression: numeric epic-1.md still yields id '1'", () => { + const epic = parseEpicFile("Some content", "epic-1.md"); + expect(epic!.id).toBe("1"); + }); +}); diff --git a/src/lib/bmad/__tests__/parse-epics.test.ts b/src/lib/bmad/__tests__/parse-epics.test.ts index fc5cf93..f4fcc01 100644 --- a/src/lib/bmad/__tests__/parse-epics.test.ts +++ b/src/lib/bmad/__tests__/parse-epics.test.ts @@ -74,3 +74,62 @@ Description of second epic. expect(result.epics[0].stories).toEqual(["1.1"]); }); }); + +describe("parseEpics — alphanumeric IDs", () => { + it("parses a single-word alphanumeric epic", () => { + const content = `## Epic Housekeeping: Structural Stabilization\nCleanup and debt.`; + const result = parseEpics(content); + expect(result.epics).toHaveLength(1); + expect(result.epics[0].id).toBe("housekeeping"); + expect(result.epics[0].title).toBe("Structural Stabilization"); + }); + + it("parses an epic with slash in ID (normalizes to hyphen)", () => { + const content = `## Epic DevOps/Infra: Pipeline Quality\nFoundation automation.`; + const result = parseEpics(content); + expect(result.epics).toHaveLength(1); + expect(result.epics[0].id).toBe("devops-infra"); + expect(result.epics[0].title).toBe("Pipeline Quality"); + }); + + it("parses alphanumeric story refs from body", () => { + const content = `## Epic DevOps/Infra: Pipeline Quality +- Story DI.1 - First task +- Story DI.2 - Second task +### Story DI.3: Third task +`; + const result = parseEpics(content); + expect(result.epics[0].stories).toEqual(["di.1", "di.2", "di.3"]); + }); + + it("parses mixed numeric + alphanumeric epics in sequence", () => { + const content = `## Epic 1: Foundation +- Story 1.1 - Init + +## Epic DevOps/Infra: Pipeline +- Story DI.1 - CI/CD + +## Epic 2: Features +- Story 2.1 - Auth +`; + const result = parseEpics(content); + expect(result.epics).toHaveLength(3); + expect(result.epics[0].id).toBe("1"); + expect(result.epics[1].id).toBe("devops-infra"); + expect(result.epics[2].id).toBe("2"); + expect(result.epics[1].stories).toEqual(["di.1"]); + }); + + it("does NOT parse headings without 'Epic' keyword as alphanumeric epics", () => { + const content = `## Introduction: Overview\n\nSome intro text.`; + const result = parseEpics(content); + expect(result.epics).toHaveLength(0); + }); + + it("regression: numeric ID still works unchanged after alphanumeric support", () => { + const content = `## Epic 3: Auth System\n- Story 3.1 - Login\n- Story 3.2 - Logout`; + const result = parseEpics(content); + expect(result.epics[0].id).toBe("3"); + expect(result.epics[0].stories).toEqual(["3.1", "3.2"]); + }); +}); diff --git a/src/lib/bmad/__tests__/parse-sprint-status.test.ts b/src/lib/bmad/__tests__/parse-sprint-status.test.ts index dc272e1..7ee1626 100644 --- a/src/lib/bmad/__tests__/parse-sprint-status.test.ts +++ b/src/lib/bmad/__tests__/parse-sprint-status.test.ts @@ -102,3 +102,82 @@ development_status: }); }); }); + +describe("parseSprintStatus — alphanumeric IDs", () => { + it("parses alphanumeric epic key with single word", () => { + const content = ` +development_status: + epic-housekeeping: in-progress +`; + const result = parseSprintStatus(content); + expect(result).not.toBeNull(); + expect(result!.epicStatuses).toHaveLength(1); + expect(result!.epicStatuses[0]).toEqual({ id: "housekeeping", status: "in-progress" }); + }); + + it("parses alphanumeric epic key with hyphenated name", () => { + const content = ` +development_status: + epic-devops-infra: done +`; + const result = parseSprintStatus(content); + expect(result!.epicStatuses[0]).toEqual({ id: "devops-infra", status: "done" }); + }); + + it("parses alphanumeric story keys", () => { + const content = ` +development_status: + di-1-task-a: done + hk-2-refactor: in-progress +`; + const result = parseSprintStatus(content); + expect(result).not.toBeNull(); + expect(result!.sprintStatus.stories).toHaveLength(2); + expect(result!.sprintStatus.stories[0]).toEqual({ + id: "di.1", + title: "di-1-task-a", + status: "done", + epicId: "di", + }); + expect(result!.sprintStatus.stories[1]).toEqual({ + id: "hk.2", + title: "hk-2-refactor", + status: "in-progress", + epicId: "hk", + }); + }); + + it("skips alphanumeric retrospective entries", () => { + const content = ` +development_status: + di-1-task: done + epic-devops-infra-retrospective: done +`; + const result = parseSprintStatus(content); + expect(result!.sprintStatus.stories).toHaveLength(1); + expect(result!.epicStatuses).toHaveLength(0); + }); + + it("does NOT parse single-segment alpha keys as stories", () => { + // A key with no number segment (no "-N-") should not match either pattern + const content = ` +development_status: + di-only: done +`; + const result = parseSprintStatus(content); + expect(result!.sprintStatus.stories).toHaveLength(0); + expect(result!.epicStatuses).toHaveLength(0); + }); + + it("regression: numeric story keys still parse correctly", () => { + const content = ` +development_status: + epic-1: done + 1-1-project-initialization: done +`; + const result = parseSprintStatus(content); + expect(result!.epicStatuses[0]).toEqual({ id: "1", status: "done" }); + expect(result!.sprintStatus.stories[0].id).toBe("1.1"); + expect(result!.sprintStatus.stories[0].epicId).toBe("1"); + }); +}); diff --git a/src/lib/bmad/__tests__/parse-story.test.ts b/src/lib/bmad/__tests__/parse-story.test.ts index 7908ccb..2dc638f 100644 --- a/src/lib/bmad/__tests__/parse-story.test.ts +++ b/src/lib/bmad/__tests__/parse-story.test.ts @@ -143,3 +143,31 @@ status: in-progress }); }); }); + +describe("parseStory — alphanumeric filenames", () => { + it("extracts id and epicId from alpha-prefixed filename", () => { + const result = parseStory("# My Task\n\nSome content", "di-1-task.md"); + expect(result).not.toBeNull(); + expect(result!.id).toBe("di.1"); + expect(result!.epicId).toBe("di"); + }); + + it("normalizes uppercase prefix to lowercase", () => { + const result = parseStory("# Task\n\nContent", "HK-2-refactor.md"); + expect(result).not.toBeNull(); + expect(result!.id).toBe("hk.2"); + expect(result!.epicId).toBe("hk"); + }); + + it("extracts title from alphanumeric story heading", () => { + const content = "# Story DI.1: Pipeline Setup\n\nContent"; + const result = parseStory(content, "di-1-pipeline.md"); + expect(result!.title).toBe("Pipeline Setup"); + }); + + it("regression: numeric N-N-title.md still produces id N.N", () => { + const result = parseStory("# Title\n\nContent", "3-2-setup.md"); + expect(result!.id).toBe("3.2"); + expect(result!.epicId).toBe("3"); + }); +}); diff --git a/src/lib/bmad/__tests__/parser.test.ts b/src/lib/bmad/__tests__/parser.test.ts new file mode 100644 index 0000000..058bf5e --- /dev/null +++ b/src/lib/bmad/__tests__/parser.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; + +/** + * Unit tests for the storyPaths filter logic in parser.ts. + * We extract the filter predicate here to test it in isolation without + * needing a full ContentProvider mock. + */ +function isStoryFile(filename: string): boolean { + if (/^\d+-\d+-.+\.md$/.test(filename)) return true; + if (/^[a-z][a-z0-9_-]*-\d+-.+\.md$/i.test(filename)) return true; + if (/^story[_-]?\d/i.test(filename)) return true; + return false; +} + +describe("storyPaths filter", () => { + describe("numeric filenames (existing behavior)", () => { + it("includes N-N-title.md", () => { + expect(isStoryFile("1-1-project-initialization.md")).toBe(true); + }); + + it("includes large numbers", () => { + expect(isStoryFile("10-3-auth-flow.md")).toBe(true); + }); + + it("includes story-N.md (legacy)", () => { + expect(isStoryFile("story-1.md")).toBe(true); + }); + + it("includes story_N.md (legacy)", () => { + expect(isStoryFile("story_2.md")).toBe(true); + }); + }); + + describe("alphanumeric filenames (new support)", () => { + it("includes alpha-N-title.md", () => { + expect(isStoryFile("di-1-task.md")).toBe(true); + }); + + it("includes multi-char prefix with number", () => { + expect(isStoryFile("hk-5-gate-qa.md")).toBe(true); + }); + + it("includes uppercase prefix (case-insensitive)", () => { + expect(isStoryFile("DI-1-task.md")).toBe(true); + }); + }); + + describe("excluded files", () => { + it("excludes alpha files missing the number segment", () => { + // "di-foo.md" looks like an alpha file but has no storyNumber — not a story + expect(isStoryFile("di-foo.md")).toBe(false); + }); + + it("excludes generic markdown files", () => { + expect(isStoryFile("readme.md")).toBe(false); + }); + + it("excludes sprint-status.yaml (not .md)", () => { + expect(isStoryFile("sprint-status.yaml")).toBe(false); + }); + + it("excludes files with only one numeric segment", () => { + expect(isStoryFile("1-task.md")).toBe(false); + }); + + it("excludes files starting with digit that look like N-N but miss slug", () => { + expect(isStoryFile("1-1.md")).toBe(false); + }); + }); +}); diff --git a/src/lib/bmad/correlate.ts b/src/lib/bmad/correlate.ts index c09e33c..f89a5cc 100644 --- a/src/lib/bmad/correlate.ts +++ b/src/lib/bmad/correlate.ts @@ -5,8 +5,8 @@ import { BmadProject, Epic, SprintStatus, StoryDetail, EpicStatus } from "./type * a human-readable title: "Project Initialization". */ function formatStoryTitle(slug: string): string { - // Remove the leading "N-N-" prefix - const withoutPrefix = slug.replace(/^\d+-\d+-/, ""); + // Remove the leading "N-N-" or "alpha-N-" prefix + const withoutPrefix = slug.replace(/^(?:\d+|[a-z][a-z0-9_-]*)-\d+-/i, ""); if (!withoutPrefix || withoutPrefix === slug) return slug; return withoutPrefix .split("-") @@ -93,11 +93,16 @@ export function correlate( }); const resultStories = mutableStories.map((story) => { - if (story.epicId) { - const epic = enrichedEpics.find((e) => e.id === story.epicId); - if (epic) { - return { ...story, epicTitle: epic.title }; - } + // Primary: match by epicId + let epic = story.epicId + ? enrichedEpics.find((e) => e.id === story.epicId) + : undefined; + // Backfill: when epicId doesn't match any epic, find one that lists this story + if (!epic) { + epic = enrichedEpics.find((e) => e.stories.includes(story.id)); + } + if (epic) { + return { ...story, epicId: epic.id, epicTitle: epic.title }; } return story; }); diff --git a/src/lib/bmad/parse-epic-file.ts b/src/lib/bmad/parse-epic-file.ts index 308e571..a9f0a19 100644 --- a/src/lib/bmad/parse-epic-file.ts +++ b/src/lib/bmad/parse-epic-file.ts @@ -1,5 +1,6 @@ import { Epic, EpicStatus } from "./types"; import matter from "gray-matter"; +import { normalizeAlphanumericId } from "./utils"; /** * Parse a single epic from an individual markdown file. @@ -57,14 +58,19 @@ function extractId( return String(fm.id); } - // 2. From heading: ## Epic 1: Title or ## 1 - Title - const headingMatch = body.match(/^##\s+(?:Epic\s+)?(\d+)[\s:.—-]/im); - if (headingMatch) return headingMatch[1]; + // 2. From heading: "## Epic 1: Title", "## 1 - Title", or "## Epic DevOps/Infra: Title" + const numericHeading = body.match(/^##\s+(?:Epic\s+)?(\d+)[\s:.—-]/im); + if (numericHeading) return numericHeading[1]; + const alphaHeading = body.match(/^##\s+Epic\s+([A-Za-z][A-Za-z0-9_/-]*)[\s:.—-]/im); + if (alphaHeading) return normalizeAlphanumericId(alphaHeading[1]); - // 3. From filename: epic-1.md, epic_1.md, 1-title.md, 1.md + // 3. From filename: epic-1.md, epic_1.md, 1-title.md, 1.md, epic-di.md, epic-devops-infra.md const nameWithoutExt = filename.replace(/\.md$/i, ""); - const fileMatch = nameWithoutExt.match(/^(?:epic[_-]?)?(\d+)(?:[_-]|$)/i); - if (fileMatch) return fileMatch[1]; + const numericFile = nameWithoutExt.match(/^(?:epic[_-]?)?(\d+)(?:[_-]|$)/i); + if (numericFile) return numericFile[1]; + // Alpha requires explicit "epic-" prefix to avoid false positives (e.g. "readme.md") + const alphaFile = nameWithoutExt.match(/^epic[_-]([a-z][a-z0-9_-]*)$/i); + if (alphaFile) return normalizeAlphanumericId(alphaFile[1]); return null; } @@ -81,7 +87,7 @@ function extractTitle( // 2. From heading const headingMatch = body.match( - /^##\s+(?:Epic\s+)?\d+[\s:.—-]+(.+)/im, + /^##\s+(?:Epic\s+)?(?:\d+|[A-Za-z][A-Za-z0-9_/-]*)[\s:.—-]+(.+)/im, ); if (headingMatch) return headingMatch[1].trim(); @@ -89,8 +95,11 @@ function extractTitle( const h1Match = body.match(/^#\s+(.+)/m); if (h1Match) { const h1 = h1Match[1].trim(); - // Strip "Epic N:" prefix if present - const stripped = h1.replace(/^(?:Epic\s+)?\d+[\s:.—-]+/i, "").trim(); + // Strip "Epic N:" or "Epic DevOps/Infra:" prefix if present + const stripped = h1.replace( + /^(?:Epic\s+(?:\d+|[A-Za-z][A-Za-z0-9_/-]*)|(?:Epic\s+)?\d+)[\s:.—-]+/i, + "" + ).trim(); return stripped || h1; } @@ -99,9 +108,12 @@ function extractTitle( function extractStoryReferences(body: string): string[] { const ids: string[] = []; - const matches = body.matchAll(/(?:story|S)[\s-]*(\d+(?:\.\d+)?)/gi); + const matches = body.matchAll( + /(?:story|S)[\s-]*((?:\d+(?:\.\d+)?)|(?:[A-Za-z][A-Za-z0-9_-]*\.\d+))/gi + ); for (const m of matches) { - const id = m[1]; + const raw = m[1]; + const id = /[A-Za-z]/.test(raw) ? raw.toLowerCase() : raw; if (id && !ids.includes(id)) { ids.push(id); } diff --git a/src/lib/bmad/parse-epics.ts b/src/lib/bmad/parse-epics.ts index cd50669..5bc0fdc 100644 --- a/src/lib/bmad/parse-epics.ts +++ b/src/lib/bmad/parse-epics.ts @@ -1,4 +1,5 @@ import { Epic, EpicStatus } from "./types"; +import { normalizeAlphanumericId } from "./utils"; export function parseEpics(content: string): { epics: Epic[]; error?: string } { try { @@ -10,16 +11,20 @@ export function parseEpics(content: string): { epics: Epic[]; error?: string } { let storyIds: string[] = []; for (const line of lines) { + // Numeric: "## Epic 1: Title" or "## 1 - Title" + // Alpha: "## Epic DevOps/Infra: Title" or "## Epic Housekeeping: Title" const epicMatch = line.match( - /^##\s+(?:Epic\s+)?(\d+)[\s:.—-]+(.+)/i + /^##\s+(?:(?:Epic\s+)?(\d+)|Epic\s+([A-Za-z][A-Za-z0-9_/-]*))[\s:.—-]+(.+)/i ); if (epicMatch) { if (currentEpic && currentEpic.id) { epics.push(finalizeEpic(currentEpic, descLines, storyIds)); } + const rawId = epicMatch[1] ?? epicMatch[2]; + const id = epicMatch[1] ? rawId : normalizeAlphanumericId(rawId); currentEpic = { - id: epicMatch[1], - title: epicMatch[2].trim(), + id, + title: epicMatch[3].trim(), }; descLines = []; storyIds = []; @@ -27,13 +32,15 @@ export function parseEpics(content: string): { epics: Epic[]; error?: string } { } if (currentEpic) { - const storyRef = line.match(/(?:story|S)[\s-]*(\d+(?:\.\d+)?)/gi); - if (storyRef) { - for (const ref of storyRef) { - const id = ref.replace(/(?:story|S)[\s-]*/i, "").trim(); - if (id && !storyIds.includes(id)) { - storyIds.push(id); - } + // Match numeric (Story 1.1) and alphanumeric (Story DI.1) refs + const storyRef = line.matchAll( + /(?:story|S)[\s-]*((?:\d+(?:\.\d+)?)|(?:[A-Za-z][A-Za-z0-9_-]*\.\d+))/gi + ); + for (const match of storyRef) { + const raw = match[1]; + const id = /[A-Za-z]/.test(raw) ? raw.toLowerCase() : raw; + if (id && !storyIds.includes(id)) { + storyIds.push(id); } } diff --git a/src/lib/bmad/parse-sprint-status.ts b/src/lib/bmad/parse-sprint-status.ts index 7e1b148..f8b6391 100644 --- a/src/lib/bmad/parse-sprint-status.ts +++ b/src/lib/bmad/parse-sprint-status.ts @@ -1,6 +1,6 @@ import yaml from "js-yaml"; import { SprintStatus, SprintStoryEntry, EpicStatus } from "./types"; -import { normalizeStoryStatus } from "./utils"; +import { normalizeStoryStatus, normalizeAlphanumericId } from "./utils"; function normalizeEpicStatus(raw: string | undefined): EpicStatus { if (!raw) return "not-started"; @@ -37,23 +37,32 @@ export function parseSprintStatus(content: string): ParsedSprintData | null { for (const [key, value] of Object.entries(devStatus)) { const statusStr = String(value); - // Epic entries: "epic-N: status" - const epicMatch = key.match(/^epic-(\d+)$/); + // Retrospective entries: skip (before epicMatch to catch epic-N-retrospective keys) + if (key.includes("retrospective")) continue; + + // Epic entries: "epic-N: status" or "epic-devops-infra: status" + const epicMatch = key.match(/^epic-(\d+|[a-z][a-z0-9_-]*)$/i); if (epicMatch) { + const rawId = epicMatch[1]; + const id = /^\d+$/.test(rawId) ? rawId : normalizeAlphanumericId(rawId); epicStatuses.push({ - id: epicMatch[1], + id, status: normalizeEpicStatus(statusStr), }); continue; } - // Retrospective entries: skip - if (key.includes("retrospective")) continue; - - // Story entries: "N-N-title: status" (e.g., "1-1-project-initialization: done") - const storyMatch = key.match(/^(\d+)-(\d+)-(.+)$/); + // Story entries: "N-N-title: status" (numeric) or "di-1-title: status" (alpha prefix) + const numericStory = key.match(/^(\d+)-(\d+)-(.+)$/); + const alphaStory = !numericStory + ? key.match(/^([a-z][a-z0-9_-]*?)-(\d+)-(.+)$/i) + : null; + const storyMatch = numericStory ?? alphaStory; if (storyMatch) { - const epicId = storyMatch[1]; + const rawEpicId = storyMatch[1]; + const epicId = /^\d+$/.test(rawEpicId) + ? rawEpicId + : normalizeAlphanumericId(rawEpicId); const storyNum = storyMatch[2]; const id = `${epicId}.${storyNum}`; stories.push({ diff --git a/src/lib/bmad/parse-story.ts b/src/lib/bmad/parse-story.ts index 6315c99..dff091c 100644 --- a/src/lib/bmad/parse-story.ts +++ b/src/lib/bmad/parse-story.ts @@ -1,6 +1,6 @@ import matter from "gray-matter"; import { StoryDetail, StoryTask } from "./types"; -import { normalizeStoryStatus } from "./utils"; +import { normalizeStoryStatus, normalizeAlphanumericId } from "./utils"; export function parseStory( content: string, @@ -10,8 +10,14 @@ export function parseStory( // Try to extract ID from filename first // Pattern: "N-N-title.md" (e.g., "1-1-project-initialization.md") const numericMatch = filename.match(/^(\d+)-(\d+)-/); + // Alphanumeric prefix: "di-1-title.md" or "hk-2-title.md" + const alphaMatch = !numericMatch + ? filename.match(/^([A-Za-z][A-Za-z0-9_-]*?)-(\d+)-/) + : null; // Legacy pattern: "story-N.md" or "story_N.md" - const legacyMatch = filename.match(/story[_-]?(\d+(?:[._-]\d+)?)/i); + const legacyMatch = !numericMatch && !alphaMatch + ? filename.match(/story[_-]?(\d+(?:[._-]\d+)?)/i) + : null; let id: string; let epicId: string; @@ -19,6 +25,10 @@ export function parseStory( if (numericMatch) { id = `${numericMatch[1]}.${numericMatch[2]}`; epicId = numericMatch[1]; + } else if (alphaMatch) { + const prefix = normalizeAlphanumericId(alphaMatch[1]); + id = `${prefix}.${alphaMatch[2]}`; + epicId = prefix; } else if (legacyMatch) { id = legacyMatch[1].replace(/[._]/, "."); epicId = id.includes(".") ? id.split(".")[0] : ""; @@ -50,8 +60,8 @@ export function parseStory( body = content; } - // Extract title from heading: "# Story 1.1: Title" or "# Title" - const titleMatch = body.match(/^#\s+(?:Story\s+[\d.]+[:\s]+)?(.+)/m); + // Extract title from heading: "# Story 1.1: Title", "# Story DI.1: Title", or "# Title" + const titleMatch = body.match(/^#\s+(?:Story\s+[\w.-]+[:\s]+)?(.+)/m); const title = frontmatterTitle || titleMatch?.[1]?.trim() || `Story ${id}`; // Extract status from "Status: done" line (plain text, not frontmatter) diff --git a/src/lib/bmad/parser.ts b/src/lib/bmad/parser.ts index 55c76f9..b8749b5 100644 --- a/src/lib/bmad/parser.ts +++ b/src/lib/bmad/parser.ts @@ -58,6 +58,7 @@ export async function getBmadProject( if (!p.includes(IMPLEMENTATION) || !p.endsWith(".md")) return false; const filename = p.split("/").pop() || ""; if (/^\d+-\d+-.+\.md$/.test(filename)) return true; + if (/^[a-z][a-z0-9_-]*-\d+-.+\.md$/i.test(filename)) return true; if (/^story[_-]?\d/i.test(filename)) return true; return false; }); diff --git a/src/lib/bmad/utils.ts b/src/lib/bmad/utils.ts index 460cb0c..c9f07c0 100644 --- a/src/lib/bmad/utils.ts +++ b/src/lib/bmad/utils.ts @@ -71,3 +71,7 @@ export function normalizeStoryId(raw: string): string { .replace(/[._]/, ".") .trim(); } + +export function normalizeAlphanumericId(raw: string): string { + return raw.toLowerCase().replace(/\//g, "-"); +} From 45bb7b3e60cb20132dd5aa2b1fd5b9ac5a5dcfc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Thu, 7 May 2026 16:19:40 -0300 Subject: [PATCH 2/5] feat: improve epic/story ID rendering and tighten parser filtering - Implement short IDs for epics (prefix-based) and stories (order-based) - Update UI badges to use short IDs and show full ID on hover - Tighten parser to ignore BMAD prompt and retrospective files - Enforce declaration check: ignore story files not in epics.md or sprint-status.yaml - Support nested story IDs (e.g., HKP.1.2 -> 1.2) - Log orphan/duplicate stories in Health Report --- src/components/dashboard/epics-list.tsx | 6 ++-- src/components/epics/epic-timeline-card.tsx | 5 +-- src/components/epics/epics-browser.tsx | 7 ++-- src/lib/bmad/correlate.ts | 40 ++++++++++++++++++--- src/lib/bmad/parser.ts | 18 ++++++++-- src/lib/bmad/types.ts | 1 + src/lib/bmad/utils.ts | 35 +++++++++++++++++- 7 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/components/dashboard/epics-list.tsx b/src/components/dashboard/epics-list.tsx index 5512268..a094cab 100644 --- a/src/components/dashboard/epics-list.tsx +++ b/src/components/dashboard/epics-list.tsx @@ -6,6 +6,8 @@ import { cn } from "@/lib/utils"; import Link from "next/link"; import type { Epic } from "@/lib/bmad/types"; +import { getEpicShortId } from "@/lib/bmad/utils"; + interface EpicsListProps { epics: Epic[]; owner: string; @@ -55,8 +57,8 @@ export function EpicsList({ epics, owner, repo }: EpicsListProps) { )} >
- - E{epic.id} + + {getEpicShortId(epic)} {epic.title} diff --git a/src/components/epics/epic-timeline-card.tsx b/src/components/epics/epic-timeline-card.tsx index 4238732..5e93731 100644 --- a/src/components/epics/epic-timeline-card.tsx +++ b/src/components/epics/epic-timeline-card.tsx @@ -2,6 +2,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { StatusBadge } from "@/components/shared/status-badge"; import { SegmentedProgressBar } from "@/components/shared/segmented-progress-bar"; import type { Epic } from "@/lib/bmad/types"; +import { getEpicShortId } from "@/lib/bmad/utils"; function getProgressColor(percent: number) { return percent >= 100 ? "bg-success" : "bg-warning"; @@ -32,8 +33,8 @@ export function EpicTimelineCard({ epic, onClick }: EpicTimelineCardProps) {
- - {epic.id} + + {getEpicShortId(epic)}

{epic.title}

diff --git a/src/components/epics/epics-browser.tsx b/src/components/epics/epics-browser.tsx index 944b0a4..adc2712 100644 --- a/src/components/epics/epics-browser.tsx +++ b/src/components/epics/epics-browser.tsx @@ -10,6 +10,7 @@ import { StoryDetailView } from "./story-detail-view"; import { useBreadcrumb } from "@/contexts/breadcrumb-context"; import { ArrowLeft } from "lucide-react"; import type { Epic, StoryDetail } from "@/lib/bmad/types"; +import { getStoryShortId } from "@/lib/bmad/utils"; type View = "epics" | "stories" | "story"; @@ -229,7 +230,7 @@ export function EpicsBrowser({
) : (
- {epicStories.map((story) => ( + {epicStories.map((story, index) => (
- - {story.id} + + {getStoryShortId(story.id, index)} {story.title} diff --git a/src/lib/bmad/correlate.ts b/src/lib/bmad/correlate.ts index f89a5cc..cef88dc 100644 --- a/src/lib/bmad/correlate.ts +++ b/src/lib/bmad/correlate.ts @@ -19,14 +19,44 @@ export function correlate( epics: Epic[], stories: StoryDetail[], epicStatuses?: { id: string; status: EpicStatus }[] -): { epics: Epic[]; stories: StoryDetail[] } { +): { epics: Epic[]; stories: StoryDetail[]; droppedStories: StoryDetail[] } { // Work on copies to avoid mutating the input arrays/objects - let mutableStories = stories.map((s) => ({ ...s })); + const declaredIds = new Set([ + ...epics.flatMap((e) => e.stories), + ...(sprintStatus?.stories.map((s) => s.id) ?? []), + ]); + + const sprintSlugs = new Set( + sprintStatus?.stories.map(s => s.title) || [] + ); + + const droppedStories: StoryDetail[] = []; const storyMap = new Map(); - for (const s of mutableStories) { - storyMap.set(s.id, s); + + for (const s of stories) { + if (!declaredIds.has(s.id)) { + droppedStories.push({ ...s }); + continue; + } + + if (storyMap.has(s.id)) { + const current = storyMap.get(s.id)!; + const currentMatches = current._sourcePath && Array.from(sprintSlugs).some(slug => current._sourcePath!.includes(slug)); + const newMatches = s._sourcePath && Array.from(sprintSlugs).some(slug => s._sourcePath!.includes(slug)); + + if (newMatches && !currentMatches) { + droppedStories.push(current); + storyMap.set(s.id, { ...s }); + } else { + droppedStories.push({ ...s }); + } + } else { + storyMap.set(s.id, { ...s }); + } } + let mutableStories = Array.from(storyMap.values()); + // Apply statuses from sprint-status.yaml to stories, and create stubs for // stories that only exist in sprint-status (no markdown file). if (sprintStatus) { @@ -107,7 +137,7 @@ export function correlate( return story; }); - return { epics: enrichedEpics, stories: resultStories }; + return { epics: enrichedEpics, stories: resultStories, droppedStories }; } export function computeProjectStats(project: Omit): { diff --git a/src/lib/bmad/parser.ts b/src/lib/bmad/parser.ts index b8749b5..977244c 100644 --- a/src/lib/bmad/parser.ts +++ b/src/lib/bmad/parser.ts @@ -57,6 +57,8 @@ export async function getBmadProject( const storyPaths = bmadPaths.filter((p) => { if (!p.includes(IMPLEMENTATION) || !p.endsWith(".md")) return false; const filename = p.split("/").pop() || ""; + if (/^epic[-_]/i.test(filename)) return false; + if (/^bmad[-_]/i.test(filename)) return false; if (/^\d+-\d+-.+\.md$/.test(filename)) return true; if (/^[a-z][a-z0-9_-]*-\d+-.+\.md$/i.test(filename)) return true; if (/^story[_-]?\d/i.test(filename)) return true; @@ -146,6 +148,7 @@ export async function getBmadProject( const filename = storyPath.split("/").pop() || ""; const story = parseStory(content, filename); if (story) { + story._sourcePath = storyPath; rawStories.push(story); } else { parseErrors.push({ file: storyPath, error: "Failed to parse story. Check the markdown format and section structure.", contentType: "story" }); @@ -159,8 +162,19 @@ export async function getBmadProject( console.warn(`[BMAD Parse] ${owner}/${repo}: ${parseErrors.length} parsing errors out of ${totalFiles} files`); } - const { epics, stories } = correlate(sprintStatus, rawEpics, rawStories, epicStatuses); - const storyPathSet = new Set(storyPaths); + const { epics, stories, droppedStories } = correlate(sprintStatus, rawEpics, rawStories, epicStatuses); + + for (const dropped of droppedStories) { + if (dropped._sourcePath) { + parseErrors.push({ + file: dropped._sourcePath, + error: "Story file not declared in epics.md or sprint-status.yaml (or was a duplicate) — ignored", + contentType: "orphan-story" + }); + } + } + + const storyPathSet = new Set(stories.map(s => s._sourcePath).filter(Boolean)); const docPaths = bmadPaths.filter((p) => !storyPathSet.has(p)); const fileTree = buildFileTree(docPaths, BMAD_OUTPUT); diff --git a/src/lib/bmad/types.ts b/src/lib/bmad/types.ts index 5f3e156..53f8cbf 100644 --- a/src/lib/bmad/types.ts +++ b/src/lib/bmad/types.ts @@ -46,6 +46,7 @@ export interface StoryDetail { tasks: StoryTask[]; completedTasks: number; totalTasks: number; + _sourcePath?: string; } export interface StoryTask { diff --git a/src/lib/bmad/utils.ts b/src/lib/bmad/utils.ts index c9f07c0..d8e5401 100644 --- a/src/lib/bmad/utils.ts +++ b/src/lib/bmad/utils.ts @@ -1,4 +1,4 @@ -import { FileTreeNode, StoryStatus } from "./types"; +import { Epic, FileTreeNode, StoryStatus } from "./types"; /** * Canonical normalizeStoryStatus used across all BMAD parsers. @@ -17,6 +17,39 @@ export function normalizeStoryStatus(raw: string | undefined): StoryStatus { return "backlog"; } +/** + * Returns a short, visual-friendly ID for an Epic (e.g., "DI", "HK", "1"). + */ +export function getEpicShortId(epic: Epic): string { + // If numeric ID, return as is + if (/^\d+$/.test(epic.id)) return epic.id; + + // Try to get prefix from first story (e.g., "di.1" -> "DI") + if (epic.stories && epic.stories.length > 0) { + const firstStoryId = epic.stories[0]; + const parts = firstStoryId.split("."); + if (parts.length > 1 && parts[0].length <= 4) { + return parts[0].toUpperCase(); + } + } + + // Fallback: first 2 characters of the ID in uppercase + return epic.id.slice(0, 2).toUpperCase(); +} + +/** + * Returns a short, visual-friendly ID for a Story (e.g., "1", "2"). + * Typically used when the Epic context is already clear. + */ +export function getStoryShortId(storyId: string, index?: number): string { + const parts = storyId.split("."); + if (parts.length > 1) { + // If it's like "1.1", "DI.1", or "HKP.1.2", return everything after the first dot + return parts.slice(1).join("."); + } + return index !== undefined ? String(index + 1) : storyId; +} + export function buildFileTree(paths: string[], basePath: string): FileTreeNode[] { const root: FileTreeNode[] = []; From 0e1a4a6e0a5e011cd4bba7d780f31eca3672f78d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Thu, 7 May 2026 16:32:50 -0300 Subject: [PATCH 3/5] fix: use getEpicShortId for epic view header and badge --- src/components/epics/epics-browser.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/epics/epics-browser.tsx b/src/components/epics/epics-browser.tsx index adc2712..8728d7a 100644 --- a/src/components/epics/epics-browser.tsx +++ b/src/components/epics/epics-browser.tsx @@ -10,7 +10,7 @@ import { StoryDetailView } from "./story-detail-view"; import { useBreadcrumb } from "@/contexts/breadcrumb-context"; import { ArrowLeft } from "lucide-react"; import type { Epic, StoryDetail } from "@/lib/bmad/types"; -import { getStoryShortId } from "@/lib/bmad/utils"; +import { getStoryShortId, getEpicShortId } from "@/lib/bmad/utils"; type View = "epics" | "stories" | "story"; @@ -138,7 +138,7 @@ export function EpicsBrowser({ return (

- Epic {selectedEpic.id}: {selectedEpic.title} + Epic {getEpicShortId(selectedEpic)}: {selectedEpic.title}

{epicStories.length} stories @@ -185,8 +185,8 @@ export function EpicsBrowser({

- - {selectedEpic.id} + + {getEpicShortId(selectedEpic)}

{selectedEpic.title} From a8f9451bdb8f38dcfa593f78b642100b8a9e679b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Thu, 7 May 2026 17:25:59 -0300 Subject: [PATCH 4/5] fix(local): normalize windows paths in local-provider security guards --- src/lib/content-provider/local-provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/content-provider/local-provider.ts b/src/lib/content-provider/local-provider.ts index 50ad95a..2041f2a 100644 --- a/src/lib/content-provider/local-provider.ts +++ b/src/lib/content-provider/local-provider.ts @@ -183,8 +183,8 @@ export class LocalProvider implements ContentProvider { } // Guard 7 — Restrict access to BMAD directories only - const firstSegment = filePath.split(path.sep)[0]; - if (!LocalProvider.BMAD_DIRS.has(firstSegment)) { + const firstSegment = filePath.replace(/\\/g, "/").split("/")[0]; + if (!LocalProvider.BMAD_DIRS.has(firstSegment)) { throw new Error("Access denied: only BMAD directories are accessible"); } } From 035656b03b3e823ec6d111297a692cecc63a2641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Thu, 7 May 2026 18:07:05 -0300 Subject: [PATCH 5/5] chore: approve builds for core dependencies --- pnpm-workspace.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 581a9d5..a3ba19d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,11 @@ +allowBuilds: + '@prisma/client': true + '@prisma/engines': true + esbuild: true + msw: true + prisma: true + sharp: true + unrs-resolver: true ignoredBuiltDependencies: - sharp - unrs-resolver