UNPKG

@pimzino/spec-workflow-mcp

Version:

MCP server for spec-driven development workflow with real-time web dashboard

326 lines 14 kB
/** * Unified Task Parser Module * Provides consistent task parsing across all components */ /** * Parse a prompt string into structured sections if it contains pipe separators * @param promptText The raw prompt text * @returns Array of prompt sections or undefined if not structured */ function parseStructuredPrompt(promptText) { // Validate input if (!promptText || typeof promptText !== 'string') { return undefined; } // Check if the prompt contains pipe separators (indicating structured format) if (!promptText.includes('|')) { return undefined; } const sections = []; // Split by pipe and process each section const parts = promptText.split('|').map(part => part.trim()).filter(part => part.length > 0); // Return early if no valid parts after filtering if (parts.length === 0) { return undefined; } for (let i = 0; i < parts.length; i++) { const part = parts[i]; // Part is guaranteed to be non-empty due to filter above // Special handling for the first part - it might contain preamble text before the first key if (i === 0) { // Look for the last occurrence of a known key pattern in the first part const knownKeys = ['Role', 'Task', 'Context', 'Instructions', 'Requirements', 'Leverage', 'Success', 'Restrictions']; let lastKeyIndex = -1; for (const key of knownKeys) { const keyPattern = new RegExp(`\\b${key}:`, 'i'); const match = part.match(keyPattern); if (match && match.index !== undefined && match.index > lastKeyIndex) { lastKeyIndex = match.index; } } if (lastKeyIndex > -1 && lastKeyIndex < part.length) { // Extract the key-value pair starting from the found key const keyValuePart = part.substring(lastKeyIndex); const colonIndex = keyValuePart.indexOf(':'); if (colonIndex > 0 && colonIndex < keyValuePart.length - 1) { const key = keyValuePart.substring(0, colonIndex).trim(); const value = keyValuePart.substring(colonIndex + 1).trim(); // Validate key and value are non-empty after cleaning if (key && value) { const cleanKey = key.replace(/^_+|_+$/g, ''); const cleanValue = value.replace(/^_+|_+$/g, ''); // Only add if both cleaned values are non-empty if (cleanKey && cleanValue) { sections.push({ key: cleanKey, value: cleanValue }); } } } } continue; } // For other parts, look for "Key: Value" pattern const colonIndex = part.indexOf(':'); if (colonIndex > 0 && colonIndex < part.length - 1) { const key = part.substring(0, colonIndex).trim(); const value = part.substring(colonIndex + 1).trim(); // Validate key and value exist if (key && value) { // Clean up any markdown formatting (underscores for italics, etc.) const cleanKey = key.replace(/^_+|_+$/g, ''); const cleanValue = value.replace(/^_+|_+$/g, ''); // Only add if both cleaned values are non-empty if (cleanKey && cleanValue) { sections.push({ key: cleanKey, value: cleanValue }); } } } else if (colonIndex <= 0 || colonIndex >= part.length - 1) { // If no valid colon position, treat as continuation only if previous section exists if (sections.length > 0) { const cleanedPart = part.replace(/^_+|_+$/g, '').trim(); if (cleanedPart) { sections[sections.length - 1].value += ' | ' + cleanedPart; } } } } return sections.length > 0 ? sections : undefined; } /** * Parse tasks from markdown content * Handles any checkbox format at any indentation level */ export function parseTasksFromMarkdown(content) { const lines = content.split('\n'); const tasks = []; let inProgressTask = null; // Find all lines with checkboxes const checkboxIndices = []; for (let i = 0; i < lines.length; i++) { if (lines[i].match(/^\s*-\s+\[([ x\-])\]/)) { checkboxIndices.push(i); } } // Process each checkbox task for (let idx = 0; idx < checkboxIndices.length; idx++) { const lineNumber = checkboxIndices[idx]; const endLine = idx < checkboxIndices.length - 1 ? checkboxIndices[idx + 1] : lines.length; const line = lines[lineNumber]; const checkboxMatch = line.match(/^(\s*)-\s+\[([ x\-])\]\s+(.+)/); if (!checkboxMatch) continue; const indent = checkboxMatch[1]; const statusChar = checkboxMatch[2]; const taskText = checkboxMatch[3]; // Determine status let status; if (statusChar === 'x') { status = 'completed'; } else if (statusChar === '-') { status = 'in-progress'; } else { status = 'pending'; } // Extract task ID and description // Match patterns like "1. Description", "1.1 Description", "2.1. Description" etc const taskMatch = taskText.match(/^(\d+(?:\.\d+)*)\s*\.?\s+(.+)/); let taskId; let description; if (taskMatch) { taskId = taskMatch[1]; description = taskMatch[2]; } else { // No task number found, skip this task continue; } // Parse metadata from content between this task and the next const requirements = []; const leverage = []; const files = []; const purposes = []; const implementationDetails = []; let prompt; for (let lineIdx = lineNumber + 1; lineIdx < endLine; lineIdx++) { const contentLine = lines[lineIdx].trim(); // Skip empty lines if (!contentLine) continue; // Check for metadata patterns // IMPORTANT: Check for _Prompt: first since it can contain nested _Requirements: and _Leverage: if (contentLine.includes('_Prompt:')) { // Capture everything after _Prompt: until the final closing underscore const promptMatch = contentLine.match(/_Prompt:\s*(.+)_$/); if (promptMatch) { prompt = promptMatch[1].trim(); } else { // If no closing underscore on same line, capture multi-line const afterPrompt = contentLine.match(/_Prompt:\s*(.+)$/); let promptText = afterPrompt ? afterPrompt[1] : ''; promptText = promptText.replace(/_$/, '').trim(); // Accumulate continuation lines that are not new bullets/metadata let j = lineIdx + 1; while (j < endLine) { const nextTrim = lines[j].trim(); if (!nextTrim) break; // stop at blank line // Stop if we hit another bullet/metadata marker or files/purpose sections if (/^-\s/.test(nextTrim) || /^Files?:/i.test(nextTrim) || /^Purpose:/i.test(nextTrim)) { break; } promptText += ' ' + nextTrim.replace(/_$/, '').trim(); j++; } prompt = promptText; // Skip consumed continuation lines lineIdx = j - 1; } } else if (contentLine.includes('_Requirements:') && !contentLine.includes('_Prompt:')) { // Only process if not inside a prompt const reqMatch = contentLine.match(/_Requirements:\s*([^_]+?)_/); if (reqMatch) { const reqText = reqMatch[1].trim(); // Split by comma and filter out empty/NFR requirements.push(...reqText.split(',').map(r => r.trim()).filter(r => r && r !== 'NFR')); } } else if (contentLine.includes('_Leverage:') && !contentLine.includes('_Prompt:')) { // Only process if not inside a prompt const levMatch = contentLine.match(/_Leverage:\s*([^_]+?)_/); if (levMatch) { const levText = levMatch[1].trim(); leverage.push(...levText.split(',').map(l => l.trim()).filter(l => l)); } } else if (contentLine.match(/Files?:/)) { const fileMatch = contentLine.match(/Files?:\s*(.+)$/); if (fileMatch) { // Split by comma and clean up each file path const filePaths = fileMatch[1] .split(',') .map(f => f.trim().replace(/\(.*?\)/, '').trim()) .filter(f => f.length > 0); files.push(...filePaths); } } else if (contentLine.startsWith('- ') && !contentLine.match(/^-\s+\[/)) { // Regular bullet point - could be implementation detail or purpose const bulletContent = contentLine.substring(2).trim(); if (bulletContent.startsWith('Purpose:')) { purposes.push(bulletContent.substring(8).trim()); } else if (!bulletContent.match(/^Files?:/) && !bulletContent.match(/^Purpose:/)) { implementationDetails.push(bulletContent); } } } // Determine if this is a header task (has no implementation details) const hasDetails = requirements.length > 0 || leverage.length > 0 || files.length > 0 || purposes.length > 0 || implementationDetails.length > 0 || !!prompt; // Parse structured prompt if applicable let promptStructured; if (prompt) { promptStructured = parseStructuredPrompt(prompt); } const task = { id: taskId, description, status, lineNumber, indentLevel: indent.length / 2, // Assuming 2 spaces per indent level isHeader: !hasDetails, completed: status === 'completed', inProgress: status === 'in-progress', // Add metadata if present ...(requirements.length > 0 && { requirements }), ...(leverage.length > 0 && { leverage: leverage.join(', ') }), ...(files.length > 0 && { files }), ...(purposes.length > 0 && { purposes }), ...(implementationDetails.length > 0 && { implementationDetails }), ...(prompt && { prompt }), ...(promptStructured && { promptStructured }) }; tasks.push(task); // Track first in-progress task (for UI highlighting) if (status === 'in-progress' && !inProgressTask) { inProgressTask = taskId; // Just store the task ID for UI comparison } } // Calculate summary const summary = { total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, inProgress: tasks.filter(t => t.status === 'in-progress').length, pending: tasks.filter(t => t.status === 'pending').length, headers: tasks.filter(t => t.isHeader).length }; return { tasks, inProgressTask, summary }; } /** * Update task status in markdown content * Handles any indentation level and task numbering format */ export function updateTaskStatus(content, taskId, newStatus) { const lines = content.split('\n'); const statusMarker = newStatus === 'completed' ? 'x' : newStatus === 'in-progress' ? '-' : ' '; // Find and update the task line for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Match checkbox line with task ID in the description // Pattern: - [x] 1.1 Task description const checkboxMatch = line.match(/^(\s*-\s+\[)([ x\-])(\]\s+)(.+)$/); if (checkboxMatch) { const taskText = checkboxMatch[4]; // Check if this line contains our target task ID // Match patterns like "1. Description", "1.1 Description", "2.1. Description" etc const taskMatch = taskText.match(/^(\d+(?:\.\d+)*)\s*\.?\s+(.+)/); if (taskMatch && taskMatch[1] === taskId) { // Reconstruct the line with new status lines[i] = checkboxMatch[1] + statusMarker + checkboxMatch[3] + taskText; return lines.join('\n'); } } } // Task not found return content; } /** * Find the next pending task that is not a header */ export function findNextPendingTask(tasks) { return tasks.find(t => t.status === 'pending' && !t.isHeader) || null; } /** * Get task by ID */ export function getTaskById(tasks, taskId) { return tasks.find(t => t.id === taskId); } /** * Export for backward compatibility with existing code */ export function parseTaskProgress(content) { const result = parseTasksFromMarkdown(content); return { total: result.summary.total, completed: result.summary.completed, pending: result.summary.pending }; } //# sourceMappingURL=task-parser.js.map