Spaces:
Paused
Paused
| /** | |
| * Spec Parser - Pure functions for parsing spec content and detecting markers | |
| * | |
| * Extracts tasks from generated specs, detects progress markers, | |
| * and extracts summary content from various formats. | |
| */ | |
| import type { ParsedTask } from '@automaker/types'; | |
| /** | |
| * Parse a single task line | |
| * Format: - [ ] T###: Description | File: path/to/file | |
| */ | |
| function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { | |
| // Match pattern: - [ ] T###: Description | File: path | |
| const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); | |
| if (!taskMatch) { | |
| // Try simpler pattern without file | |
| const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); | |
| if (simpleMatch) { | |
| return { | |
| id: simpleMatch[1], | |
| description: simpleMatch[2].trim(), | |
| phase: currentPhase, | |
| status: 'pending', | |
| }; | |
| } | |
| return null; | |
| } | |
| return { | |
| id: taskMatch[1], | |
| description: taskMatch[2].trim(), | |
| filePath: taskMatch[3]?.trim(), | |
| phase: currentPhase, | |
| status: 'pending', | |
| }; | |
| } | |
| /** | |
| * Parse tasks from generated spec content | |
| * Looks for the ```tasks code block and extracts task lines | |
| * Format: - [ ] T###: Description | File: path/to/file | |
| */ | |
| export function parseTasksFromSpec(specContent: string): ParsedTask[] { | |
| const tasks: ParsedTask[] = []; | |
| // Extract content within ```tasks ... ``` block | |
| const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); | |
| if (!tasksBlockMatch) { | |
| // Try fallback: look for task lines anywhere in content | |
| const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); | |
| if (!taskLines) { | |
| return tasks; | |
| } | |
| // Parse fallback task lines | |
| let currentPhase: string | undefined; | |
| for (const line of taskLines) { | |
| const parsed = parseTaskLine(line, currentPhase); | |
| if (parsed) { | |
| tasks.push(parsed); | |
| } | |
| } | |
| return tasks; | |
| } | |
| const tasksContent = tasksBlockMatch[1]; | |
| const lines = tasksContent.split('\n'); | |
| let currentPhase: string | undefined; | |
| for (const line of lines) { | |
| const trimmedLine = line.trim(); | |
| // Check for phase header (e.g., "## Phase 1: Foundation") | |
| const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); | |
| if (phaseMatch) { | |
| currentPhase = phaseMatch[1].trim(); | |
| continue; | |
| } | |
| // Check for task line | |
| if (trimmedLine.startsWith('- [ ]')) { | |
| const parsed = parseTaskLine(trimmedLine, currentPhase); | |
| if (parsed) { | |
| tasks.push(parsed); | |
| } | |
| } | |
| } | |
| return tasks; | |
| } | |
| /** | |
| * Detect [TASK_START] marker in text and extract task ID | |
| * Format: [TASK_START] T###: Description | |
| */ | |
| export function detectTaskStartMarker(text: string): string | null { | |
| const match = text.match(/\[TASK_START\]\s*(T\d{3})/); | |
| return match ? match[1] : null; | |
| } | |
| /** | |
| * Detect [TASK_COMPLETE] marker in text and extract task ID and summary | |
| * Format: [TASK_COMPLETE] T###: Brief summary | |
| */ | |
| export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null { | |
| // Use a regex that captures the summary until newline or next task marker | |
| // Allow brackets in summary content (e.g., "supports array[index] access") | |
| // Pattern breakdown: | |
| // - \[TASK_COMPLETE\]\s* - Match the marker | |
| // - (T\d{3}) - Capture task ID | |
| // - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket) | |
| // - But we want to allow brackets in summary, so we use a different approach: | |
| // - Match summary until newline, then trim any trailing markers in post-processing | |
| const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i); | |
| if (!match) return null; | |
| // Post-process: remove trailing task markers from summary if present | |
| let summary = match[2]?.trim(); | |
| if (summary) { | |
| // Remove trailing content that looks like another marker | |
| summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim(); | |
| } | |
| return { | |
| id: match[1], | |
| summary: summary || undefined, | |
| }; | |
| } | |
| /** | |
| * Detect [PHASE_COMPLETE] marker in text and extract phase number | |
| * Format: [PHASE_COMPLETE] Phase N complete | |
| */ | |
| export function detectPhaseCompleteMarker(text: string): number | null { | |
| const match = text.match(/\[PHASE_COMPLETE\]\s*Phase\s*(\d+)/i); | |
| return match ? parseInt(match[1], 10) : null; | |
| } | |
| /** | |
| * Fallback spec detection when [SPEC_GENERATED] marker is missing | |
| * Looks for structural elements that indicate a spec was generated. | |
| * This is especially important for non-Claude models that may not output | |
| * the explicit [SPEC_GENERATED] marker. | |
| * | |
| * @param text - The text content to check for spec structure | |
| * @returns true if the text appears to be a generated spec | |
| */ | |
| export function detectSpecFallback(text: string): boolean { | |
| // Check for key structural elements of a spec | |
| const hasTasksBlock = /```tasks[\s\S]*```/.test(text); | |
| const hasTaskLines = /- \[ \] T\d{3}:/.test(text); | |
| // Check for common spec sections (case-insensitive) | |
| const hasAcceptanceCriteria = /acceptance criteria/i.test(text); | |
| const hasTechnicalContext = /technical context/i.test(text); | |
| const hasProblemStatement = /problem statement/i.test(text); | |
| const hasUserStory = /user story/i.test(text); | |
| // Additional patterns for different model outputs | |
| const hasGoal = /\*\*Goal\*\*:/i.test(text); | |
| const hasSolution = /\*\*Solution\*\*:/i.test(text); | |
| const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text); | |
| const hasOverview = /##\s*(overview|summary)/i.test(text); | |
| // Spec is detected if we have task structure AND at least some spec content | |
| const hasTaskStructure = hasTasksBlock || hasTaskLines; | |
| const hasSpecContent = | |
| hasAcceptanceCriteria || | |
| hasTechnicalContext || | |
| hasProblemStatement || | |
| hasUserStory || | |
| hasGoal || | |
| hasSolution || | |
| hasImplementation || | |
| hasOverview; | |
| return hasTaskStructure && hasSpecContent; | |
| } | |
| /** | |
| * Extract summary from text content | |
| * Checks for multiple formats in order of priority: | |
| * 1. Explicit <summary> tags | |
| * 2. ## Summary section (markdown) | |
| * 3. **Goal**: section (lite planning mode) | |
| * 4. **Problem**: or **Problem Statement**: section (spec/full modes) | |
| * 5. **Solution**: section as fallback | |
| * | |
| * Note: Uses last match for each pattern to avoid stale summaries | |
| * when agent output accumulates across multiple runs. | |
| * | |
| * @param text - The text content to extract summary from | |
| * @returns The extracted summary string, or null if no summary found | |
| */ | |
| export function extractSummary(text: string): string | null { | |
| // Helper to truncate content to first paragraph with max length | |
| const truncate = (content: string, maxLength: number): string => { | |
| const firstPara = content.split(/\n\n/)[0]; | |
| return firstPara.length > maxLength ? `${firstPara.substring(0, maxLength)}...` : firstPara; | |
| }; | |
| // Helper to get last match from matchAll results | |
| const getLastMatch = (matches: IterableIterator<RegExpMatchArray>): RegExpMatchArray | null => { | |
| const arr = [...matches]; | |
| return arr.length > 0 ? arr[arr.length - 1] : null; | |
| }; | |
| // Check for explicit <summary> tags first (use last match to avoid stale summaries) | |
| const summaryMatches = text.matchAll(/<summary>([\s\S]*?)<\/summary>/g); | |
| const summaryMatch = getLastMatch(summaryMatches); | |
| if (summaryMatch) { | |
| return summaryMatch[1].trim(); | |
| } | |
| // Check for ## Summary section (use last match) | |
| // Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections | |
| // (like "### Root Cause", "### Fix Applied") that belong to the summary content. | |
| const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi); | |
| const sectionMatch = getLastMatch(sectionMatches); | |
| if (sectionMatch) { | |
| const content = sectionMatch[1].trim(); | |
| // Keep full content (including ### subsections) up to max length | |
| return content.length > 500 ? `${content.substring(0, 500)}...` : content; | |
| } | |
| // Check for **Goal**: section (lite mode, use last match) | |
| const goalMatches = text.matchAll(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/gi); | |
| const goalMatch = getLastMatch(goalMatches); | |
| if (goalMatch) { | |
| return goalMatch[1].trim(); | |
| } | |
| // Check for **Problem**: or **Problem Statement**: section (spec/full modes, use last match) | |
| const problemMatches = text.matchAll( | |
| /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi | |
| ); | |
| const problemMatch = getLastMatch(problemMatches); | |
| if (problemMatch) { | |
| return truncate(problemMatch[1].trim(), 500); | |
| } | |
| // Check for **Solution**: section as fallback (use last match) | |
| const solutionMatches = text.matchAll(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi); | |
| const solutionMatch = getLastMatch(solutionMatches); | |
| if (solutionMatch) { | |
| return truncate(solutionMatch[1].trim(), 300); | |
| } | |
| return null; | |
| } | |