UNPKG

@dvc2/tasktracker-cli

Version:

Developer context journal for AI-assisted coding - maintain project context across sessions

495 lines (423 loc) • 16.1 kB
/** * TaskTracker PRD Command * * Parse Product Requirements Documents and break them into actionable development context * Helps maintain project vision and requirements across development sessions */ const fs = require('fs'); const path = require('path'); const { output } = require('../core/formatting'); const { saveJournalEntry, getCurrentSession } = require('../utils/journal-utils'); /** * Initialize paths required by the PRD command * @param {string} rootDir The application root directory */ function initPaths(_rootDir) { // PRD will be stored in .tasktracker/prd/ } /** * Parse and store a PRD * @param {array} args Command arguments (file path or direct text) * @param {object} options Command options * @returns {object} Result with status */ function parsePRD(args, options = {}) { try { let prdContent = ''; if (args.length === 0) { output('āŒ PRD content or file path required', 'error', { globalOptions: options }); output('Usage: tt prd "Build a todo app with user auth" or tt prd ./requirements.md', 'info', { globalOptions: options }); return { success: false, error: 'PRD content required' }; } const input = args.join(' '); // Check if input is a file path if (fs.existsSync(input)) { try { prdContent = fs.readFileSync(input, 'utf8'); output(`šŸ“„ Reading PRD from ${input}`, 'info', { globalOptions: options }); } catch (e) { output(`āŒ Error reading file ${input}: ${e.message}`, 'error', { globalOptions: options }); return { success: false, error: `Cannot read file: ${e.message}` }; } } else { // Treat as direct PRD content prdContent = input; } if (!prdContent.trim()) { output('āŒ PRD content is empty', 'error', { globalOptions: options }); return { success: false, error: 'Empty PRD content' }; } // Parse the PRD into structured data const parsedPRD = extractPRDStructure(prdContent); // Save the PRD savePRD(parsedPRD); // Generate initial journal entries from PRD if (options.journal !== false) { generateJournalFromPRD(parsedPRD); } if (options.json) { output(JSON.stringify({ success: true, data: parsedPRD, message: 'PRD parsed and stored' }, null, 2), 'data', { globalOptions: options }); } else { output('āœ… PRD parsed successfully', 'success', { globalOptions: options }); output(`šŸ“‹ Project: ${parsedPRD.title}`, 'info', { globalOptions: options }); output(`šŸŽÆ Goals: ${parsedPRD.goals.length} identified`, 'info', { globalOptions: options }); output(`āš™ļø Features: ${parsedPRD.features.length} identified`, 'info', { globalOptions: options }); output(`🚧 Requirements: ${parsedPRD.requirements.length} identified`, 'info', { globalOptions: options }); if (options.journal !== false) { output('šŸ“ Initial journal entries created from PRD', 'info', { globalOptions: options }); } } return { success: true, prd: parsedPRD }; } catch (error) { output(`āŒ Error parsing PRD: ${error.message}`, 'error', { globalOptions: options }); return { success: false, error: error.message }; } } /** * Show current PRD summary * @param {array} args Command arguments * @param {object} options Command options * @returns {object} Result with status */ function showPRD(args, options = {}) { try { const prd = loadPRD(); if (!prd) { output('šŸ“„ No PRD found. Create one with: tt prd "Your project description"', 'info', { globalOptions: options }); return { success: true, message: 'No PRD found' }; } if (options.json) { output(JSON.stringify({ success: true, data: prd }, null, 2), 'data', { globalOptions: options }); } else { displayPRDSummary(prd, options); } return { success: true, prd }; } catch (error) { output(`āŒ Error showing PRD: ${error.message}`, 'error', { globalOptions: options }); return { success: false, error: error.message }; } } /** * Generate development context from PRD * @param {array} args Command arguments * @param {object} options Command options * @returns {object} Result with status */ function generatePRDContext(args, options = {}) { try { const prd = loadPRD(); if (!prd) { output('šŸ“„ No PRD found. Create one first with: tt prd "Your project description"', 'info', { globalOptions: options }); return { success: false, error: 'No PRD found' }; } const context = buildPRDContext(prd); if (options.output) { const outputPath = options.output.endsWith('.md') ? options.output : `${options.output}.md`; fs.writeFileSync(outputPath, context); output(`āœ… PRD context written to ${outputPath}`, 'success', { globalOptions: options }); } else { output(context, 'info', { globalOptions: options }); } return { success: true, context, prd }; } catch (error) { output(`āŒ Error generating PRD context: ${error.message}`, 'error', { globalOptions: options }); return { success: false, error: error.message }; } } /** * Extract structured data from PRD content with improved parsing */ function extractPRDStructure(content) { const prd = { id: Date.now(), timestamp: new Date().toISOString(), title: '', description: '', goals: [], features: [], requirements: [], constraints: [], userStories: [], technicalStack: [], rawContent: content }; // Parse markdown structure if present const lines = content.split('\n'); let currentSection = null; let currentList = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Extract title from first heading or first line if (!prd.title && line) { if (line.startsWith('#')) { prd.title = line.replace(/^#+\s*/, ''); } else if (i === 0) { prd.title = line.length > 100 ? line.substring(0, 100) + '...' : line; } } // Detect markdown sections if (line.startsWith('#')) { // Save previous section's list if (currentSection && currentList.length > 0) { addToAppropriateSection(prd, currentSection, currentList); currentList = []; } currentSection = line.toLowerCase(); } // Parse list items if (line.startsWith('-') || line.startsWith('*') || line.match(/^\d+\./)) { const item = line.replace(/^[-*]\s*/, '').replace(/^\d+\.\s*/, '').trim(); if (item) { currentList.push(item); } } // Parse user stories (As a... I want... So that...) if (line.toLowerCase().includes('as a') && line.toLowerCase().includes('i want')) { prd.userStories.push(line); } // Detect technical stack mentions const techKeywords = ['react', 'node', 'python', 'django', 'vue', 'angular', 'postgres', 'mongodb', 'redis', 'docker', 'kubernetes', 'aws', 'gcp', 'azure']; const lineLower = line.toLowerCase(); techKeywords.forEach(tech => { if (lineLower.includes(tech) && !prd.technicalStack.includes(tech)) { prd.technicalStack.push(tech); } }); } // Save last section if (currentSection && currentList.length > 0) { addToAppropriateSection(prd, currentSection, currentList); } // If no structured data found, use intelligent extraction if (prd.goals.length === 0 && prd.features.length === 0 && prd.requirements.length === 0) { intelligentExtraction(content, prd); } // Set description from content if not already set if (!prd.description) { const paragraphs = content.split('\n\n').filter(p => p.trim() && !p.startsWith('#')); if (paragraphs.length > 0) { prd.description = paragraphs[0].trim().substring(0, 300) + (paragraphs[0].length > 300 ? '...' : ''); } } return prd; } /** * Add items to appropriate PRD section based on section heading */ function addToAppropriateSection(prd, sectionHeading, items) { const heading = sectionHeading.toLowerCase(); if (heading.includes('goal') || heading.includes('objective') || heading.includes('aim')) { prd.goals.push(...items); } else if (heading.includes('feature') || heading.includes('functionality') || heading.includes('capability')) { prd.features.push(...items); } else if (heading.includes('requirement') || heading.includes('must have') || heading.includes('need')) { prd.requirements.push(...items); } else if (heading.includes('constraint') || heading.includes('limitation') || heading.includes('restriction')) { prd.constraints.push(...items); } else if (heading.includes('user stor')) { prd.userStories.push(...items); } else if (heading.includes('tech') || heading.includes('stack')) { prd.technicalStack.push(...items); } } /** * Intelligent extraction when no clear structure is found */ function intelligentExtraction(content, prd) { // Split into sentences const sentences = content.split(/[.!?]+/).filter(s => s.trim().length > 10); sentences.forEach(sentence => { const clean = sentence.trim(); const lower = clean.toLowerCase(); // Goals/Objectives if (lower.includes('goal') || lower.includes('objective') || lower.includes('aim') || lower.includes('purpose') || lower.includes('mission')) { prd.goals.push(clean); } // Features else if (lower.includes('feature') || lower.includes('function') || lower.includes('capability') || lower.includes('should be able to') || lower.includes('users can') || lower.includes('will allow')) { prd.features.push(clean); } // Requirements else if (lower.includes('must') || lower.includes('require') || lower.includes('need') || lower.includes('essential') || lower.includes('critical')) { prd.requirements.push(clean); } // Constraints else if (lower.includes('cannot') || lower.includes('should not') || lower.includes('limit') || lower.includes('constraint') || lower.includes('restriction')) { prd.constraints.push(clean); } // User stories else if (lower.includes('as a') && (lower.includes('i want') || lower.includes('i need'))) { prd.userStories.push(clean); } }); // If still no goals, use the description as a general goal if (prd.goals.length === 0 && prd.description) { prd.goals.push(prd.description); } } /** * Save PRD to storage */ function savePRD(prd) { const prdDir = path.join(process.cwd(), '.tasktracker', 'prd'); if (!fs.existsSync(prdDir)) { fs.mkdirSync(prdDir, { recursive: true }); } const prdFile = path.join(prdDir, 'current.json'); fs.writeFileSync(prdFile, JSON.stringify(prd, null, 2)); } /** * Load current PRD */ function loadPRD() { const prdFile = path.join(process.cwd(), '.tasktracker', 'prd', 'current.json'); if (!fs.existsSync(prdFile)) { return null; } try { return JSON.parse(fs.readFileSync(prdFile, 'utf8')); } catch (e) { return null; } } /** * Display PRD summary */ function displayPRDSummary(prd, options) { output(`šŸ“‹ Project: ${prd.title}`, 'info', { globalOptions: options }); output(`šŸ“… Created: ${new Date(prd.timestamp).toLocaleDateString()}`, 'info', { globalOptions: options }); if (prd.description) { output(`\nšŸ“ Description:\n${prd.description}`, 'info', { globalOptions: options }); } if (prd.goals && prd.goals.length > 0) { output('\nšŸŽÆ Goals:', 'info', { globalOptions: options }); prd.goals.forEach((goal, i) => { output(` ${i + 1}. ${goal}`, 'info', { globalOptions: options }); }); } if (prd.features && prd.features.length > 0) { output('\nāš™ļø Features:', 'info', { globalOptions: options }); prd.features.forEach((feature, i) => { output(` ${i + 1}. ${feature}`, 'info', { globalOptions: options }); }); } if (prd.requirements && prd.requirements.length > 0) { output('\n🚧 Requirements:', 'info', { globalOptions: options }); prd.requirements.forEach((req, i) => { output(` ${i + 1}. ${req}`, 'info', { globalOptions: options }); }); } if (prd.userStories && prd.userStories.length > 0) { output('\nšŸ‘¤ User Stories:', 'info', { globalOptions: options }); prd.userStories.forEach((story, i) => { output(` ${i + 1}. ${story}`, 'info', { globalOptions: options }); }); } if (prd.technicalStack && prd.technicalStack.length > 0) { output('\nšŸ› ļø Technical Stack:', 'info', { globalOptions: options }); output(` ${prd.technicalStack.join(', ')}`, 'info', { globalOptions: options }); } if (prd.constraints && prd.constraints.length > 0) { output('\nāš ļø Constraints:', 'info', { globalOptions: options }); prd.constraints.forEach((constraint, i) => { output(` ${i + 1}. ${constraint}`, 'info', { globalOptions: options }); }); } } /** * Build context from PRD for AI assistants */ function buildPRDContext(prd) { let context = '# Product Requirements Document\n\n'; context += `## Project: ${prd.title}\n\n`; if (prd.description) { context += `**Description:** ${prd.description}\n\n`; } if (prd.goals.length > 0) { context += '## Project Goals\n\n'; prd.goals.forEach((goal, i) => { context += `${i + 1}. ${goal}\n`; }); context += '\n'; } if (prd.features.length > 0) { context += '## Required Features\n\n'; prd.features.forEach((feature, i) => { context += `${i + 1}. ${feature}\n`; }); context += '\n'; } if (prd.requirements.length > 0) { context += '## Technical Requirements\n\n'; prd.requirements.forEach((req, i) => { context += `${i + 1}. ${req}\n`; }); context += '\n'; } if (prd.constraints.length > 0) { context += '## Constraints & Limitations\n\n'; prd.constraints.forEach((constraint, i) => { context += `${i + 1}. ${constraint}\n`; }); context += '\n'; } context += '## AI Development Context\n\n'; context += 'You are helping implement this project. Key points:\n\n'; context += '1. **Stay aligned with the goals** - All development should serve the stated objectives\n'; context += '2. **Implement required features** - Focus on the features listed above\n'; context += '3. **Respect constraints** - Work within the specified limitations\n'; context += '4. **Maintain project vision** - Keep the overall product direction in mind\n\n'; context += '**Remember:** Use `tt journal` to document decisions and progress as you implement these requirements.\n'; return context; } /** * Generate initial journal entries from PRD */ function generateJournalFromPRD(prd) { const baseTimestamp = Date.now(); // Capture base timestamp // Add PRD context entry const contextEntry = { id: baseTimestamp, // Use base timestamp: new Date().toISOString(), type: 'context', content: `Project initialized: ${prd.title}. Goals: ${prd.goals.length}, Features: ${prd.features.length}, Requirements: ${prd.requirements.length}`, tags: ['prd', 'initialization'], files: [], session: getCurrentSession() }; // Add goal entries const goalEntries = []; prd.goals.forEach((goal, i) => { const goalEntry = { id: baseTimestamp + 1000 + i, // Offset significantly to avoid collision with contextEntry and other goal entries timestamp: new Date().toISOString(), type: 'idea', content: `Project Goal: ${goal}`, tags: ['prd', 'goal'], files: [], session: getCurrentSession() }; goalEntries.push(goalEntry); // Collect goal entries before saving }); // Save entries - goals first, then context. Order can be debated but this is fine. goalEntries.forEach(entry => saveJournalEntry(entry)); saveJournalEntry(contextEntry); } module.exports = { initPaths, parsePRD, showPRD, generatePRDContext, loadPRD };