UNPKG

task-master-neo-sdlc

Version:

Enhanced task management system with Neo SDLC agents and MCP tools for comprehensive, AI-driven software development lifecycle management.

1,640 lines (1,443 loc) 180 kB
/** * task-manager.js * Task management functions for the Task Master CLI */ import fs from 'fs'; import path from 'path'; import chalk from 'chalk'; import boxen from 'boxen'; import Table from 'cli-table3'; import readline from 'readline'; import { Anthropic } from '@anthropic-ai/sdk'; import ora from 'ora'; import inquirer from 'inquirer'; import { CONFIG, log, readJSON, writeJSON, sanitizePrompt, findTaskById, readComplexityReport, findTaskInComplexityReport, truncate, enableSilentMode, disableSilentMode, isSilentMode } from './utils.js'; import { displayBanner, getStatusWithColor, formatDependenciesWithStatus, getComplexityWithColor, startLoadingIndicator, stopLoadingIndicator, createProgressBar } from './ui.js'; import { callClaude, generateSubtasks, generateSubtasksWithPerplexity, generateComplexityAnalysisPrompt, getAvailableAIModel, handleClaudeError, _handleAnthropicStream, getConfiguredAnthropicClient, sendChatWithContext, parseTasksFromCompletion, generateTaskDescriptionWithPerplexity, parseSubtasksFromText } from './ai-services.js'; import { validateTaskDependencies, validateAndFixDependencies } from './dependency-manager.js'; // Initialize Anthropic client const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); // Import perplexity if available let perplexity; try { if (process.env.PERPLEXITY_API_KEY) { // Using the existing approach from ai-services.js const OpenAI = (await import('openai')).default; perplexity = new OpenAI({ apiKey: process.env.PERPLEXITY_API_KEY, baseURL: 'https://api.perplexity.ai' }); log( 'info', `Initialized Perplexity client with OpenAI compatibility layer` ); } } catch (error) { log('warn', `Failed to initialize Perplexity client: ${error.message}`); log('warn', 'Research-backed features will not be available'); } /** * Parse a PRD file and generate tasks * @param {string} prdPath - Path to the PRD file * @param {string} tasksPath - Path to the tasks.json file * @param {number} numTasks - Number of tasks to generate * @param {Object} options - Additional options * @param {Object} options.reportProgress - Function to report progress to MCP server (optional) * @param {Object} options.mcpLog - MCP logger object (optional) * @param {Object} options.session - Session object from MCP server (optional) * @param {Object} aiClient - AI client to use (optional) * @param {Object} modelConfig - Model configuration (optional) */ async function parsePRD( prdPath, tasksPath, numTasks, options = {}, aiClient = null, modelConfig = null ) { const { reportProgress, mcpLog, session } = options; // Determine output format based on mcpLog presence (simplification) const outputFormat = mcpLog ? 'json' : 'text'; // Create custom reporter that checks for MCP log and silent mode const report = (message, level = 'info') => { if (mcpLog) { mcpLog[level](message); } else if (!isSilentMode() && outputFormat === 'text') { // Only log to console if not in silent mode and outputFormat is 'text' log(level, message); } }; try { report(`Parsing PRD file: ${prdPath}`, 'info'); // Read the PRD content const prdContent = fs.readFileSync(prdPath, 'utf8'); // Call Claude to generate tasks, passing the provided AI client if available const tasksData = await callClaude( prdContent, prdPath, numTasks, 0, { reportProgress, mcpLog, session }, aiClient, modelConfig ); // Create the directory if it doesn't exist const tasksDir = path.dirname(tasksPath); if (!fs.existsSync(tasksDir)) { fs.mkdirSync(tasksDir, { recursive: true }); } // Write the tasks to the file writeJSON(tasksPath, tasksData); report( `Successfully generated ${tasksData.tasks.length} tasks from PRD`, 'success' ); report(`Tasks saved to: ${tasksPath}`, 'info'); // Generate individual task files if (reportProgress && mcpLog) { // Enable silent mode when being called from MCP server enableSilentMode(); await generateTaskFiles(tasksPath, tasksDir); disableSilentMode(); } else { await generateTaskFiles(tasksPath, tasksDir); } // Only show success boxes for text output (CLI) if (outputFormat === 'text') { console.log( boxen( chalk.green( `Successfully generated ${tasksData.tasks.length} tasks from PRD` ), { padding: 1, borderColor: 'green', borderStyle: 'round' } ) ); console.log( boxen( chalk.white.bold('Next Steps:') + '\n\n' + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list')} to view all tasks\n` + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down a task into subtasks`, { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } ) ); } return tasksData; } catch (error) { report(`Error parsing PRD: ${error.message}`, 'error'); // Only show error UI for text output (CLI) if (outputFormat === 'text') { console.error(chalk.red(`Error: ${error.message}`)); if (CONFIG.debug) { console.error(error); } // Don't exit during tests, let Jest handle failures if (process.env.NODE_ENV !== 'test') { process.exit(1); } } else { throw error; // Re-throw for JSON output } } } /** * Update tasks based on new context * @param {string} tasksPath - Path to the tasks.json file * @param {number} fromId - Task ID to start updating from * @param {string} prompt - Prompt with new context * @param {boolean} useResearch - Whether to use Perplexity AI for research * @param {function} reportProgress - Function to report progress to MCP server (optional) * @param {Object} mcpLog - MCP logger object (optional) * @param {Object} session - Session object from MCP server (optional) */ async function updateTasks( tasksPath, fromId, prompt, useResearch = false, { reportProgress, mcpLog, session } = {} ) { // Determine output format based on mcpLog presence (simplification) const outputFormat = mcpLog ? 'json' : 'text'; // Create custom reporter that checks for MCP log and silent mode const report = (message, level = 'info') => { if (mcpLog) { mcpLog[level](message); } else if (!isSilentMode() && outputFormat === 'text') { // Only log to console if not in silent mode and outputFormat is 'text' log(level, message); } }; try { report(`Updating tasks from ID ${fromId} with prompt: "${prompt}"`); // Read the tasks file const data = readJSON(tasksPath); if (!data || !data.tasks) { throw new Error(`No valid tasks found in ${tasksPath}`); } // Find tasks to update (ID >= fromId and not 'done') const tasksToUpdate = data.tasks.filter( (task) => task.id >= fromId && task.status !== 'done' ); if (tasksToUpdate.length === 0) { report( `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`, 'info' ); // Only show UI elements for text output (CLI) if (outputFormat === 'text') { console.log( chalk.yellow( `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)` ) ); } return; } // Only show UI elements for text output (CLI) if (outputFormat === 'text') { // Show the tasks that will be updated const table = new Table({ head: [ chalk.cyan.bold('ID'), chalk.cyan.bold('Title'), chalk.cyan.bold('Status') ], colWidths: [5, 60, 10] }); tasksToUpdate.forEach((task) => { table.push([ task.id, truncate(task.title, 57), getStatusWithColor(task.status) ]); }); console.log( boxen(chalk.white.bold(`Updating ${tasksToUpdate.length} tasks`), { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } }) ); console.log(table.toString()); // Display a message about how completed subtasks are handled console.log( boxen( chalk.cyan.bold('How Completed Subtasks Are Handled:') + '\n\n' + chalk.white( '• Subtasks marked as "done" or "completed" will be preserved\n' ) + chalk.white( '• New subtasks will build upon what has already been completed\n' ) + chalk.white( '• If completed work needs revision, a new subtask will be created instead of modifying done items\n' ) + chalk.white( '• This approach maintains a clear record of completed work and new requirements' ), { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } ) ); } // Build the system prompt const systemPrompt = `You are an AI assistant helping to update software development tasks based on new context. You will be given a set of tasks and a prompt describing changes or new implementation details. Your job is to update the tasks to reflect these changes, while preserving their basic structure. Guidelines: 1. Maintain the same IDs, statuses, and dependencies unless specifically mentioned in the prompt 2. Update titles, descriptions, details, and test strategies to reflect the new information 3. Do not change anything unnecessarily - just adapt what needs to change based on the prompt 4. You should return ALL the tasks in order, not just the modified ones 5. Return a complete valid JSON object with the updated tasks array 6. VERY IMPORTANT: Preserve all subtasks marked as "done" or "completed" - do not modify their content 7. For tasks with completed subtasks, build upon what has already been done rather than rewriting everything 8. If an existing completed subtask needs to be changed/undone based on the new context, DO NOT modify it directly 9. Instead, add a new subtask that clearly indicates what needs to be changed or replaced 10. Use the existence of completed subtasks as an opportunity to make new subtasks more specific and targeted The changes described in the prompt should be applied to ALL tasks in the list.`; const taskData = JSON.stringify(tasksToUpdate, null, 2); // Initialize variables for model selection and fallback let updatedTasks; let loadingIndicator = null; let claudeOverloaded = false; let modelAttempts = 0; const maxModelAttempts = 2; // Try up to 2 models before giving up // Only create loading indicator for text output (CLI) initially if (outputFormat === 'text') { loadingIndicator = startLoadingIndicator( useResearch ? 'Updating tasks with Perplexity AI research...' : 'Updating tasks with Claude AI...' ); } try { // Import the getAvailableAIModel function const { getAvailableAIModel } = await import('./ai-services.js'); // Try different models with fallback while (modelAttempts < maxModelAttempts && !updatedTasks) { modelAttempts++; const isLastAttempt = modelAttempts >= maxModelAttempts; let modelType = null; try { // Get the appropriate model based on current state const result = getAvailableAIModel({ claudeOverloaded, requiresResearch: useResearch }); modelType = result.type; const client = result.client; report( `Attempt ${modelAttempts}/${maxModelAttempts}: Updating tasks using ${modelType}`, 'info' ); // Update loading indicator - only for text output if (outputFormat === 'text') { if (loadingIndicator) { stopLoadingIndicator(loadingIndicator); } loadingIndicator = startLoadingIndicator( `Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...` ); } if (modelType === 'perplexity') { // Call Perplexity AI using proper format const perplexityModel = process.env.PERPLEXITY_MODEL || session?.env?.PERPLEXITY_MODEL || 'sonar-pro'; const result = await client.chat.completions.create({ model: perplexityModel, messages: [ { role: 'system', content: `${systemPrompt}\n\nAdditionally, please research the latest best practices, implementation details, and considerations when updating these tasks. Use your online search capabilities to gather relevant information. Remember to strictly follow the guidelines about preserving completed subtasks and building upon what has already been done rather than modifying or replacing it.` }, { role: 'user', content: `Here are the tasks to update: ${taskData} Please update these tasks based on the following new context: ${prompt} IMPORTANT: In the tasks JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items. Return only the updated tasks as a valid JSON array.` } ], temperature: parseFloat( process.env.TEMPERATURE || session?.env?.TEMPERATURE || CONFIG.temperature ), max_tokens: parseInt( process.env.MAX_TOKENS || session?.env?.MAX_TOKENS || CONFIG.maxTokens ) }); const responseText = result.choices[0].message.content; // Extract JSON from response const jsonStart = responseText.indexOf('['); const jsonEnd = responseText.lastIndexOf(']'); if (jsonStart === -1 || jsonEnd === -1) { throw new Error( `Could not find valid JSON array in ${modelType}'s response` ); } const jsonText = responseText.substring(jsonStart, jsonEnd + 1); updatedTasks = JSON.parse(jsonText); } else { // Call Claude to update the tasks with streaming let responseText = ''; let streamingInterval = null; try { // Update loading indicator to show streaming progress - only for text output if (outputFormat === 'text') { let dotCount = 0; const readline = await import('readline'); streamingInterval = setInterval(() => { readline.cursorTo(process.stdout, 0); process.stdout.write( `Receiving streaming response from Claude${'.'.repeat(dotCount)}` ); dotCount = (dotCount + 1) % 4; }, 500); } // Use streaming API call const stream = await client.messages.create({ model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, temperature: session?.env?.TEMPERATURE || CONFIG.temperature, system: systemPrompt, messages: [ { role: 'user', content: `Here is the task to update: ${taskData} Please update this task based on the following new context: ${prompt} IMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items. Return only the updated task as a valid JSON object.` } ], stream: true }); // Process the stream for await (const chunk of stream) { if (chunk.type === 'content_block_delta' && chunk.delta.text) { responseText += chunk.delta.text; } if (reportProgress) { await reportProgress({ progress: (responseText.length / CONFIG.maxTokens) * 100 }); } if (mcpLog) { mcpLog.info( `Progress: ${(responseText.length / CONFIG.maxTokens) * 100}%` ); } } if (streamingInterval) clearInterval(streamingInterval); report( `Completed streaming response from ${modelType} API (Attempt ${modelAttempts})`, 'info' ); // Extract JSON from response const jsonStart = responseText.indexOf('['); const jsonEnd = responseText.lastIndexOf(']'); if (jsonStart === -1 || jsonEnd === -1) { throw new Error( `Could not find valid JSON array in ${modelType}'s response` ); } const jsonText = responseText.substring(jsonStart, jsonEnd + 1); updatedTasks = JSON.parse(jsonText); } catch (streamError) { if (streamingInterval) clearInterval(streamingInterval); // Process stream errors explicitly report(`Stream error: ${streamError.message}`, 'error'); // Check if this is an overload error let isOverload = false; // Check 1: SDK specific property if (streamError.type === 'overloaded_error') { isOverload = true; } // Check 2: Check nested error property else if (streamError.error?.type === 'overloaded_error') { isOverload = true; } // Check 3: Check status code else if ( streamError.status === 429 || streamError.status === 529 ) { isOverload = true; } // Check 4: Check message string else if ( streamError.message?.toLowerCase().includes('overloaded') ) { isOverload = true; } if (isOverload) { claudeOverloaded = true; report( 'Claude overloaded. Will attempt fallback model if available.', 'warn' ); // Let the loop continue to try the next model throw new Error('Claude overloaded'); } else { // Re-throw non-overload errors throw streamError; } } } // If we got here successfully, break out of the loop if (updatedTasks) { report( `Successfully updated tasks using ${modelType} on attempt ${modelAttempts}`, 'success' ); break; } } catch (modelError) { const failedModel = modelType || 'unknown model'; report( `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`, 'warn' ); // Continue to next attempt if we have more attempts and this was an overload error const wasOverload = modelError.message ?.toLowerCase() .includes('overload'); if (wasOverload && !isLastAttempt) { if (modelType === 'claude') { claudeOverloaded = true; report('Will attempt with Perplexity AI next', 'info'); } continue; // Continue to next attempt } else if (isLastAttempt) { report( `Final attempt (${modelAttempts}/${maxModelAttempts}) failed. No fallback possible.`, 'error' ); throw modelError; // Re-throw on last attempt } else { throw modelError; // Re-throw for non-overload errors } } } // If we don't have updated tasks after all attempts, throw an error if (!updatedTasks) { throw new Error( 'Failed to generate updated tasks after all model attempts' ); } // Replace the tasks in the original data updatedTasks.forEach((updatedTask) => { const index = data.tasks.findIndex((t) => t.id === updatedTask.id); if (index !== -1) { data.tasks[index] = updatedTask; } }); // Write the updated tasks to the file writeJSON(tasksPath, data); report(`Successfully updated ${updatedTasks.length} tasks`, 'success'); // Generate individual task files await generateTaskFiles(tasksPath, path.dirname(tasksPath)); // Only show success box for text output (CLI) if (outputFormat === 'text') { console.log( boxen( chalk.green(`Successfully updated ${updatedTasks.length} tasks`), { padding: 1, borderColor: 'green', borderStyle: 'round' } ) ); } } finally { // Stop the loading indicator if it was created if (loadingIndicator) { stopLoadingIndicator(loadingIndicator); loadingIndicator = null; } } } catch (error) { report(`Error updating tasks: ${error.message}`, 'error'); // Only show error box for text output (CLI) if (outputFormat === 'text') { console.error(chalk.red(`Error: ${error.message}`)); // Provide helpful error messages based on error type if (error.message?.includes('ANTHROPIC_API_KEY')) { console.log( chalk.yellow('\nTo fix this issue, set your Anthropic API key:') ); console.log(' export ANTHROPIC_API_KEY=your_api_key_here'); } else if (error.message?.includes('PERPLEXITY_API_KEY') && useResearch) { console.log(chalk.yellow('\nTo fix this issue:')); console.log( ' 1. Set your Perplexity API key: export PERPLEXITY_API_KEY=your_api_key_here' ); console.log( ' 2. Or run without the research flag: task-master update --from=<id> --prompt="..."' ); } else if (error.message?.includes('overloaded')) { console.log( chalk.yellow( '\nAI model overloaded, and fallback failed or was unavailable:' ) ); console.log(' 1. Try again in a few minutes.'); console.log(' 2. Ensure PERPLEXITY_API_KEY is set for fallback.'); } if (CONFIG.debug) { console.error(error); } // Don't exit during tests, let Jest handle failures if (process.env.NODE_ENV !== 'test') { process.exit(1); } } else { throw error; // Re-throw for JSON output } } } /** * Update a single task by ID * @param {string} tasksPath - Path to the tasks.json file * @param {number} taskId - Task ID to update * @param {string} prompt - Prompt with new context * @param {boolean} useResearch - Whether to use Perplexity AI for research * @param {function} reportProgress - Function to report progress to MCP server (optional) * @param {Object} mcpLog - MCP logger object (optional) * @param {Object} session - Session object from MCP server (optional) * @returns {Object} - Updated task data or null if task wasn't updated */ async function updateTaskById( tasksPath, taskId, prompt, useResearch = false, { reportProgress, mcpLog, session } = {} ) { // Determine output format based on mcpLog presence (simplification) const outputFormat = mcpLog ? 'json' : 'text'; // Create custom reporter that checks for MCP log and silent mode const report = (message, level = 'info') => { if (mcpLog) { mcpLog[level](message); } else if (!isSilentMode() && outputFormat === 'text') { // Only log to console if not in silent mode and outputFormat is 'text' log(level, message); } }; try { report(`Updating single task ${taskId} with prompt: "${prompt}"`, 'info'); // Validate task ID is a positive integer if (!Number.isInteger(taskId) || taskId <= 0) { throw new Error( `Invalid task ID: ${taskId}. Task ID must be a positive integer.` ); } // Validate prompt if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { throw new Error( 'Prompt cannot be empty. Please provide context for the task update.' ); } // Validate research flag if ( useResearch && (!perplexity || !process.env.PERPLEXITY_API_KEY || session?.env?.PERPLEXITY_API_KEY) ) { report( 'Perplexity AI is not available. Falling back to Claude AI.', 'warn' ); // Only show UI elements for text output (CLI) if (outputFormat === 'text') { console.log( chalk.yellow( 'Perplexity AI is not available (API key may be missing). Falling back to Claude AI.' ) ); } useResearch = false; } // Validate tasks file exists if (!fs.existsSync(tasksPath)) { throw new Error(`Tasks file not found at path: ${tasksPath}`); } // Read the tasks file const data = readJSON(tasksPath); if (!data || !data.tasks) { throw new Error( `No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.` ); } // Find the specific task to update const taskToUpdate = data.tasks.find((task) => task.id === taskId); if (!taskToUpdate) { throw new Error( `Task with ID ${taskId} not found. Please verify the task ID and try again.` ); } // Check if task is already completed if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') { report( `Task ${taskId} is already marked as done and cannot be updated`, 'warn' ); // Only show warning box for text output (CLI) if (outputFormat === 'text') { console.log( boxen( chalk.yellow( `Task ${taskId} is already marked as ${taskToUpdate.status} and cannot be updated.` ) + '\n\n' + chalk.white( 'Completed tasks are locked to maintain consistency. To modify a completed task, you must first:' ) + '\n' + chalk.white( '1. Change its status to "pending" or "in-progress"' ) + '\n' + chalk.white('2. Then run the update-task command'), { padding: 1, borderColor: 'yellow', borderStyle: 'round' } ) ); } return null; } // Only show UI elements for text output (CLI) if (outputFormat === 'text') { // Show the task that will be updated const table = new Table({ head: [ chalk.cyan.bold('ID'), chalk.cyan.bold('Title'), chalk.cyan.bold('Status') ], colWidths: [5, 60, 10] }); table.push([ taskToUpdate.id, truncate(taskToUpdate.title, 57), getStatusWithColor(taskToUpdate.status) ]); console.log( boxen(chalk.white.bold(`Updating Task #${taskId}`), { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } }) ); console.log(table.toString()); // Display a message about how completed subtasks are handled console.log( boxen( chalk.cyan.bold('How Completed Subtasks Are Handled:') + '\n\n' + chalk.white( '• Subtasks marked as "done" or "completed" will be preserved\n' ) + chalk.white( '• New subtasks will build upon what has already been completed\n' ) + chalk.white( '• If completed work needs revision, a new subtask will be created instead of modifying done items\n' ) + chalk.white( '• This approach maintains a clear record of completed work and new requirements' ), { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } ) ); } // Build the system prompt const systemPrompt = `You are an AI assistant helping to update a software development task based on new context. You will be given a task and a prompt describing changes or new implementation details. Your job is to update the task to reflect these changes, while preserving its basic structure. Guidelines: 1. VERY IMPORTANT: NEVER change the title of the task - keep it exactly as is 2. Maintain the same ID, status, and dependencies unless specifically mentioned in the prompt 3. Update the description, details, and test strategy to reflect the new information 4. Do not change anything unnecessarily - just adapt what needs to change based on the prompt 5. Return a complete valid JSON object representing the updated task 6. VERY IMPORTANT: Preserve all subtasks marked as "done" or "completed" - do not modify their content 7. For tasks with completed subtasks, build upon what has already been done rather than rewriting everything 8. If an existing completed subtask needs to be changed/undone based on the new context, DO NOT modify it directly 9. Instead, add a new subtask that clearly indicates what needs to be changed or replaced 10. Use the existence of completed subtasks as an opportunity to make new subtasks more specific and targeted 11. Ensure any new subtasks have unique IDs that don't conflict with existing ones The changes described in the prompt should be thoughtfully applied to make the task more accurate and actionable.`; const taskData = JSON.stringify(taskToUpdate, null, 2); // Initialize variables for model selection and fallback let updatedTask; let loadingIndicator = null; let claudeOverloaded = false; let modelAttempts = 0; const maxModelAttempts = 2; // Try up to 2 models before giving up // Only create initial loading indicator for text output (CLI) if (outputFormat === 'text') { loadingIndicator = startLoadingIndicator( useResearch ? 'Updating task with Perplexity AI research...' : 'Updating task with Claude AI...' ); } try { // Import the getAvailableAIModel function const { getAvailableAIModel } = await import('./ai-services.js'); // Try different models with fallback while (modelAttempts < maxModelAttempts && !updatedTask) { modelAttempts++; const isLastAttempt = modelAttempts >= maxModelAttempts; let modelType = null; try { // Get the appropriate model based on current state const result = getAvailableAIModel({ claudeOverloaded, requiresResearch: useResearch }); modelType = result.type; const client = result.client; report( `Attempt ${modelAttempts}/${maxModelAttempts}: Updating task using ${modelType}`, 'info' ); // Update loading indicator - only for text output if (outputFormat === 'text') { if (loadingIndicator) { stopLoadingIndicator(loadingIndicator); } loadingIndicator = startLoadingIndicator( `Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...` ); } if (modelType === 'perplexity') { // Call Perplexity AI const perplexityModel = process.env.PERPLEXITY_MODEL || session?.env?.PERPLEXITY_MODEL || 'sonar-pro'; const result = await client.chat.completions.create({ model: perplexityModel, messages: [ { role: 'system', content: `${systemPrompt}\n\nAdditionally, please research the latest best practices, implementation details, and considerations when updating this task. Use your online search capabilities to gather relevant information. Remember to strictly follow the guidelines about preserving completed subtasks and building upon what has already been done rather than modifying or replacing it.` }, { role: 'user', content: `Here is the task to update: ${taskData} Please update this task based on the following new context: ${prompt} IMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items. Return only the updated task as a valid JSON object.` } ], temperature: parseFloat( process.env.TEMPERATURE || session?.env?.TEMPERATURE || CONFIG.temperature ), max_tokens: parseInt( process.env.MAX_TOKENS || session?.env?.MAX_TOKENS || CONFIG.maxTokens ) }); const responseText = result.choices[0].message.content; // Extract JSON from response const jsonStart = responseText.indexOf('{'); const jsonEnd = responseText.lastIndexOf('}'); if (jsonStart === -1 || jsonEnd === -1) { throw new Error( `Could not find valid JSON object in ${modelType}'s response. The response may be malformed.` ); } const jsonText = responseText.substring(jsonStart, jsonEnd + 1); try { updatedTask = JSON.parse(jsonText); } catch (parseError) { throw new Error( `Failed to parse ${modelType} response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...` ); } } else { // Call Claude to update the task with streaming let responseText = ''; let streamingInterval = null; try { // Update loading indicator to show streaming progress - only for text output if (outputFormat === 'text') { let dotCount = 0; const readline = await import('readline'); streamingInterval = setInterval(() => { readline.cursorTo(process.stdout, 0); process.stdout.write( `Receiving streaming response from Claude${'.'.repeat(dotCount)}` ); dotCount = (dotCount + 1) % 4; }, 500); } // Use streaming API call const stream = await client.messages.create({ model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, temperature: session?.env?.TEMPERATURE || CONFIG.temperature, system: systemPrompt, messages: [ { role: 'user', content: `Here is the task to update: ${taskData} Please update this task based on the following new context: ${prompt} IMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items. Return only the updated task as a valid JSON object.` } ], stream: true }); // Process the stream for await (const chunk of stream) { if (chunk.type === 'content_block_delta' && chunk.delta.text) { responseText += chunk.delta.text; } if (reportProgress) { await reportProgress({ progress: (responseText.length / CONFIG.maxTokens) * 100 }); } if (mcpLog) { mcpLog.info( `Progress: ${(responseText.length / CONFIG.maxTokens) * 100}%` ); } } if (streamingInterval) clearInterval(streamingInterval); report( `Completed streaming response from ${modelType} API (Attempt ${modelAttempts})`, 'info' ); // Extract JSON from response const jsonStart = responseText.indexOf('{'); const jsonEnd = responseText.lastIndexOf('}'); if (jsonStart === -1 || jsonEnd === -1) { throw new Error( `Could not find valid JSON object in ${modelType}'s response. The response may be malformed.` ); } const jsonText = responseText.substring(jsonStart, jsonEnd + 1); try { updatedTask = JSON.parse(jsonText); } catch (parseError) { throw new Error( `Failed to parse ${modelType} response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...` ); } } catch (streamError) { if (streamingInterval) clearInterval(streamingInterval); // Process stream errors explicitly report(`Stream error: ${streamError.message}`, 'error'); // Check if this is an overload error let isOverload = false; // Check 1: SDK specific property if (streamError.type === 'overloaded_error') { isOverload = true; } // Check 2: Check nested error property else if (streamError.error?.type === 'overloaded_error') { isOverload = true; } // Check 3: Check status code else if ( streamError.status === 429 || streamError.status === 529 ) { isOverload = true; } // Check 4: Check message string else if ( streamError.message?.toLowerCase().includes('overloaded') ) { isOverload = true; } if (isOverload) { claudeOverloaded = true; report( 'Claude overloaded. Will attempt fallback model if available.', 'warn' ); // Let the loop continue to try the next model throw new Error('Claude overloaded'); } else { // Re-throw non-overload errors throw streamError; } } } // If we got here successfully, break out of the loop if (updatedTask) { report( `Successfully updated task using ${modelType} on attempt ${modelAttempts}`, 'success' ); break; } } catch (modelError) { const failedModel = modelType || 'unknown model'; report( `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`, 'warn' ); // Continue to next attempt if we have more attempts and this was an overload error const wasOverload = modelError.message ?.toLowerCase() .includes('overload'); if (wasOverload && !isLastAttempt) { if (modelType === 'claude') { claudeOverloaded = true; report('Will attempt with Perplexity AI next', 'info'); } continue; // Continue to next attempt } else if (isLastAttempt) { report( `Final attempt (${modelAttempts}/${maxModelAttempts}) failed. No fallback possible.`, 'error' ); throw modelError; // Re-throw on last attempt } else { throw modelError; // Re-throw for non-overload errors } } } // If we don't have updated task after all attempts, throw an error if (!updatedTask) { throw new Error( 'Failed to generate updated task after all model attempts' ); } // Validation of the updated task if (!updatedTask || typeof updatedTask !== 'object') { throw new Error( 'Received invalid task object from AI. The response did not contain a valid task.' ); } // Ensure critical fields exist if (!updatedTask.title || !updatedTask.description) { throw new Error( 'Updated task is missing required fields (title or description).' ); } // Ensure ID is preserved if (updatedTask.id !== taskId) { report( `Task ID was modified in the AI response. Restoring original ID ${taskId}.`, 'warn' ); updatedTask.id = taskId; } // Ensure status is preserved unless explicitly changed in prompt if ( updatedTask.status !== taskToUpdate.status && !prompt.toLowerCase().includes('status') ) { report( `Task status was modified without explicit instruction. Restoring original status '${taskToUpdate.status}'.`, 'warn' ); updatedTask.status = taskToUpdate.status; } // Ensure completed subtasks are preserved if (taskToUpdate.subtasks && taskToUpdate.subtasks.length > 0) { if (!updatedTask.subtasks) { report( 'Subtasks were removed in the AI response. Restoring original subtasks.', 'warn' ); updatedTask.subtasks = taskToUpdate.subtasks; } else { // Check for each completed subtask const completedSubtasks = taskToUpdate.subtasks.filter( (st) => st.status === 'done' || st.status === 'completed' ); for (const completedSubtask of completedSubtasks) { const updatedSubtask = updatedTask.subtasks.find( (st) => st.id === completedSubtask.id ); // If completed subtask is missing or modified, restore it if (!updatedSubtask) { report( `Completed subtask ${completedSubtask.id} was removed. Restoring it.`, 'warn' ); updatedTask.subtasks.push(completedSubtask); } else if ( updatedSubtask.title !== completedSubtask.title || updatedSubtask.description !== completedSubtask.description || updatedSubtask.details !== completedSubtask.details || updatedSubtask.status !== completedSubtask.status ) { report( `Completed subtask ${completedSubtask.id} was modified. Restoring original.`, 'warn' ); // Find and replace the modified subtask const index = updatedTask.subtasks.findIndex( (st) => st.id === completedSubtask.id ); if (index !== -1) { updatedTask.subtasks[index] = completedSubtask; } } } // Ensure no duplicate subtask IDs const subtaskIds = new Set(); const uniqueSubtasks = []; for (const subtask of updatedTask.subtasks) { if (!subtaskIds.has(subtask.id)) { subtaskIds.add(subtask.id); uniqueSubtasks.push(subtask); } else { report( `Duplicate subtask ID ${subtask.id} found. Removing duplicate.`, 'warn' ); } } updatedTask.subtasks = uniqueSubtasks; } } // Update the task in the original data const index = data.tasks.findIndex((t) => t.id === taskId); if (index !== -1) { data.tasks[index] = updatedTask; } else { throw new Error(`Task with ID ${taskId} not found in tasks array.`); } // Write the updated tasks to the file writeJSON(tasksPath, data); report(`Successfully updated task ${taskId}`, 'success'); // Generate individual task files await generateTaskFiles(tasksPath, path.dirname(tasksPath)); // Only show success box for text output (CLI) if (outputFormat === 'text') { console.log( boxen( chalk.green(`Successfully updated task #${taskId}`) + '\n\n' + chalk.white.bold('Updated Title:') + ' ' + updatedTask.title, { padding: 1, borderColor: 'green', borderStyle: 'round' } ) ); } // Return the updated task for testing purposes return updatedTask; } finally { // Stop the loading indicator if it was created if (loadingIndicator) { stopLoadingIndicator(loadingIndicator); loadingIndicator = null; } } } catch (error) { report(`Error updating task: ${error.message}`, 'error'); // Only show error UI for text output (CLI) if (outputFormat === 'text') { console.error(chalk.red(`Error: ${error.message}`)); // Provide more helpful error messages for common issues if (error.message.includes('ANTHROPIC_API_KEY')) { console.log( chalk.yellow('\nTo fix this issue, set your Anthropic API key:') ); console.log(' export ANTHROPIC_API_KEY=your_api_key_here'); } else if (error.message.includes('PERPLEXITY_API_KEY')) { console.log(chalk.yellow('\nTo fix this issue:')); console.log( ' 1. Set your Perplexity API key: export PERPLEXITY_API_KEY=your_api_key_here' ); console.log( ' 2. Or run without the research flag: task-master update-task --id=<id> --prompt="..."' ); } else if ( error.message.includes('Task with ID') && error.message.includes('not found') ) { console.log(chalk.yellow('\nTo fix this issue:')); console.log(' 1. Run task-master list to see all available task IDs'); console.log(' 2. Use a valid task ID with the --id parameter'); } if (CONFIG.debug) { console.error(error); } // Don't exit during tests, let Jest handle failures if (process.env.NODE_ENV !== 'test') { process.exit(1); } } else { throw error; // Re-throw for JSON output } return null; } } /** * Generate individual task files from tasks.json * @param {string} tasksPath - Path to the tasks.json file * @param {string} outputDir - Output directory for task files * @param {Object} options - Additional options (mcpLog for MCP mode) * @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode */ function generateTaskFiles(tasksPath, outputDir, options = {}) { try { // Determine if we're in MCP mode by checking for mcpLog const isMcpMode = !!options?.mcpLog; log('info', `Reading tasks from ${tasksPath}...`); const data = readJSON(tasksPath); if (!data || !data.tasks) { throw new Error(`No valid tasks found in ${tasksPath}`); } // Create the output directory if it doesn't exist if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } log('info', `Found ${data.tasks.length} tasks to generate files for.`); // Validate and fix dependencies before generating files log( 'info', `Validating and fixing dependencies before generating files...` ); validateAndFixDependencies(data, tasksPath); // Generate task files log('info', 'Generating individual task files...'); data.tasks.forEach((task) => { const taskPath = path.join( outputDir, `task_${task.id.toString().padStart(3, '0')}.txt` ); // Format the content let content = `# Task ID: ${task.id}\n`; content += `# Title: ${task.title}\n`; content += `# Status: ${task.status || 'pending'}\n`; // Format dependencies with their status if (task.dependencies && task.dependencies.length > 0) { content += `# Dependencies: ${formatDependenciesWithStatus(task.dependencies, data.tasks, false)}\n`; } else { content += '# Dependencies: None\n'; } content += `# Priority: ${task.priority || 'medium'}\n`; content += `# Description: ${task.description || ''}\n`; // Add more detailed sections content += '# Details:\n'; content += (task.details || '') .split('\n') .map((line) => line) .join('\n'); content += '\n\n'; content += '# Test Strategy:\n'; content += (task.testStrategy || '') .split('\n') .map((line) => line) .join('\n'); content += '\n'; // Add subtasks if they exist if (task.subtasks && task.subtasks.length > 0) { content += '\n# Subtasks:\n'; task.subtasks.forEach((subtask) => { content += `## ${subtask.id}. ${subtask.title} [${subtask.status || 'pending'}]\n`; if (subtask.dependencies && subtask.dependencies.length > 0) { // Format subtask dependencies let subtaskDeps = subtask.dependencies .map((depId) => { if (typeof depId === 'number') { // Handle numeric dependencies to other subtasks const foundSubtask = task.subtasks.find( (st) => st.id === depId ); if (foundSubtask) { // Just return the plain ID format without any color formatting return `${task.id}.${depId}`; } } return depId.toString(); }) .join(', '); content += `### Dependencies: ${subtaskDeps}\n`; } else { content += '### Dependencies: None\n'; } content += `### Description: ${subtask.description || ''}\n`; content += '### Details:\n'; content += (subtask.details || '') .split('\n') .map((line) => line) .join('\n'); content += '\n\n'; }); } // Write the file fs.writeFileSync(taskPath, content); log('info', `Generated: task_${task.id.toString().padStart(3, '0')}.txt`); }); log( 'success', `All ${data.tasks.length} tasks have been generated into '${outputDir}'.` ); // Return success data in MCP mode if (isMcpMode) { return { success: true, count: data.tasks.length, directory: outputDir }; } } catch (error) { log('error', `Error generating task files: ${error.message}`); // Only show error UI in CLI mode if (!options?.mcpLog) { console.error(chalk.red(`Error generating task files: ${error.message}`)); if (CONFIG.debug) { console.error(error); } // Don't exit during tests, let Jest handle failures if (process.env.NODE_ENV !== 'test') { process.exit(1); } } else { // In MCP mode, throw the error for the caller to handle throw error; } } } /** * Set the status of a task * @param {string} tasksPath - Path to the tasks.json file * @param {string} taskIdInput - Task ID(s) to update * @param {string} newStatus - New status * @param {Object} options - Additional options (mcpLog for MCP mode) * @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode */ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) { try { // Determine if we're in MCP mode by checking for mcpLog const isMcpMode = !!options?.mcpLog; // Only display UI elements if not in MCP mode if (!isMcpMode) { displayBanner(); console.log( boxen(chalk.white.bold(`Updating Task Status to: ${newStatus}`), { padding: 1, borderColor: 'blue', borderStyle: 'round' }) ); } log('info', `Reading tasks from ${tasksPath}...`); const data = readJSON(tasksPath); if (!data || !data.tasks) { throw new Error(`No valid tasks found in ${tasksPath}`); } // Handle multiple task IDs (comma-separated) const taskIds = taskIdInput.split(',').map((id) => id.trim()); const updatedTasks = []; // Update each task for (const id of taskIds) { await updateSingleTaskStatus(tasksPath, id, newStatus, data, !isMcpMode); updatedTasks.push(id); } // Write the updated tasks to the file writeJSON(tasksPath, data); // Validate dependencies after status update log('info', 'Validating dependencies after status update...'); validateTaskDependencies(data.tasks); // Generate individual task files log('info', 'Regenerating task files...'); await generateTaskFiles(tasksPath, path.dirname(tasksPath), { mcpLog: options.mcpLog }); // Display success message - only in CLI mode if (!isMcpMode) { for (const id of updatedTasks) { const task = findTaskById(data.tasks, id); const taskName = task ? task.title : id; console.log( boxen( chalk.white.bold(`Successfully updated task ${id} status:`) + '\n' + `From: ${chalk.yellow(task ? task.status : 'unknown')}\n` + `To: ${chalk.green(newStatus)}`, { padding: 1, borderColor: 'green', borderStyle: 'round' } ) ); } } // Return success value for programmatic use return { success: true, updatedTasks: updatedTasks.map((id) => ({ id, status: newStatus })) }; } catch (error) { log('error', `Error setting task status: ${error.message}`); // Only show error UI in CLI mode if (!options?.mcpLog) { console.error(chalk.red(`Error: ${error.message}`)); if (CONFIG.debug) { console.error(error); } // Don't exit during tests, let Jest handle failures if (process.env.NODE_ENV !== 'test') { process.exit(1); } } else { // In MCP mode, throw the error for the caller to handle throw error; } } } /** * Update the status of a single task * @param {string} tasksPath - Path to the tasks.json file * @param {string} taskIdInput - Task ID to update * @param {string} newStatus - New status * @param {Object} data - Tasks data * @param {boolean} showUi - Whether to show UI elements */ async function updateSingleTaskStatus( tasksPath, taskIdInput, newStatus, data, showUi = true ) { // Check if it's a subtask (e.g., "1.2") if (taskIdInpu