Skip to main content
Glama
prd-integration.ts30.6 kB
/** * PRD Integration Service * * Integrates with the existing prd-generator tool to provide project context * for task decomposition. Handles PRD discovery, parsing, and context integration * with error handling and caching. */ import fs from 'fs/promises'; import path from 'path'; import logger from '../../../logger.js'; import type { PRDInfo, ParsedPRD } from '../types/artifact-types.js'; import { UnifiedSecurityEngine, createDefaultSecurityConfig } from '../core/unified-security-engine.js'; /** * PRD parsing result */ export interface PRDResult { /** Success status */ success: boolean; /** Parsed PRD data */ prdData?: ParsedPRD; /** Error message if parsing failed */ error?: string; /** Parsing time in milliseconds */ parsingTime?: number; } /** * PRD integration configuration */ interface PRDIntegrationConfig { /** Maximum age of PRD before considering it stale (in milliseconds) */ maxAge: number; /** Whether to cache PRD results */ enableCaching: boolean; /** Maximum number of cached PRDs */ maxCacheSize: number; /** Performance monitoring enabled */ enablePerformanceMonitoring: boolean; } /** * PRD metadata information */ export interface PRDMetadata { /** PRD file path */ filePath: string; /** Project path */ projectPath: string; /** Creation timestamp */ createdAt: Date; /** File size in bytes */ fileSize: number; /** PRD version */ version: string; /** Performance metrics */ performanceMetrics: { parsingTime: number; fileSize: number; featureCount: number; sectionCount: number; }; } /** * PRD validation result */ export interface PRDValidationResult { /** Whether the PRD is valid */ isValid: boolean; /** Validation errors */ errors: string[]; /** Validation warnings */ warnings: string[]; /** Completeness score (0-1) */ completenessScore: number; /** Validation timestamp */ validatedAt: Date; } /** * PRD data types for API requests */ export type PRDDataType = | 'overview' | 'features' | 'technical' | 'constraints' | 'metadata' | 'full_content'; /** * PRD Integration Service implementation */ export class PRDIntegrationService { private static instance: PRDIntegrationService; private config: PRDIntegrationConfig; private prdCache = new Map<string, PRDInfo>(); private performanceMetrics = new Map<string, PRDMetadata['performanceMetrics']>(); private securityEngine: UnifiedSecurityEngine | null = null; private constructor() { this.config = { maxAge: 24 * 60 * 60 * 1000, // 24 hours enableCaching: true, maxCacheSize: 50, enablePerformanceMonitoring: true }; logger.debug('PRD integration service initialized'); } /** * Get or initialize the security engine */ private async getSecurityEngine(): Promise<UnifiedSecurityEngine> { if (!this.securityEngine) { const config = createDefaultSecurityConfig(); this.securityEngine = UnifiedSecurityEngine.getInstance(config); await this.securityEngine.initialize(); } return this.securityEngine; } /** * Get singleton instance */ static getInstance(): PRDIntegrationService { if (!PRDIntegrationService.instance) { PRDIntegrationService.instance = new PRDIntegrationService(); } return PRDIntegrationService.instance; } /** * Parse PRD for a project */ async parsePRD(prdFilePath: string): Promise<PRDResult> { const startTime = Date.now(); try { logger.info({ prdFilePath }, 'Starting PRD parsing'); // Validate PRD file path await this.validatePRDPath(prdFilePath); // Read PRD content const prdContent = await fs.readFile(prdFilePath, 'utf-8'); // Parse PRD content const prdData = await this.parsePRDContent(prdContent, prdFilePath); const parsingTime = Date.now() - startTime; // Update cache if (this.config.enableCaching) { await this.updatePRDCache(prdFilePath); } logger.info({ prdFilePath, parsingTime, featureCount: prdData.features.length }, 'PRD parsing completed successfully'); return { success: true, prdData, parsingTime }; } catch (error) { const parsingTime = Date.now() - startTime; logger.error({ err: error, prdFilePath }, 'PRD parsing failed with exception'); return { success: false, error: error instanceof Error ? error.message : String(error), parsingTime }; } } /** * Detect existing PRD for a project */ async detectExistingPRD(projectPath?: string): Promise<PRDInfo | null> { try { // Check cache first if (this.config.enableCaching && projectPath && this.prdCache.has(projectPath)) { const cached = this.prdCache.get(projectPath)!; // Verify file still exists try { await fs.access(cached.filePath); return cached; } catch { // File no longer exists, remove from cache this.prdCache.delete(projectPath); } } // Look for PRD files in the output directory const prdFiles = await this.findPRDFiles(projectPath); if (prdFiles.length === 0) { return null; } // Get the most recent PRD const mostRecent = prdFiles.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; // Update cache if (this.config.enableCaching && projectPath) { this.prdCache.set(projectPath, mostRecent); } return mostRecent; } catch (error) { logger.warn({ err: error, projectPath }, 'Failed to detect existing PRD'); return null; } } /** * Validate PRD file path with security checks */ private async validatePRDPath(prdFilePath: string): Promise<void> { try { // Use unified security engine for path validation const securityEngine = await this.getSecurityEngine(); const validationResponse = await securityEngine.validatePath(prdFilePath, 'read'); if (!validationResponse.success) { throw new Error(`Security validation failed: ${validationResponse.error?.message || 'Unknown error'}`); } const validationResult = validationResponse.data; if (!validationResult.isValid) { throw new Error(`Security validation failed: ${validationResult.error || 'Path validation failed'}`); } // Log any security warnings if (validationResult.warnings && validationResult.warnings.length > 0) { logger.warn({ prdFilePath, warnings: validationResult.warnings }, 'PRD path validation warnings'); } // Additional PRD-specific validation if (!prdFilePath.endsWith('.md')) { throw new Error('PRD file must be a Markdown file (.md)'); } } catch (error) { logger.error({ err: error, prdFilePath }, 'PRD path validation failed'); throw new Error(`Invalid PRD file path: ${error instanceof Error ? error.message : String(error)}`); } } /** * Update PRD cache */ private async updatePRDCache(prdFilePath: string): Promise<void> { try { const stats = await fs.stat(prdFilePath); const fileName = path.basename(prdFilePath); // Extract project name and creation date from filename const { projectName, createdAt } = this.extractPRDMetadataFromFilename(fileName); const prdInfo: PRDInfo = { filePath: prdFilePath, fileName, createdAt, projectName, fileSize: stats.size, isAccessible: true, lastModified: stats.mtime }; // Use project name as cache key this.prdCache.set(projectName, prdInfo); // Maintain cache size limit if (this.prdCache.size > this.config.maxCacheSize) { const oldestKey = this.prdCache.keys().next().value; if (oldestKey) { this.prdCache.delete(oldestKey); } } } catch (error) { logger.warn({ err: error, prdFilePath }, 'Failed to update PRD cache'); } } /** * Split compound words into individual parts for better matching * Examples: * - "kidzhealth" → ["kidz", "health"] → ["kids", "health"] * - "HealthCompanion" → ["Health", "Companion"] * - "web-app" → ["web", "app"] */ private splitCompoundWord(word: string): string[] { // First, split by common boundaries const parts = word.split(/(?=[A-Z])|[-_]|(?<=[a-z])(?=[0-9])/); // Then handle special cases like 'kidz' → 'kids' const normalizedParts = parts.map(part => { // Convert 'z' to 's' at end of words for common misspellings if (part.endsWith('z') && part.length > 2) { const withoutZ = part.slice(0, -1); // Check if it looks like a pluralization (vowel before z) if (/[aeiou]$/.test(withoutZ)) { return withoutZ + 's'; } } return part; }); // Also generate space-separated version const spacedVersion = word .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase .replace(/([a-z])z([a-z])/g, '$1s $2') // kidz → kids .toLowerCase(); // Return unique parts const allParts = [...normalizedParts, ...spacedVersion.split(' ')]; return Array.from(new Set(allParts.filter(p => p.length > 0))); } /** * Extract metadata from PRD filename */ private extractPRDMetadataFromFilename(fileName: string): { projectName: string; createdAt: Date } { // Expected format: YYYY-MM-DDTHH-mm-ss-sssZ-project-name-prd.md const match = fileName.match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-(.+)-prd\.md$/); if (match) { const [, timestamp, projectSlug] = match; const createdAt = new Date(timestamp.replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2}):(\d{3})Z/, 'T$1:$2:$3.$4Z')); const projectName = projectSlug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); return { projectName, createdAt }; } // Fallback for non-standard filenames return { projectName: fileName.replace(/-prd\.md$/, '').replace(/-/g, ' '), createdAt: new Date() }; } /** * Find existing PRD files for a project */ private async findPRDFiles(projectPath?: string): Promise<PRDInfo[]> { try { // Get the output directory from environment or default const outputBaseDir = process.env.VIBE_CODER_OUTPUT_DIR || path.join(process.cwd(), 'VibeCoderOutput'); const prdOutputDir = path.join(outputBaseDir, 'prd-generator'); // Check if output directory exists try { await fs.access(prdOutputDir); } catch { return []; // No output directory means no PRDs } // Find all .md files in the output directory const files = await fs.readdir(prdOutputDir, { withFileTypes: true }); const prdFiles: PRDInfo[] = []; for (const file of files) { if (file.isFile() && file.name.endsWith('-prd.md')) { const filePath = path.join(prdOutputDir, file.name); try { const stats = await fs.stat(filePath); const { projectName, createdAt } = this.extractPRDMetadataFromFilename(file.name); // If projectPath is specified, filter by project name or project ID if (projectPath) { // Handle both project ID (PID-EDUPLAY-ADVENTURES-001) and project name matching const projectPathLower = projectPath.toLowerCase(); const projectNameLower = projectName.toLowerCase(); // Check if projectPath is a project ID (starts with PID-) if (projectPathLower.startsWith('pid-')) { // For project IDs, be more lenient with matching // Try multiple matching strategies const projectIdParts = projectPathLower.replace('pid-', '').split('-'); // Strategy 1: Check if any significant part matches with enhanced fuzzy matching const hasMatchingParts = projectIdParts.some(part => { if (part.length <= 2) { logger.debug({ part, reason: 'too_short' }, 'Skipping PRD match for short part'); return false; } // Try exact match if (projectNameLower.includes(part)) { logger.debug({ part, projectName: projectNameLower, matchType: 'exact' }, 'PRD match found via exact match'); return true; } // Use the helper function to split compound words const splitParts = this.splitCompoundWord(part); logger.debug({ originalPart: part, splitParts, projectName: projectNameLower }, 'Attempting fuzzy PRD match with split parts'); // Check if any of the split parts match for (const splitPart of splitParts) { if (splitPart.length > 1 && projectNameLower.includes(splitPart.toLowerCase())) { logger.debug({ originalPart: part, matchedPart: splitPart, projectName: projectNameLower, matchType: 'split_part' }, 'PRD match found via split part'); return true; } } // Check if ALL parts of a compound word are present (for cases like "kids health") const compoundParts = splitParts.filter(p => p.length > 2); if (compoundParts.length > 1) { const allPartsMatch = compoundParts.every(word => projectNameLower.includes(word.toLowerCase()) ); if (allPartsMatch) { logger.debug({ originalPart: part, matchedParts: compoundParts, projectName: projectNameLower, matchType: 'all_compound_parts' }, 'PRD match found via all compound parts'); return true; } } logger.debug({ part, splitParts, projectName: projectNameLower, reason: 'no_match' }, 'No PRD match found for part'); return false; }); // Strategy 2: Check if this is a platform-based project (common terms) const isPlatformProject = projectNameLower.includes('platform') || projectNameLower.includes('web') || projectNameLower.includes('based'); // Strategy 3: Check if the project ID contains common educational terms const hasEducationalTerms = projectIdParts.some(part => ['edu', 'play', 'game', 'learn', 'platform'].includes(part) ); // Log matching decision logger.debug({ projectId: projectPathLower, projectName: projectNameLower, hasMatchingParts, isPlatformProject, hasEducationalTerms, strategies: { fuzzyMatch: hasMatchingParts, platformMatch: isPlatformProject, educationalMatch: hasEducationalTerms } }, 'PRD matching strategies evaluated'); // Accept if any strategy matches if (!hasMatchingParts && !isPlatformProject && !hasEducationalTerms) { logger.debug({ projectId: projectPathLower, fileName: file.name, reason: 'no_strategy_matched' }, 'Skipping PRD file - no matching strategy succeeded'); continue; } } else { // Traditional project name matching const expectedProjectName = path.basename(projectPath).toLowerCase(); if (!projectNameLower.includes(expectedProjectName)) { continue; } } } prdFiles.push({ filePath, fileName: file.name, createdAt, projectName, fileSize: stats.size, isAccessible: true, lastModified: stats.mtime }); } catch (error) { logger.warn({ err: error, fileName: file.name }, 'Failed to process PRD file'); // Add as inaccessible file const { projectName, createdAt } = this.extractPRDMetadataFromFilename(file.name); prdFiles.push({ filePath: path.join(prdOutputDir, file.name), fileName: file.name, createdAt, projectName, fileSize: 0, isAccessible: false, lastModified: new Date() }); } } } return prdFiles; } catch (error) { logger.error({ err: error, projectPath }, 'Failed to find PRD files'); return []; } } /** * Parse PRD content from markdown */ private async parsePRDContent(content: string, filePath: string): Promise<ParsedPRD> { const startTime = Date.now(); try { // Validate file path before accessing file system const securityEngine = await this.getSecurityEngine(); const validationResponse = await securityEngine.validatePath(filePath, 'read'); if (!validationResponse.success) { throw new Error(`Security validation failed: ${validationResponse.error?.message || 'Unknown error'}`); } const validationResult = validationResponse.data; if (!validationResult.isValid) { throw new Error(`Security validation failed: ${validationResult.error || 'Path validation failed'}`); } const lines = content.split('\n'); const fileName = path.basename(filePath); const { projectName, createdAt } = this.extractPRDMetadataFromFilename(fileName); const stats = await fs.stat(validationResult.normalizedPath || filePath); // Initialize parsed PRD structure const parsedPRD: ParsedPRD = { metadata: { filePath, projectName, createdAt, fileSize: stats.size }, overview: { description: '', businessGoals: [], productGoals: [], successMetrics: [] }, targetAudience: { primaryUsers: [], demographics: [], userNeeds: [] }, features: [], technical: { techStack: [], architecturalPatterns: [], performanceRequirements: [], securityRequirements: [], scalabilityRequirements: [] }, constraints: { timeline: [], budget: [], resources: [], technical: [] } }; // Parse content sections let currentSection = ''; let currentSubsection = ''; let featureId = 1; let currentFeature: ParsedPRD['features'][0] | null = null; let inUserStory = false; let inAcceptanceCriteria = false; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Detect main sections if (line.startsWith('# ')) { currentSection = line.substring(2).toLowerCase(); currentSubsection = ''; currentFeature = null; continue; } // Detect subsections if (line.startsWith('## ')) { currentSubsection = line.substring(3).toLowerCase(); currentFeature = null; continue; } // Detect sub-subsections (features in ### format) if (line.startsWith('### ')) { const subsectionTitle = line.substring(4); currentSubsection = subsectionTitle.toLowerCase(); // Check if this is a feature section (e.g., "### 4.1. Core Content Delivery") if (currentSection.includes('feature') || currentSection.includes('functionality')) { const featureMatch = subsectionTitle.match(/^(\d+\.?\d*\.?)\s*(.+)$/); if (featureMatch) { const [, , featureTitle] = featureMatch; currentFeature = { id: `F${featureId.toString().padStart(3, '0')}`, title: featureTitle.trim(), description: '', userStories: [], acceptanceCriteria: [], priority: 'medium' }; parsedPRD.features.push(currentFeature); featureId++; inUserStory = false; inAcceptanceCriteria = false; } } continue; } // Parse user story and acceptance criteria sections if (currentFeature && line.startsWith('**')) { if (line.toLowerCase().includes('user story:')) { inUserStory = true; inAcceptanceCriteria = false; continue; } else if (line.toLowerCase().includes('acceptance criteria:')) { inUserStory = false; inAcceptanceCriteria = true; continue; } else if (line.toLowerCase().includes('description:')) { inUserStory = false; inAcceptanceCriteria = false; continue; } } // Parse feature content if (currentFeature) { if (inUserStory && line.length > 0) { currentFeature.userStories.push(line); } else if (inAcceptanceCriteria && line.startsWith('- ')) { currentFeature.acceptanceCriteria.push(line.substring(2)); } else if (!inUserStory && !inAcceptanceCriteria && line.length > 0 && !line.startsWith('**')) { if (currentFeature.description) { currentFeature.description += ' '; } currentFeature.description += line; } } // Parse content based on current section (for non-feature sections) if (!currentFeature) { this.parsePRDSection(line, currentSection, currentSubsection, parsedPRD, featureId); } // Also check for the old format (- **Feature:**) if (currentSection.includes('feature') && line.startsWith('- **') && line.includes(':**')) { const match = line.match(/- \*\*(.+?):\*\*\s*(.+)/); if (match) { const [, title, description] = match; currentFeature = { id: `F${featureId.toString().padStart(3, '0')}`, title: title.trim(), description: description.trim(), userStories: [], acceptanceCriteria: [], priority: 'medium' }; parsedPRD.features.push(currentFeature); featureId++; } } } // Record performance metrics if (this.config.enablePerformanceMonitoring) { const parsingTime = Date.now() - startTime; this.performanceMetrics.set(filePath, { parsingTime, fileSize: stats.size, featureCount: parsedPRD.features.length, sectionCount: 5 // overview, target audience, features, technical, constraints }); } logger.info({ filePath, featureCount: parsedPRD.features.length, features: parsedPRD.features.map(f => ({ id: f.id, title: f.title })) }, 'PRD content parsed successfully'); return parsedPRD; } catch (error) { logger.error({ err: error, filePath }, 'Failed to parse PRD content'); throw error; } } /** * Parse individual PRD section content */ private parsePRDSection( line: string, section: string, subsection: string, parsedPRD: ParsedPRD, featureId: number ): void { if (!line || line.startsWith('#')) return; // Parse based on section and subsection if (section.includes('introduction') || section.includes('overview') || section.includes('comprehensive app prd')) { if (subsection.includes('description') && line.length > 10 && !line.startsWith('- ')) { parsedPRD.overview.description += line + ' '; } else if (line.startsWith('- ')) { if (subsection.includes('business') && subsection.includes('goal')) { parsedPRD.overview.businessGoals.push(line.substring(2)); } else if (subsection.includes('product') && subsection.includes('goal')) { parsedPRD.overview.productGoals.push(line.substring(2)); } else if (subsection.includes('success') && subsection.includes('metric')) { parsedPRD.overview.successMetrics.push(line.substring(2)); } } // Handle direct content under main section if (!subsection && line.length > 10 && !line.startsWith('- ') && !line.startsWith('#')) { parsedPRD.overview.description += line + ' '; } } if (section.includes('target') || section.includes('audience')) { if (line.startsWith('- ')) { if (subsection.includes('user') || subsection.includes('primary')) { parsedPRD.targetAudience.primaryUsers.push(line.substring(2)); } else if (subsection.includes('demographic')) { parsedPRD.targetAudience.demographics.push(line.substring(2)); } else if (subsection.includes('need')) { parsedPRD.targetAudience.userNeeds.push(line.substring(2)); } } } if (section.includes('feature') || section.includes('functionality')) { if (line.startsWith('- **') && line.includes(':**')) { // New feature const match = line.match(/- \*\*(.+?):\*\*\s*(.+)/); if (match) { const [, title, description] = match; parsedPRD.features.push({ id: `F${featureId.toString().padStart(3, '0')}`, title: title.trim(), description: description.trim(), userStories: [], acceptanceCriteria: [], priority: 'medium' }); } } else if (line.startsWith(' - ') && parsedPRD.features.length > 0) { // Feature details const lastFeature = parsedPRD.features[parsedPRD.features.length - 1]; if (subsection.includes('story') || subsection.includes('user')) { lastFeature.userStories.push(line.substring(4)); } else if (subsection.includes('criteria') || subsection.includes('acceptance')) { lastFeature.acceptanceCriteria.push(line.substring(4)); } } } if (section.includes('technical') || section.includes('technology')) { if (line.startsWith('- ')) { if (subsection.includes('stack') || subsection.includes('technology')) { parsedPRD.technical.techStack.push(line.substring(2)); } else if (subsection.includes('pattern') || subsection.includes('architecture')) { parsedPRD.technical.architecturalPatterns.push(line.substring(2)); } else if (subsection.includes('performance')) { parsedPRD.technical.performanceRequirements.push(line.substring(2)); } else if (subsection.includes('security')) { parsedPRD.technical.securityRequirements.push(line.substring(2)); } else if (subsection.includes('scalability')) { parsedPRD.technical.scalabilityRequirements.push(line.substring(2)); } } } if (section.includes('constraint') || section.includes('limitation')) { if (line.startsWith('- ')) { if (subsection.includes('timeline') || subsection.includes('schedule')) { parsedPRD.constraints.timeline.push(line.substring(2)); } else if (subsection.includes('budget') || subsection.includes('cost')) { parsedPRD.constraints.budget.push(line.substring(2)); } else if (subsection.includes('resource') || subsection.includes('team')) { parsedPRD.constraints.resources.push(line.substring(2)); } else if (subsection.includes('technical')) { parsedPRD.constraints.technical.push(line.substring(2)); } } } } /** * Get PRD metadata */ async getPRDMetadata(prdFilePath: string): Promise<PRDMetadata> { try { // Validate PRD file path first await this.validatePRDPath(prdFilePath); const stats = await fs.stat(prdFilePath); const fileName = path.basename(prdFilePath); const { createdAt } = this.extractPRDMetadataFromFilename(fileName); // Get performance metrics if available const performanceMetrics = this.performanceMetrics.get(prdFilePath) || { parsingTime: 0, fileSize: stats.size, featureCount: 0, sectionCount: 0 }; return { filePath: prdFilePath, projectPath: '', // Will be determined by caller createdAt, fileSize: stats.size, version: '1.0', // Default version performanceMetrics }; } catch (error) { logger.error({ err: error, prdFilePath }, 'Failed to get PRD metadata'); throw error; } } /** * Clear PRD cache */ clearCache(): void { this.prdCache.clear(); this.performanceMetrics.clear(); logger.info('PRD integration cache cleared'); } /** * Update configuration */ updateConfig(newConfig: Partial<PRDIntegrationConfig>): void { this.config = { ...this.config, ...newConfig }; logger.debug({ config: this.config }, 'PRD integration configuration updated'); } /** * Get current configuration */ getConfig(): PRDIntegrationConfig { return { ...this.config }; } /** * Get performance metrics */ getPerformanceMetrics(): Map<string, PRDMetadata['performanceMetrics']> { return new Map(this.performanceMetrics); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/freshtechbro/vibe-coder-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server