UNPKG

@pimzino/claude-code-spec-workflow

Version:

Automated workflows for Claude Code. Includes spec-driven development (Requirements → Design → Tasks → Implementation) with intelligent task execution, optional steering documents and streamlined bug fix workflow (Report → Analyze → Fix → Verify). We have

956 lines 45.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.SpecParser = void 0; const promises_1 = require("fs/promises"); const path_1 = require("path"); const fs_1 = require("fs"); const logger_1 = require("./logger"); const steering_1 = require("../steering"); class SpecParser { constructor(projectPath) { // Normalize path to handle Windows/Unix separators before resolving const normalizedInput = projectPath.replace(/\\/g, '/'); this.projectPath = (0, path_1.normalize)((0, path_1.resolve)(normalizedInput)); this.specsPath = (0, path_1.join)(this.projectPath, '.claude', 'specs'); this.bugsPath = (0, path_1.join)(this.projectPath, '.claude', 'bugs'); this.steeringLoader = new steering_1.SteeringLoader(this.projectPath); } async getProjectSteeringStatus() { return this.getSteeringStatus(); } async getAllBugs() { try { // Check if bugs directory exists first try { await (0, promises_1.access)(this.bugsPath, fs_1.constants.F_OK); } catch { // Bugs directory doesn't exist, return empty array return []; } (0, logger_1.debug)('Reading bugs from:', this.bugsPath); const dirs = await (0, promises_1.readdir)(this.bugsPath); (0, logger_1.debug)('Found bug directories:', dirs); const bugs = await Promise.all(dirs.filter((dir) => !dir.startsWith('.')).map((dir) => this.getBug(dir))); const validBugs = bugs.filter((bug) => bug !== null); (0, logger_1.debug)('Parsed bugs:', validBugs.length); // Sort by last modified date, newest first validBugs.sort((a, b) => { const dateA = a.lastModified ? new Date(a.lastModified).getTime() : 0; const dateB = b.lastModified ? new Date(b.lastModified).getTime() : 0; return dateB - dateA; }); return validBugs; } catch (error) { console.error('Error reading bugs from', this.bugsPath, ':', error); return []; } } async getAllSpecs() { try { // Check if specs directory exists first try { await (0, promises_1.access)(this.specsPath, fs_1.constants.F_OK); } catch { // Specs directory doesn't exist, return empty array return []; } (0, logger_1.debug)('Reading specs from:', this.specsPath); const dirs = await (0, promises_1.readdir)(this.specsPath); (0, logger_1.debug)('Found directories:', dirs); const specs = await Promise.all(dirs.filter((dir) => !dir.startsWith('.')).map((dir) => this.getSpec(dir))); const validSpecs = specs.filter((spec) => spec !== null); (0, logger_1.debug)('Parsed specs:', validSpecs.length); // Sort by last modified date, newest first validSpecs.sort((a, b) => { const dateA = a.lastModified ? new Date(a.lastModified).getTime() : 0; const dateB = b.lastModified ? new Date(b.lastModified).getTime() : 0; return dateB - dateA; }); return validSpecs; } catch (error) { console.error('Error reading specs from', this.specsPath, ':', error); return []; } } async getSpec(name) { const specPath = (0, path_1.join)(this.specsPath, name); try { await (0, promises_1.access)(specPath, fs_1.constants.F_OK); } catch { return null; } const spec = { name, displayName: this.formatDisplayName(name), status: 'not-started', }; // Check requirements const requirementsPath = (0, path_1.join)(specPath, 'requirements.md'); if (await this.fileExists(requirementsPath)) { const content = await (0, promises_1.readFile)(requirementsPath, 'utf-8'); // Try to extract title from the first heading // Handle formats like "# Requirements: Feature Name", "# Requirements - Feature Name", or "# Feature Name Requirements" const titleMatch = content.match(/^#\s+(?:Requirements\s*[-:]\s+)?(.+?)(?:\s+Requirements)?$/m); if (titleMatch?.[1]?.trim() && titleMatch[1].trim().toLowerCase() !== 'requirements') { spec.displayName = titleMatch[1].trim(); } const extractedRequirements = this.extractRequirements(content); const extractedStories = this.extractUserStories(content); spec.requirements = { exists: true, userStories: extractedStories.length, approved: content.includes('✅ APPROVED') || content.includes('**Approved:** ✓'), content: extractedRequirements, }; // Set initial status spec.status = 'requirements'; // If requirements are approved, we move to design phase if (spec.requirements.approved) { spec.status = 'design'; } } // Check design const designPath = (0, path_1.join)(specPath, 'design.md'); if (await this.fileExists(designPath)) { const content = await (0, promises_1.readFile)(designPath, 'utf-8'); // If we haven't found a display name yet, try to extract from design if (spec.displayName === this.formatDisplayName(name)) { // Handle formats like "# Design: Feature Name", "# Design - Feature Name", or "# Feature Name Design" const titleMatch = content.match(/^#\s+(?:Design\s*[-:]\s+)?(.+?)(?:\s+Design(?:\s+Document)?)?$/m); if (titleMatch?.[1]?.trim() && titleMatch[1].trim().toLowerCase() !== 'design') { spec.displayName = titleMatch[1].trim(); } } const codeReuseContent = this.extractCodeReuseAnalysis(content); spec.design = { exists: true, approved: content.includes('✅ APPROVED'), hasCodeReuseAnalysis: content.includes('## Code Reuse Analysis') || content.includes('### Existing Components to Reuse') || content.includes('## Existing Components') || content.includes('## Code Reuse') || codeReuseContent.length > 0, codeReuseContent: codeReuseContent, }; // If design is approved, we move to tasks phase if (spec.design.approved) { spec.status = 'tasks'; } } // Check tasks const tasksPath = (0, path_1.join)(specPath, 'tasks.md'); if (await this.fileExists(tasksPath)) { (0, logger_1.debug)(`Reading tasks from: ${tasksPath}`); const content = await (0, promises_1.readFile)(tasksPath, 'utf-8'); (0, logger_1.debug)('Tasks file content length:', content.length); (0, logger_1.debug)('Tasks file includes APPROVED:', content.includes('✅ APPROVED')); // If we still haven't found a display name, try to extract from tasks if (spec.displayName === this.formatDisplayName(name)) { // Handle formats like "# Tasks: Feature Name", "# Tasks - Feature Name", or "# Implementation Plan: Feature Name" const titleMatch = content.match(/^#\s+(?:(?:Tasks|Implementation Plan)\s*[-:]\s+)?(.+?)(?:\s+(?:Tasks|Plan))?$/m); if (titleMatch?.[1]?.trim() && titleMatch[1].trim().toLowerCase() !== 'tasks' && titleMatch[1].trim().toLowerCase() !== 'implementation plan') { spec.displayName = titleMatch[1].trim(); } } const taskList = this.parseTasks(content); const completed = this.countCompletedTasks(taskList); const total = this.countTotalTasks(taskList); (0, logger_1.debug)('Parsed task counts - Total:', total, 'Completed:', completed); spec.tasks = { exists: true, approved: content.includes('✅ APPROVED'), total, completed, taskList, }; if (spec.tasks.approved) { if (completed === 0) { spec.status = 'tasks'; } else if (completed < total) { spec.status = 'in-progress'; // Always use the first uncompleted task as in-progress const inProgressTask = this.findInProgressTask(taskList); if (inProgressTask) { spec.tasks.inProgress = inProgressTask; } } else { spec.status = 'completed'; } } } // Get last modified time const files = ['requirements.md', 'design.md', 'tasks.md']; let lastModified = new Date(0); for (const file of files) { const filePath = (0, path_1.join)(specPath, file); if (await this.fileExists(filePath)) { const stats = await Promise.resolve().then(() => __importStar(require('fs'))).then((fs) => fs.promises.stat(filePath)); if (stats.mtime > lastModified) { lastModified = stats.mtime; } } } spec.lastModified = lastModified; return spec; } async getBug(name) { const bugPath = (0, path_1.join)(this.bugsPath, name); try { await (0, promises_1.access)(bugPath, fs_1.constants.F_OK); } catch { return null; } const bug = { name, displayName: this.formatDisplayName(name), status: 'reported', }; // Check report const reportPath = (0, path_1.join)(bugPath, 'report.md'); if (await this.fileExists(reportPath)) { const content = await (0, promises_1.readFile)(reportPath, 'utf-8'); // Try to extract title from the first heading const titleMatch = content.match(/^#\s+(?:Bug Report\s*[-:]\s+)?(.+?)(?:\s+Bug Report)?$/m); if (titleMatch?.[1]?.trim() && titleMatch[1].trim().toLowerCase() !== 'bug report') { bug.displayName = titleMatch[1].trim(); } const severity = this.extractBugSeverity(content); const expectedBehavior = this.extractSection(content, 'Expected Behavior'); const actualBehavior = this.extractSection(content, 'Actual Behavior'); const impact = this.extractSection(content, 'Impact'); bug.report = { exists: true, ...(severity && { severity }), reproductionSteps: this.extractReproductionSteps(content), ...(expectedBehavior && { expectedBehavior }), ...(actualBehavior && { actualBehavior }), ...(impact && { impact }), }; } // Check analysis const analysisPath = (0, path_1.join)(bugPath, 'analysis.md'); if (await this.fileExists(analysisPath)) { const content = await (0, promises_1.readFile)(analysisPath, 'utf-8'); const rootCause = this.extractSection(content, 'Root Cause'); const proposedFix = this.extractSection(content, 'Proposed Fix'); bug.analysis = { exists: true, ...(rootCause && { rootCause }), ...(proposedFix && { proposedFix }), filesAffected: this.extractFilesAffected(content), }; // Only set to analyzing if there's actual analysis content // Check for content in the actual analysis sections (not just headers) const hasRootCauseContent = this.hasContentAfterSection(content, 'Root Cause Analysis') || this.hasContentAfterSection(content, 'Investigation Summary') || this.hasContentAfterSection(content, 'Root Cause'); const hasImplementationPlan = this.hasContentAfterSection(content, 'Implementation Plan') || this.hasContentAfterSection(content, 'Changes Required'); if (hasRootCauseContent || hasImplementationPlan) { bug.status = 'analyzing'; // If analysis is complete (all required sections have content), we're ready to fix const hasAllAnalysisContent = hasRootCauseContent && hasImplementationPlan; const analysisApproved = content.includes('✅ APPROVED') || content.includes('**Approved:** ✓') || content.includes('## Next Phase') || content.includes('proceed to'); if (hasAllAnalysisContent && analysisApproved) { bug.status = 'fixing'; } } } // Check if fix has been implemented const fixPath = (0, path_1.join)(bugPath, 'fix.md'); if (await this.fileExists(fixPath)) { const content = await (0, promises_1.readFile)(fixPath, 'utf-8'); // Check if fix has actual content (not just template) const hasFixContent = this.hasContentAfterSection(content, 'Fix Summary') || this.hasContentAfterSection(content, 'Implementation Details') || this.hasContentAfterSection(content, 'Changes Made') || this.hasContentAfterSection(content, 'Code Changes'); if (hasFixContent) { bug.status = 'fixed'; // Add fix information to bug object const summary = this.extractSection(content, 'Fix Summary'); bug.fix = { exists: true, ...(summary && { summary }), implemented: content.includes('✅') || content.includes('implemented') || content.includes('complete'), }; } } // Check verification const verificationPath = (0, path_1.join)(bugPath, 'verification.md'); if (await this.fileExists(verificationPath)) { const content = await (0, promises_1.readFile)(verificationPath, 'utf-8'); const testsPassed = this.extractTestStatus(content); bug.verification = { exists: true, verified: content.includes('✅ VERIFIED') || content.includes('**Verified:** ✓') || content.includes('**Production Verified**') || (content.includes('✅') && content.toLowerCase().includes('verified')), ...(testsPassed !== undefined && { testsPassed }), regressionChecks: this.extractRegressionChecks(content), }; // Only set to verifying if there's actual verification content AND we're past the fixing phase const hasTestResults = this.hasContentAfterSection(content, 'Test Results') || this.hasContentAfterSection(content, 'Verification Steps') || this.hasContentAfterSection(content, 'Fix Verification'); const hasRegressionChecks = this.hasContentAfterSection(content, 'Regression Testing') || this.hasContentAfterSection(content, 'Regression Checks') || this.hasContentAfterSection(content, 'Side Effects Check'); // Only set to verifying if: // 1. There's actual verification content (not just template) // 2. Bug is in 'fixed' status (fix has been implemented) if ((hasTestResults || hasRegressionChecks) && bug.status === 'fixed') { bug.status = 'verifying'; // Check if verification is complete if (bug.verification?.verified) { bug.status = 'resolved'; } } } // Get last modified time const files = ['report.md', 'analysis.md', 'verification.md']; let lastModified = new Date(0); for (const file of files) { const filePath = (0, path_1.join)(bugPath, file); if (await this.fileExists(filePath)) { const stats = await Promise.resolve().then(() => __importStar(require('fs'))).then((fs) => fs.promises.stat(filePath)); if (stats.mtime > lastModified) { lastModified = stats.mtime; } } } bug.lastModified = lastModified; return bug; } parseTasks(content) { (0, logger_1.debug)('Parsing tasks from content...'); const tasks = []; const lines = content.split('\n'); (0, logger_1.debug)('Total lines:', lines.length); // Let's test what the actual lines look like lines.slice(0, 20).forEach((line, i) => { if (line.includes('[') && line.includes(']')) { (0, logger_1.debug)(`Line ${i}: "${line}"`); } }); // Match the actual format: "- [x] 1. Create GraphQL queries..." or "- [ ] **1. Task description**" const taskRegex = /^(\s*)- \[([ x])\] (?:\*\*)?(\d+(?:\.\d+)*)\. (.+?)(?:\*\*)?$/; const requirementsRegex = /_Requirements: ([\d., ]+)/; const leverageRegex = /_Leverage: (.+)$/; // Removed _In Progress: parsing - now automatically using first uncompleted task let currentTask = null; let parentStack = []; for (const line of lines) { const match = line.match(taskRegex); if (match) { const indent = match[1] || ''; const checked = match[2] || ''; const id = match[3] || ''; const description = match[4] || ''; const level = indent.length / 2; currentTask = { id, description: description?.trim() || '', completed: checked === 'x', requirements: [], }; // Find parent based on level while (parentStack.length > 0 && parentStack[parentStack.length - 1].level >= level) { parentStack.pop(); } if (parentStack.length > 0) { const parent = parentStack[parentStack.length - 1].task; if (!parent.subtasks) parent.subtasks = []; parent.subtasks.push(currentTask); } else { tasks.push(currentTask); } parentStack.push({ level, task: currentTask }); } else if (currentTask) { // Check for requirements const reqMatch = line.match(requirementsRegex); if (reqMatch?.[1]) { currentTask.requirements = reqMatch[1].split(',').map((r) => r.trim()); } // Check for leverage const levMatch = line.match(leverageRegex); if (levMatch?.[1]) { currentTask.leverage = levMatch[1].trim(); } // Removed in-progress marker check - now using first uncompleted task automatically } } // No longer storing in-progress task ID - automatically determined by first uncompleted task return tasks; } countCompletedTasks(tasks) { let count = 0; for (const task of tasks) { if (task.completed) count++; if (task.subtasks) { count += this.countCompletedTasks(task.subtasks); } } return count; } countTotalTasks(tasks) { let count = tasks.length; for (const task of tasks) { if (task.subtasks) { count += this.countTotalTasks(task.subtasks); } } return count; } findInProgressTask(tasks) { for (const task of tasks) { if (!task.completed) { return task.id; } if (task.subtasks) { const subTaskId = this.findInProgressTask(task.subtasks); if (subTaskId) return subTaskId; } } return undefined; } formatDisplayName(name) { return name .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } extractRequirements(content) { const requirements = []; const lines = content.split('\n'); let currentRequirement = null; let inAcceptanceCriteria = false; (0, logger_1.debug)('Extracting requirements from content...'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line) continue; // Skip non-requirement section headers if (line.match(/^###\s+(Non-Functional Requirements|Technical Constraints|Edge Cases)\s*$/)) { continue; } // Check if line contains a numbered requirement - try multiple patterns const requirementPatterns = [ /^### Requirement (\d+): (.+)$/, // ### Requirement 1: Title /^## Requirement (\d+): (.+)$/, // ## Requirement 1: Title /^### (\d+)\. (.+)$/, // ### 1. Title /^## (\d+)\. (.+)$/, // ## 1. Title /^### (FR-\d+): (.+)$/, // ### FR-1: Title (Functional Requirement) /^### (NFR-\d+): (.+)$/, // ### NFR-1: Title (Non-Functional Requirement) /^### (AC-\d+): (.+)$/, // ### AC-1: Title (Acceptance Criteria) /^### (US-\d+): (.+)$/, // ### US-1: Title (User Story) ]; let matchFound = false; for (const pattern of requirementPatterns) { const match = line?.match(pattern); if (match) { // Save previous requirement if (currentRequirement) { requirements.push(currentRequirement); } currentRequirement = { id: match[1] || '', title: match[2]?.trim() || '', acceptanceCriteria: [], }; (0, logger_1.debug)(`Found requirement ${match[1]}: ${match[2]?.trim()}`); inAcceptanceCriteria = false; matchFound = true; break; } } if (!matchFound && currentRequirement) { // Debug every line to see what we're getting if (line.trim()) { (0, logger_1.debug)(`Processing line for ${currentRequirement.id}: "${line}"`); } // Look for user story in requirement body if (line?.includes('**User Story:**')) { currentRequirement.userStory = line.replace('**User Story:**', '').trim(); (0, logger_1.debug)(`Found user story for ${currentRequirement.id}: ${currentRequirement.userStory}`); } // Look for user story parts in new format - check more broadly else if (line?.includes('As a') || line?.includes('I want') || line?.includes('So that')) { if (!currentRequirement.userStory) { currentRequirement.userStory = ''; } currentRequirement.userStory += ' ' + line.trim(); (0, logger_1.debug)(`Building user story for ${currentRequirement.id}: ${line?.trim()}`); } // Look for acceptance criteria section else if (line?.includes('#### Acceptance Criteria') || line?.includes('**Acceptance Criteria**')) { inAcceptanceCriteria = true; (0, logger_1.debug)(`Found acceptance criteria section for ${currentRequirement.id}`); } // Look for GIVEN/WHEN/THEN format in new style (might be direct under requirement) else if (line?.includes('GIVEN') || line?.includes('WHEN') || line?.includes('THEN')) { if (!currentRequirement.acceptanceCriteria) { currentRequirement.acceptanceCriteria = []; } // Collect GIVEN/WHEN/THEN as a group for better formatting // Start collecting a new acceptance criteria scenario let scenario = line?.trim() || ''; let j = i + 1; // Collect WHEN and THEN parts that follow while (j < lines.length) { const nextLine = lines[j]?.trim() || ''; if (nextLine.startsWith('**WHEN**') || nextLine.startsWith('**THEN**')) { scenario += ' ' + nextLine; j++; } else if (nextLine === '') { // Empty line indicates end of this scenario break; } else if (nextLine.startsWith('**GIVEN**')) { // New GIVEN means this is a separate scenario - save current and start new currentRequirement.acceptanceCriteria.push(scenario); scenario = nextLine; j++; continue; } else if (nextLine.startsWith('###') || nextLine.startsWith('##')) { // Section header indicates end break; } else { // Continuation of current line scenario += ' ' + nextLine; j++; } } currentRequirement.acceptanceCriteria.push(scenario); i = j - 1; // Skip lines we've already processed } // Collect acceptance criteria items (numbered format) else if (currentRequirement && inAcceptanceCriteria && line?.match(/^\d+\. /)) { const criterion = line.replace(/^\d+\. /, '').trim(); if (criterion) { currentRequirement.acceptanceCriteria.push(criterion); (0, logger_1.debug)(`Found acceptance criterion for ${currentRequirement.id}: ${criterion}`); } } // Also collect bullet point acceptance criteria (- WHEN...) else if (currentRequirement && inAcceptanceCriteria && line?.match(/^[-•]\s+/)) { const criterion = line.replace(/^[-•]\s+/, '').trim(); if (criterion) { currentRequirement.acceptanceCriteria.push(criterion); } } // For FR/NFR requirements, collect bullet points directly as acceptance criteria else if (currentRequirement && !inAcceptanceCriteria && line?.match(/^[-•]\s+/) && currentRequirement.id && (currentRequirement.id.startsWith('FR-') || currentRequirement.id.startsWith('NFR-'))) { if (!currentRequirement.acceptanceCriteria) { currentRequirement.acceptanceCriteria = []; } const criterion = line?.replace(/^[-•]\s+/, '').trim() || ''; if (criterion) { currentRequirement.acceptanceCriteria.push(criterion); (0, logger_1.debug)(`Found bullet criterion for ${currentRequirement.id}: ${criterion}`); } } // For FR/NFR requirements, also collect regular text as part of description else if (currentRequirement && line?.trim() && !line.startsWith('#') && currentRequirement.id && (currentRequirement.id.startsWith('FR-') || currentRequirement.id.startsWith('NFR-'))) { // If it's descriptive text, add it to the user story if (!currentRequirement.userStory) { currentRequirement.userStory = line.trim(); } else if (!line.match(/^[-•]\s+/)) { // Continue building the description if it's not a bullet point currentRequirement.userStory += ' ' + line.trim(); } (0, logger_1.debug)(`Adding description for ${currentRequirement.id}: ${line?.trim()}`); } } } // Don't forget the last requirement if (currentRequirement) { requirements.push(currentRequirement); } // Clean up user stories that might have extra spaces requirements.forEach(req => { if (req.userStory) { req.userStory = req.userStory.trim().replace(/\s+/g, ' '); } }); // In the new format, we need to separate different types of entries: // - US-* are user stories, not requirements // - FR-*, NFR-* are the actual requirements // - AC-* are acceptance criteria, not requirements const userStories = requirements.filter(r => r.id && r.id.startsWith('US-')); const functionalRequirements = requirements.filter(r => r.id && (r.id.startsWith('FR-') || r.id.startsWith('NFR-'))); const acceptanceCriteria = requirements.filter(r => r.id && r.id.startsWith('AC-')); const otherRequirements = requirements.filter(r => !r.id || (!r.id.startsWith('US-') && !r.id.startsWith('FR-') && !r.id.startsWith('NFR-') && !r.id.startsWith('AC-'))); // Return ALL requirements (including user stories and acceptance criteria) // The filtering was preventing US-* and AC-* from being displayed const allRequirements = requirements; (0, logger_1.debug)(`Extracted ${allRequirements.length} requirements total:`, allRequirements.map(r => `${r.id}: ${r.title}`)); (0, logger_1.debug)(` Breakdown: ${functionalRequirements.length} FR/NFR, ${userStories.length} US, ${acceptanceCriteria.length} AC, ${otherRequirements.length} other`); allRequirements.forEach(req => { (0, logger_1.debug)(` ${req.id}: userStory="${req.userStory || 'none'}", acceptanceCriteria=${req.acceptanceCriteria?.length || 0}`); }); return allRequirements; } extractUserStories(content) { const stories = []; const lines = content.split('\n'); let currentStory = ''; let inStorySection = false; let currentStoryTitle = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line) continue; // Check for old format: **User Story:** if (line.includes('**User Story:**')) { if (currentStory) { stories.push(currentStory.trim()); } // Extract the story content after "**User Story:**" currentStory = line?.replace('**User Story:**', '').trim() || ''; inStorySection = true; currentStoryTitle = ''; } // Check for new format: ### US-N: Title else if (line.match(/^### US-\d+: (.+)$/)) { if (currentStory) { stories.push(currentStory.trim()); } const match = line.match(/^### US-\d+: (.+)$/); currentStoryTitle = match?.[1]?.trim() || ''; currentStory = currentStoryTitle; inStorySection = true; // Look ahead for the user story content in the new format // Format: **As a** X **I want** Y **So that** Z let storyParts = []; for (let j = i + 1; j < lines.length && j < i + 10; j++) { const nextLine = lines[j]?.trim() || ''; if (nextLine.startsWith('**As a**') || nextLine.startsWith('**I want**') || nextLine.startsWith('**So that**')) { storyParts.push(nextLine); } else if (nextLine.startsWith('###') || nextLine.startsWith('##')) { break; } } if (storyParts.length > 0) { currentStory = currentStoryTitle + ': ' + storyParts.join(' '); } } else if (inStorySection && line?.trim()) { // Stop at next major section (### or ##) or next user story if (line.startsWith('###') || line.startsWith('##') || line.includes('**User Story:**')) { if (currentStory) { stories.push(currentStory.trim()); currentStory = ''; } // If this line is another user story, process it if (line.includes('**User Story:**')) { currentStory = line?.replace('**User Story:**', '').trim() || ''; currentStoryTitle = ''; } else { inStorySection = false; } } else if (!line.startsWith('#') && line?.trim() && !currentStoryTitle) { // Continue building the story if it's not a heading (old format only) currentStory += ' ' + line.trim(); } } } // Don't forget the last story if (currentStory) { stories.push(currentStory.trim()); } return stories; // Return all stories } extractCodeReuseAnalysis(content) { const categories = []; const lines = content.split('\n'); let inCodeReuseSection = false; let currentCategory = null; for (const line of lines) { // Look for various code reuse section headers if (line.includes('## Code Reuse Analysis') || line.includes('### Existing Components to Reuse') || line.includes('## Existing Components') || line.includes('## Code Reuse')) { inCodeReuseSection = true; continue; } if (inCodeReuseSection) { // Stop at next major section if ((line.startsWith('## ') || line.startsWith('### ')) && !line.includes('Code Reuse') && !line.includes('Existing Components')) { break; } // Look for numbered categories like "1. **Configuration Infrastructure**" or just "1. Item Name:" const categoryMatch = line.match(/^\d+\.\s*(?:\*\*(.+?)\*\*|(.+?)(?::|\s*$))/); if (categoryMatch) { const categoryName = categoryMatch[1] || categoryMatch[2]; // In phenix format, the numbered items ARE the reuse items, not categories // So we'll treat them as single-item categories for consistency if (currentCategory) { categories.push(currentCategory); } currentCategory = { title: categoryName?.trim() || '', items: [], }; // Check if there's content on the same line after colon const colonIndex = line.indexOf(':'); if (colonIndex > -1 && colonIndex < line.length - 1) { const afterColon = line.substring(colonIndex + 1).trim(); if (afterColon) { currentCategory.items.push(afterColon); } } } // Look for bullet points under categories else if (currentCategory && (line.startsWith(' - ') || line.startsWith(' - ') || line.startsWith('- '))) { const item = line.replace(/^\s*-\s*/, '').trim(); if (item) { // Clean up markdown formatting const cleanItem = item .replace(/`([^`]+)`/g, '$1') // Remove backticks .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold formatting .trim(); currentCategory.items.push(cleanItem); } } } } // Don't forget the last category if (currentCategory) { categories.push(currentCategory); } return categories; } async fileExists(path) { try { await (0, promises_1.access)(path, fs_1.constants.F_OK); return true; } catch { return false; } } async getSteeringStatus() { const steeringPath = (0, path_1.join)(this.projectPath, '.claude', 'steering'); try { await (0, promises_1.access)(steeringPath, fs_1.constants.F_OK); const status = { exists: true, hasProduct: await this.fileExists((0, path_1.join)(steeringPath, 'product.md')), hasTech: await this.fileExists((0, path_1.join)(steeringPath, 'tech.md')), hasStructure: await this.fileExists((0, path_1.join)(steeringPath, 'structure.md')) }; return status; } catch { return { exists: false, hasProduct: false, hasTech: false, hasStructure: false }; } } extractBugSeverity(content) { const severityMatch = content.match(/\*\*Severity\*\*:\s*(critical|high|medium|low)/i); if (severityMatch?.[1]) { return severityMatch[1].toLowerCase(); } return undefined; } extractReproductionSteps(content) { const steps = []; const lines = content.split('\n'); let inReproductionSection = false; for (const line of lines) { if (line.includes('## Reproduction Steps') || line.includes('### Reproduction Steps')) { inReproductionSection = true; continue; } if (inReproductionSection) { // Stop at next section if (line.startsWith('## ') || line.startsWith('### ')) { break; } // Look for numbered steps const stepMatch = line.match(/^\d+\.\s+(.+)$/); if (stepMatch?.[1]) { steps.push(stepMatch[1].trim()); } } } return steps; } extractSection(content, sectionName) { const lines = content.split('\n'); let inSection = false; let sectionContent = ''; for (const line of lines) { if (line.includes(`## ${sectionName}`) || line.includes(`### ${sectionName}`)) { inSection = true; continue; } if (inSection) { // Stop at next section if (line.startsWith('## ') || line.startsWith('### ')) { break; } if (line.trim()) { sectionContent += line.trim() + ' '; } } } return sectionContent.trim() || undefined; } hasContentAfterSection(content, sectionName) { const lines = content.split('\n'); let inSection = false; let hasContent = false; for (const line of lines) { if (line.includes(`## ${sectionName}`) || line.includes(`### ${sectionName}`)) { inSection = true; continue; } if (inSection) { // Stop at next section if (line.startsWith('## ') || line.startsWith('### ')) { break; } // Check if line has meaningful content (not just whitespace or template placeholders) const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('[') && // Skip template placeholders like [Description] !trimmed.match(/^\[.*\]$/) && // Skip full line placeholders !trimmed.match(/^<.*>$/) && // Skip placeholder tags !trimmed.match(/^\{.*\}$/)) { // Skip template variables hasContent = true; break; } } } return hasContent; } extractFilesAffected(content) { const files = []; const lines = content.split('\n'); let inFilesSection = false; for (const line of lines) { if (line.includes('Files Affected') || line.includes('Affected Files')) { inFilesSection = true; continue; } if (inFilesSection) { // Stop at next section if (line.startsWith('## ') || line.startsWith('### ')) { break; } // Look for file paths (basic heuristic: contains / or .) const fileMatch = line.match(/[-•]\s*(.+\.[a-zA-Z]+)/) || line.match(/[-•]\s*(.+\/.+)/); if (fileMatch?.[1]) { files.push(fileMatch[1].trim()); } } } return files; } extractTestStatus(content) { if (content.includes('✅ All tests passed') || content.includes('Tests: PASSED')) { return true; } if (content.includes('❌ Tests failed') || content.includes('Tests: FAILED')) { return false; } return undefined; } extractRegressionChecks(content) { const checks = []; const lines = content.split('\n'); let inRegressionSection = false; for (const line of lines) { if (line.includes('Regression Checks') || line.includes('Regression Testing')) { inRegressionSection = true; continue; } if (inRegressionSection) { // Stop at next section if (line.startsWith('## ') || line.startsWith('### ')) { break; } // Look for check items const checkMatch = line.match(/[-•✅❌]\s+(.+)$/); if (checkMatch?.[1]) { checks.push(checkMatch[1].trim()); } } } return checks; } } exports.SpecParser = SpecParser; //# sourceMappingURL=parser.js.map