@hyperdrive.bot/bmad-workflow 1.0.13 → 1.0.15

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.
@@ -78,19 +78,34 @@ export default class PrdFix extends Command {
78
78
  // First, check if the PRD already parses correctly
79
79
  this.log(Colors.info(`\nAnalyzing PRD: ${relativePath}`));
80
80
  let needsFix = false;
81
+ let fixReason = '';
81
82
  try {
82
83
  const epics = this.prdParser.parseEpics(originalContent, prdPath);
83
- this.log(Colors.success(`\n✓ PRD already valid - found ${epics.length} epic(s)`));
84
- this.log(Colors.dim(' No changes needed.\n'));
85
- // Show the epics found
86
- for (const epic of epics) {
87
- this.log(Colors.dim(` • Epic ${epic.number}: ${epic.title}`));
84
+ // Also validate that each epic contains stories
85
+ const storyCounts = this.prdParser.countStoriesPerEpic(originalContent, epics);
86
+ const epicsWithoutStories = epics.filter((e) => (storyCounts.get(e.number) || 0) === 0);
87
+ if (epicsWithoutStories.length > 0) {
88
+ needsFix = true;
89
+ const missing = epicsWithoutStories.map((e) => `Epic ${e.number}`).join(', ');
90
+ fixReason = `Found ${epics.length} epic(s) but ${epicsWithoutStories.length} missing stories: ${missing}`;
91
+ this.log(Colors.warning(`\n⚠ ${fixReason}`));
92
+ this.log(Colors.warning(' Epics need story headers like: ### Story 1.1: Title'));
93
+ }
94
+ else {
95
+ this.log(Colors.success(`\n✓ PRD already valid - found ${epics.length} epic(s)`));
96
+ this.log(Colors.dim(' No changes needed.\n'));
97
+ // Show the epics found with story counts
98
+ for (const epic of epics) {
99
+ const count = storyCounts.get(epic.number) || 0;
100
+ this.log(Colors.dim(` • Epic ${epic.number}: ${epic.title} (${count} stories)`));
101
+ }
102
+ this.log('');
103
+ return;
88
104
  }
89
- this.log('');
90
- return;
91
105
  }
92
106
  catch {
93
107
  needsFix = true;
108
+ fixReason = 'No epics found';
94
109
  this.log(Colors.warning('\n⚠ PRD format issues detected - attempting auto-fix...'));
95
110
  }
96
111
  if (needsFix) {
@@ -1376,9 +1376,15 @@ Write output to: ${outputPath}`;
1376
1376
  this.logger.warn({ epicNumber: epic.number }, 'Epic file not found, skipping');
1377
1377
  return [];
1378
1378
  }
1379
- const epicContent = await this.fileManager.readFile(epicFilePath);
1380
- const stories = this.epicParser.parseStories(epicContent, epicFilePath);
1381
- return stories;
1379
+ try {
1380
+ const epicContent = await this.fileManager.readFile(epicFilePath);
1381
+ const stories = this.epicParser.parseStories(epicContent, epicFilePath);
1382
+ return stories;
1383
+ }
1384
+ catch (error) {
1385
+ this.logger.warn({ epicNumber: epic.number, error: error.message }, 'Failed to parse stories from epic, skipping');
1386
+ return [];
1387
+ }
1382
1388
  }));
1383
1389
  // Flatten the array of story arrays
1384
1390
  allStories.push(...epicStories.flat());
@@ -137,13 +137,10 @@ export class EpicParser {
137
137
  this.logger.debug({ matches: matches.length, patternName: pattern.name }, 'Pattern matched stories');
138
138
  }
139
139
  }
140
- // Check if any stories were found
140
+ // If no stories found, return empty array (epic may legitimately have no stories)
141
141
  if (allMatches.length === 0) {
142
- this.logger.error({ epicPath }, 'No stories found in epic');
143
- throw new ParserError('No stories found in epic. Expected format: `### Story 1.1: Title` or similar', {
144
- filePath: epicPath,
145
- suggestion: 'Ensure epic has story headers like: ### Story 1.1: Initialize Project',
146
- });
142
+ this.logger.warn({ epicPath }, 'No stories found in epic, returning empty array');
143
+ return [];
147
144
  }
148
145
  // Sort by position (natural document order)
149
146
  allMatches.sort((a, b) => a.position - b.position);
@@ -2,8 +2,8 @@
2
2
  * PRD Fixer Service
3
3
  *
4
4
  * Uses AI to automatically reformat PRD documents that don't match
5
- * the expected epic/story format. This allows the workflow to handle
6
- * PRDs written in various formats by normalizing them before parsing.
5
+ * the expected epic/story format. The AI writes the fixed content
6
+ * directly to the file path, avoiding fragile stdout parsing.
7
7
  *
8
8
  * @example
9
9
  * ```typescript
@@ -38,7 +38,8 @@ export interface PrdFixResult {
38
38
  * PRD Fixer Service
39
39
  *
40
40
  * Automatically reformats PRD documents to match expected epic/story patterns
41
- * using AI. Creates a backup before modifying the original file.
41
+ * using AI. The AI agent writes the fixed content directly to the file,
42
+ * creating a backup first.
42
43
  */
43
44
  export declare class PrdFixer {
44
45
  private readonly agentRunner;
@@ -56,8 +57,8 @@ export declare class PrdFixer {
56
57
  * Fix a PRD document to match expected format
57
58
  *
58
59
  * Uses AI to analyze and reformat the PRD content to match the expected
59
- * epic/story structure. Creates a backup of the original file before
60
- * overwriting with the fixed content.
60
+ * epic/story structure. The AI creates a backup and writes the fixed
61
+ * content directly to the file path.
61
62
  *
62
63
  * @param prdPath - Path to the PRD file
63
64
  * @param originalContent - Original PRD content that failed parsing
@@ -68,19 +69,12 @@ export declare class PrdFixer {
68
69
  /**
69
70
  * Build the prompt for fixing the PRD
70
71
  *
72
+ * Instructs the AI to read the file at the given path, reformat it,
73
+ * and write it back directly to the same path.
74
+ *
71
75
  * @param prdPath - Path to the PRD file
72
- * @param content - Original PRD content
73
76
  * @param references - Optional reference files
74
77
  * @returns Complete prompt string for the AI agent
75
78
  */
76
79
  private buildFixPrompt;
77
- /**
78
- * Extract the fixed PRD content from the AI response
79
- *
80
- * Looks for content wrapped in markdown code blocks and extracts it.
81
- *
82
- * @param output - Raw AI response output
83
- * @returns Extracted PRD content or null if not found
84
- */
85
- private extractFixedContent;
86
80
  }
@@ -2,8 +2,8 @@
2
2
  * PRD Fixer Service
3
3
  *
4
4
  * Uses AI to automatically reformat PRD documents that don't match
5
- * the expected epic/story format. This allows the workflow to handle
6
- * PRDs written in various formats by normalizing them before parsing.
5
+ * the expected epic/story format. The AI writes the fixed content
6
+ * directly to the file path, avoiding fragile stdout parsing.
7
7
  *
8
8
  * @example
9
9
  * ```typescript
@@ -18,7 +18,8 @@
18
18
  * PRD Fixer Service
19
19
  *
20
20
  * Automatically reformats PRD documents to match expected epic/story patterns
21
- * using AI. Creates a backup before modifying the original file.
21
+ * using AI. The AI agent writes the fixed content directly to the file,
22
+ * creating a backup first.
22
23
  */
23
24
  export class PrdFixer {
24
25
  agentRunner;
@@ -40,8 +41,8 @@ export class PrdFixer {
40
41
  * Fix a PRD document to match expected format
41
42
  *
42
43
  * Uses AI to analyze and reformat the PRD content to match the expected
43
- * epic/story structure. Creates a backup of the original file before
44
- * overwriting with the fixed content.
44
+ * epic/story structure. The AI creates a backup and writes the fixed
45
+ * content directly to the file path.
45
46
  *
46
47
  * @param prdPath - Path to the PRD file
47
48
  * @param originalContent - Original PRD content that failed parsing
@@ -51,9 +52,13 @@ export class PrdFixer {
51
52
  async fixPrd(prdPath, originalContent, references) {
52
53
  this.logger.info({ prdPath, references: references?.length ?? 0 }, 'Attempting to auto-fix PRD format');
53
54
  try {
55
+ // Create backup before AI modifies the file
56
+ const backupPath = `${prdPath}.bak`;
57
+ await this.fileManager.writeFile(backupPath, originalContent);
58
+ this.logger.info({ backupPath }, 'Created backup of original PRD');
54
59
  // Build the prompt for fixing the PRD
55
- const prompt = this.buildFixPrompt(prdPath, originalContent, references);
56
- // Execute the AI agent to fix the PRD
60
+ const prompt = this.buildFixPrompt(prdPath, references);
61
+ // Execute the AI agent to fix the PRD (writes directly to prdPath)
57
62
  const result = await this.agentRunner.runAgent(prompt, {
58
63
  agentType: 'prd-fixer',
59
64
  timeout: 300_000, // 5 minutes should be enough for reformatting
@@ -66,23 +71,29 @@ export class PrdFixer {
66
71
  fixed: false,
67
72
  };
68
73
  }
69
- // Extract the fixed content from the AI response
70
- const fixedContent = this.extractFixedContent(result.output);
71
- if (!fixedContent) {
72
- this.logger.error({ prdPath }, 'Could not extract fixed content from AI response');
74
+ // Read the file back to verify the AI wrote it
75
+ const fixedContent = await this.fileManager.readFile(prdPath);
76
+ // Validate the AI actually changed the file and it looks like a PRD
77
+ if (fixedContent === originalContent) {
78
+ this.logger.error({ prdPath }, 'AI agent did not modify the PRD file');
73
79
  return {
74
80
  content: originalContent,
75
- error: 'Could not extract fixed PRD content from AI response',
81
+ error: 'AI agent did not modify the PRD file',
76
82
  fixed: false,
77
83
  };
78
84
  }
79
- // Create backup of original file
80
- const backupPath = `${prdPath}.bak`;
81
- await this.fileManager.writeFile(backupPath, originalContent);
82
- this.logger.info({ backupPath }, 'Created backup of original PRD');
83
- // Write the fixed content to the original path
84
- await this.fileManager.writeFile(prdPath, fixedContent);
85
- this.logger.info({ prdPath }, 'Wrote fixed PRD content');
85
+ if (!/##\s+Epic\s+\d+/i.test(fixedContent)) {
86
+ this.logger.error({ prdPath }, 'Fixed file does not contain valid epic headers');
87
+ // Restore from backup
88
+ await this.fileManager.writeFile(prdPath, originalContent);
89
+ this.logger.info({ prdPath }, 'Restored original PRD from backup');
90
+ return {
91
+ content: originalContent,
92
+ error: 'AI output did not contain valid epic headers, original restored',
93
+ fixed: false,
94
+ };
95
+ }
96
+ this.logger.info({ prdPath }, 'PRD fixed successfully');
86
97
  return {
87
98
  content: fixedContent,
88
99
  fixed: true,
@@ -101,94 +112,30 @@ export class PrdFixer {
101
112
  /**
102
113
  * Build the prompt for fixing the PRD
103
114
  *
115
+ * Instructs the AI to read the file at the given path, reformat it,
116
+ * and write it back directly to the same path.
117
+ *
104
118
  * @param prdPath - Path to the PRD file
105
- * @param content - Original PRD content
106
119
  * @param references - Optional reference files
107
120
  * @returns Complete prompt string for the AI agent
108
121
  */
109
- buildFixPrompt(prdPath, content, references) {
110
- // Build reference context if provided
122
+ buildFixPrompt(prdPath, references) {
111
123
  let referenceContext = '';
112
124
  if (references && references.length > 0) {
113
- referenceContext = `
114
-
115
- REFERENCE FILES FOR CONTEXT:
116
- ${references.map((ref) => `- ${ref}`).join('\n')}
117
-
118
- Read these reference files to understand the project context and patterns.`;
125
+ referenceContext = `\nAlso read these reference files for project context:\n${references.map((ref) => `- ${ref}`).join('\n')}\n`;
119
126
  }
120
- return `You are a PRD formatting expert. Your task is to reformat a Product Requirements Document (PRD) to match the expected structure for automated workflow processing.
121
-
122
- TASK: Reformat the PRD file at "${prdPath}" to use the correct epic and story format.
123
-
124
- EXPECTED FORMAT:
125
- 1. Epic headers MUST use this exact format: ## Epic N: Title
126
- - Examples: "## Epic 1: Foundation & Setup", "## Epic 2: Core Features"
127
- - The "Epic" keyword is REQUIRED
128
- - The number MUST be sequential (1, 2, 3...)
129
- - A colon separates the number from the title
130
-
131
- 2. Story sections within epics should use:
132
- - ### Stories (as a section header)
133
- - Bullet points: - Story N.M: Description
134
- - Example: "- Story 1.1: Initialize project structure"
135
-
136
- 3. Each epic should have:
137
- - A description paragraph after the header
138
- - A ### Goals section with bullet points
139
- - A ### Stories section with story bullet points
140
-
141
- CURRENT PRD CONTENT:
142
- \`\`\`markdown
143
- ${content}
144
- \`\`\`
127
+ return `You are a PRD formatting expert. Read the PRD file at "${prdPath}", reformat it to match the expected structure, and write the result back to the same file.
145
128
  ${referenceContext}
129
+ EXPECTED FORMAT:
130
+ 1. Epic headers: ## Epic N: Title (keyword "Epic" required, sequential numbering, colon separator)
131
+ 2. Story headers within epics: ### Story N.M: Title (N=epic number, M=story number)
132
+ 3. Each story should have acceptance criteria
146
133
 
147
134
  INSTRUCTIONS:
148
- 1. Analyze the current PRD structure
135
+ 1. Read "${prdPath}"
149
136
  2. Identify all epics/features/phases (they might be labeled differently)
150
- 3. Reformat them to use "## Epic N: Title" format
151
- 4. Ensure stories are properly formatted under each epic
152
- 5. Preserve ALL original content and meaning - only change the formatting
153
- 6. Number epics sequentially starting from 1
154
- 7. Number stories as N.M where N is the epic number and M is the story number within that epic
155
-
156
- OUTPUT FORMAT:
157
- Output ONLY the reformatted PRD content wrapped in a markdown code block.
158
- Do not include any explanation before or after the code block.
159
- The output must be a complete, valid markdown document.
160
-
161
- \`\`\`markdown
162
- [Your reformatted PRD here]
163
- \`\`\``;
164
- }
165
- /**
166
- * Extract the fixed PRD content from the AI response
167
- *
168
- * Looks for content wrapped in markdown code blocks and extracts it.
169
- *
170
- * @param output - Raw AI response output
171
- * @returns Extracted PRD content or null if not found
172
- */
173
- extractFixedContent(output) {
174
- // Try to extract content from markdown code block
175
- const codeBlockMatch = output.match(/```(?:markdown)?\s*\n([\s\S]*?)\n```/);
176
- if (codeBlockMatch && codeBlockMatch[1]) {
177
- const extracted = codeBlockMatch[1].trim();
178
- // Validate it looks like a PRD (has at least one epic header)
179
- if (/##\s+Epic\s+\d+/i.test(extracted)) {
180
- return extracted;
181
- }
182
- }
183
- // Fallback: if the output itself looks like a valid PRD
184
- if (/##\s+Epic\s+\d+/i.test(output)) {
185
- // Strip any leading/trailing explanation text
186
- const lines = output.split('\n');
187
- const prdStart = lines.findIndex((line) => line.startsWith('#'));
188
- if (prdStart !== -1) {
189
- return lines.slice(prdStart).join('\n').trim();
190
- }
191
- }
192
- return null;
137
+ 3. Reformat epic headers to "## Epic N: Title" and story headers to "### Story N.M: Title"
138
+ 4. Preserve ALL original content and meaning only change the formatting
139
+ 5. Write the complete reformatted PRD back to "${prdPath}"`;
193
140
  }
194
141
  }
@@ -53,6 +53,14 @@ export declare class PrdParser {
53
53
  *
54
54
  * CRITICAL: Pattern order matches prd-tmpl.yaml output format
55
55
  * Template generates: ## Epic 1 Title (space-separated)
56
+ *
57
+ * Patterns support both H2 (##) and H3 (###) epic headers.
58
+ * PRDs may use H3 for epics when H2 is used for document sections
59
+ * (e.g., ## 6. Epic Details → ### Epic 1: Title).
60
+ *
61
+ * Generic patterns (N: and N.) are only used as fallback when no
62
+ * explicit "Epic N" patterns match — they can false-positive on
63
+ * numbered document sections like "## 1. Goals".
56
64
  */
57
65
  private readonly patterns;
58
66
  /**
@@ -82,6 +90,17 @@ export declare class PrdParser {
82
90
  * ```
83
91
  */
84
92
  parseEpics(prdContent: string, prdPath: string): Epic[];
93
+ /**
94
+ * Count stories per epic within a PRD document
95
+ *
96
+ * Splits the PRD into epic sections and counts story headers in each.
97
+ * Used by `prd fix` to validate that epics contain stories.
98
+ *
99
+ * @param prdContent - Full PRD markdown content
100
+ * @param epics - Parsed epics from `parseEpics()`
101
+ * @returns Map of epic number to story count
102
+ */
103
+ countStoriesPerEpic(prdContent: string, epics: Epic[]): Map<number, number>;
85
104
  /**
86
105
  * Deduplicate matches that appear at the same position
87
106
  *
@@ -32,12 +32,32 @@ export class PrdParser {
32
32
  *
33
33
  * CRITICAL: Pattern order matches prd-tmpl.yaml output format
34
34
  * Template generates: ## Epic 1 Title (space-separated)
35
+ *
36
+ * Patterns support both H2 (##) and H3 (###) epic headers.
37
+ * PRDs may use H3 for epics when H2 is used for document sections
38
+ * (e.g., ## 6. Epic Details → ### Epic 1: Title).
39
+ *
40
+ * Generic patterns (N: and N.) are only used as fallback when no
41
+ * explicit "Epic N" patterns match — they can false-positive on
42
+ * numbered document sections like "## 1. Goals".
35
43
  */
36
44
  patterns = [
37
45
  {
38
46
  name: 'Epic N Nested (N. Epic M:)',
39
47
  regex: /^##\s+\d+\.\s+Epic\s+(\d+)\s*:\s*(.+?)$/gim,
40
48
  },
49
+ {
50
+ name: 'H3 Epic N: Title',
51
+ regex: /^###\s+Epic\s+(\d+)\s*:\s*(.+?)$/gim,
52
+ },
53
+ {
54
+ name: 'H3 Epic N - Title',
55
+ regex: /^###\s+Epic\s+(\d+)\s+-\s+(.+?)$/gim,
56
+ },
57
+ {
58
+ name: 'H3 Epic N Title (space)',
59
+ regex: /^###\s+Epic\s+(\d+)\s+(?![:-])(.+?)$/gim,
60
+ },
41
61
  {
42
62
  name: 'Epic N: Title',
43
63
  regex: /^##\s+Epic\s+(\d+)\s*:\s*(.+?)$/gim,
@@ -106,16 +126,27 @@ export class PrdParser {
106
126
  // Collect matches from all patterns - patterns are ordered by specificity
107
127
  // More specific "Epic N" patterns come first in the array
108
128
  const allMatches = [];
129
+ let hasExplicitEpicMatches = false;
109
130
  for (const pattern of this.patterns) {
110
131
  // Skip "N. Title" pattern if nested format found (N. is used for sections)
111
132
  if (hasNestedEpicFormat && pattern.name === 'N. Title') {
112
133
  this.logger.debug({ patternName: pattern.name }, 'Skipping N. pattern - nested Epic format found in PRD');
113
134
  continue;
114
135
  }
136
+ // Skip generic number-only patterns when explicit "Epic N" patterns already matched
137
+ // Generic patterns like "## 1. Goals" false-positive on document section headers
138
+ if (hasExplicitEpicMatches && (pattern.name === 'N: Title' || pattern.name === 'N. Title')) {
139
+ this.logger.debug({ patternName: pattern.name }, 'Skipping generic pattern - explicit Epic patterns already matched');
140
+ continue;
141
+ }
115
142
  const matches = this.matchPattern(pattern, lines, prdContent);
116
143
  if (matches.length > 0) {
117
144
  allMatches.push(...matches);
118
145
  this.logger.debug({ matches: matches.length, patternName: pattern.name }, 'Pattern matched epics');
146
+ // Track if we found explicit "Epic N" patterns (any pattern with "Epic" in the name)
147
+ if (pattern.name.includes('Epic')) {
148
+ hasExplicitEpicMatches = true;
149
+ }
119
150
  }
120
151
  }
121
152
  // Check if any epics were found
@@ -135,6 +166,45 @@ export class PrdParser {
135
166
  this.logger.info({ epicCount: epics.length, prdPath }, 'Successfully parsed epics from PRD');
136
167
  return epics;
137
168
  }
169
+ /**
170
+ * Count stories per epic within a PRD document
171
+ *
172
+ * Splits the PRD into epic sections and counts story headers in each.
173
+ * Used by `prd fix` to validate that epics contain stories.
174
+ *
175
+ * @param prdContent - Full PRD markdown content
176
+ * @param epics - Parsed epics from `parseEpics()`
177
+ * @returns Map of epic number to story count
178
+ */
179
+ countStoriesPerEpic(prdContent, epics) {
180
+ const lines = prdContent.split('\n');
181
+ const result = new Map();
182
+ // Story header patterns matching EpicParser patterns
183
+ const storyPatterns = [
184
+ /^###\s+Story\s+(\d+)\.(\d+)\s*:\s*.+$/i,
185
+ /^###\s+Story\s+(\d+)\.(\d+)\s+-\s+.+$/i,
186
+ /^###\s+(\d+)\.(\d+)\s*:\s*.+$/i,
187
+ /^###\s+(\d+)\.(\d+)\s*\.\s+.+$/i,
188
+ ];
189
+ for (const epic of epics) {
190
+ // Find the end of this epic's section (next epic's position or EOF)
191
+ const nextEpic = epics.find((e) => e.position > epic.position);
192
+ const sectionEnd = nextEpic ? nextEpic.position : lines.length;
193
+ const sectionLines = lines.slice(epic.position, sectionEnd);
194
+ let storyCount = 0;
195
+ for (const line of sectionLines) {
196
+ for (const pattern of storyPatterns) {
197
+ if (pattern.test(line)) {
198
+ storyCount++;
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ result.set(epic.number, storyCount);
204
+ this.logger.debug({ epicNumber: epic.number, storyCount }, 'Counted stories in epic section');
205
+ }
206
+ return result;
207
+ }
138
208
  /**
139
209
  * Deduplicate matches that appear at the same position
140
210
  *
@@ -212,12 +282,8 @@ export class PrdParser {
212
282
  const position = this.findLineNumber(lines, fullMatch);
213
283
  // Extract fullNumber based on pattern type
214
284
  let fullNumber;
215
- if (pattern.name.includes('Nested')) {
216
- // For nested format "## N. Epic M:", use "Epic M"
217
- fullNumber = `Epic ${epicNumber}`;
218
- }
219
- else if (pattern.name.startsWith('Epic N')) {
220
- // For Epic patterns, use "Epic N"
285
+ if (pattern.name.includes('Nested') || pattern.name.includes('Epic N') || pattern.name.includes('H3 Epic')) {
286
+ // For explicit Epic patterns (H2, H3, nested), use "Epic N"
221
287
  fullNumber = `Epic ${epicNumber}`;
222
288
  }
223
289
  else {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hyperdrive.bot/bmad-workflow",
3
3
  "description": "AI-driven development workflow orchestration CLI for BMAD projects",
4
- "version": "1.0.13",
4
+ "version": "1.0.15",
5
5
  "author": {
6
6
  "name": "DevSquad",
7
7
  "email": "marcelo@devsquad.email",