-
- {story.id}
+
+ {getStoryShortId(story.id, index)}
{story.title}
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..cef88dc 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("-")
@@ -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) {
@@ -93,16 +123,21 @@ 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;
});
- return { epics: enrichedEpics, stories: resultStories };
+ return { epics: enrichedEpics, stories: resultStories, droppedStories };
}
export function computeProjectStats(project: Omit): {
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..977244c 100644
--- a/src/lib/bmad/parser.ts
+++ b/src/lib/bmad/parser.ts
@@ -57,7 +57,10 @@ 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;
return false;
});
@@ -145,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" });
@@ -158,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 460cb0c..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[] = [];
@@ -71,3 +104,7 @@ export function normalizeStoryId(raw: string): string {
.replace(/[._]/, ".")
.trim();
}
+
+export function normalizeAlphanumericId(raw: string): string {
+ return raw.toLowerCase().replace(/\//g, "-");
+}
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");
}
}