mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-11-09 22:37:54 +00:00
* fix: resolve path resolution issues in parse-prd and analyze-complexity commands This commit fixes critical path resolution regressions where commands were requiring files they create to already exist. ## Changes Made: ### 1. parse-prd Command (Lines 808, 828-835, 919-921) **Problem**: Command required tasks.json to exist before it could create it (catch-22) **Root Cause**: Default value in option definition meant options.output was always set **Fixes**: - Removed default value from --output option definition (line 808) - Modified initTaskMaster to only include tasksPath when explicitly specified - Added null handling for output path with fallback to default location ### 2. analyze-complexity Command (Lines 1637-1640, 1673-1680, 1695-1696) **Problem**: Command required complexity report file to exist before creating it **Root Cause**: Default value in option definition meant options.output was always set **Fixes**: - Removed default value from --output option definition (lines 1637-1640) - Modified initTaskMaster to only include complexityReportPath when explicitly specified - Added null handling for report path with fallback to default location ## Technical Details: The core issue was that Commander.js option definitions with default values always populate the options object, making conditional checks like `if (options.output)` always true. By removing default values from option definitions, we ensure paths are only included in initTaskMaster when users explicitly provide them. This approach is cleaner than using boolean flags (true/false) for required/optional, as it eliminates the path entirely when not needed, letting initTaskMaster use its default behavior. ## Testing: - parse-prd now works on fresh projects without existing tasks.json - analyze-complexity creates report file without requiring it to exist - Commands maintain backward compatibility when paths are explicitly provided Fixes issues reported in PATH-FIXES.md and extends the solution to other affected commands. * fix: update expand-task test to match context gathering fix The test was expecting gatheredContext to be a string, but the actual implementation returns an object with a context property. Updated the ContextGatherer mock to return the correct format and added missing FuzzyTaskSearch mock. --------- Co-authored-by: Ben Vargas <ben@example.com>
519 lines
16 KiB
JavaScript
519 lines
16 KiB
JavaScript
import path from 'path';
|
|
import chalk from 'chalk';
|
|
import boxen from 'boxen';
|
|
import Table from 'cli-table3';
|
|
import { z } from 'zod'; // Keep Zod for post-parsing validation
|
|
|
|
import {
|
|
log as consoleLog,
|
|
readJSON,
|
|
writeJSON,
|
|
truncate,
|
|
isSilentMode,
|
|
getCurrentTag
|
|
} from '../utils.js';
|
|
|
|
import {
|
|
getStatusWithColor,
|
|
startLoadingIndicator,
|
|
stopLoadingIndicator,
|
|
displayAiUsageSummary
|
|
} from '../ui.js';
|
|
|
|
import { getDebugFlag } from '../config-manager.js';
|
|
import { getPromptManager } from '../prompt-manager.js';
|
|
import generateTaskFiles from './generate-task-files.js';
|
|
import { generateTextService } from '../ai-services-unified.js';
|
|
import { getModelConfiguration } from './models.js';
|
|
import { ContextGatherer } from '../utils/contextGatherer.js';
|
|
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
|
|
import { flattenTasksWithSubtasks, findProjectRoot } from '../utils.js';
|
|
|
|
// Zod schema for validating the structure of tasks AFTER parsing
|
|
const updatedTaskSchema = z
|
|
.object({
|
|
id: z.number().int(),
|
|
title: z.string(),
|
|
description: z.string(),
|
|
status: z.string(),
|
|
dependencies: z.array(z.union([z.number().int(), z.string()])),
|
|
priority: z.string().nullable(),
|
|
details: z.string().nullable(),
|
|
testStrategy: z.string().nullable(),
|
|
subtasks: z.array(z.any()).nullable() // Keep subtasks flexible for now
|
|
})
|
|
.strip(); // Allow potential extra fields during parsing if needed, then validate structure
|
|
const updatedTaskArraySchema = z.array(updatedTaskSchema);
|
|
|
|
/**
|
|
* Parses an array of task objects from AI's text response.
|
|
* @param {string} text - Response text from AI.
|
|
* @param {number} expectedCount - Expected number of tasks.
|
|
* @param {Function | Object} logFn - The logging function or MCP log object.
|
|
* @param {boolean} isMCP - Flag indicating if logFn is MCP logger.
|
|
* @returns {Array} Parsed and validated tasks array.
|
|
* @throws {Error} If parsing or validation fails.
|
|
*/
|
|
function parseUpdatedTasksFromText(text, expectedCount, logFn, isMCP) {
|
|
const report = (level, ...args) => {
|
|
if (isMCP) {
|
|
if (typeof logFn[level] === 'function') logFn[level](...args);
|
|
else logFn.info(...args);
|
|
} else if (!isSilentMode()) {
|
|
// Check silent mode for consoleLog
|
|
consoleLog(level, ...args);
|
|
}
|
|
};
|
|
|
|
report(
|
|
'info',
|
|
'Attempting to parse updated tasks array from text response...'
|
|
);
|
|
if (!text || text.trim() === '')
|
|
throw new Error('AI response text is empty.');
|
|
|
|
let cleanedResponse = text.trim();
|
|
const originalResponseForDebug = cleanedResponse;
|
|
let parseMethodUsed = 'raw'; // Track which method worked
|
|
|
|
// --- NEW Step 1: Try extracting between [] first ---
|
|
const firstBracketIndex = cleanedResponse.indexOf('[');
|
|
const lastBracketIndex = cleanedResponse.lastIndexOf(']');
|
|
let potentialJsonFromArray = null;
|
|
|
|
if (firstBracketIndex !== -1 && lastBracketIndex > firstBracketIndex) {
|
|
potentialJsonFromArray = cleanedResponse.substring(
|
|
firstBracketIndex,
|
|
lastBracketIndex + 1
|
|
);
|
|
// Basic check to ensure it's not just "[]" or malformed
|
|
if (potentialJsonFromArray.length <= 2) {
|
|
potentialJsonFromArray = null; // Ignore empty array
|
|
}
|
|
}
|
|
|
|
// If [] extraction yielded something, try parsing it immediately
|
|
if (potentialJsonFromArray) {
|
|
try {
|
|
const testParse = JSON.parse(potentialJsonFromArray);
|
|
// It worked! Use this as the primary cleaned response.
|
|
cleanedResponse = potentialJsonFromArray;
|
|
parseMethodUsed = 'brackets';
|
|
} catch (e) {
|
|
report(
|
|
'info',
|
|
'Content between [] looked promising but failed initial parse. Proceeding to other methods.'
|
|
);
|
|
// Reset cleanedResponse to original if bracket parsing failed
|
|
cleanedResponse = originalResponseForDebug;
|
|
}
|
|
}
|
|
|
|
// --- Step 2: If bracket parsing didn't work or wasn't applicable, try code block extraction ---
|
|
if (parseMethodUsed === 'raw') {
|
|
// Only look for ```json blocks now
|
|
const codeBlockMatch = cleanedResponse.match(
|
|
/```json\s*([\s\S]*?)\s*```/i // Only match ```json
|
|
);
|
|
if (codeBlockMatch) {
|
|
cleanedResponse = codeBlockMatch[1].trim();
|
|
parseMethodUsed = 'codeblock';
|
|
report('info', 'Extracted JSON content from JSON Markdown code block.');
|
|
} else {
|
|
report('info', 'No JSON code block found.');
|
|
// --- Step 3: If code block failed, try stripping prefixes ---
|
|
const commonPrefixes = [
|
|
'json\n',
|
|
'javascript\n', // Keep checking common prefixes just in case
|
|
'python\n',
|
|
'here are the updated tasks:',
|
|
'here is the updated json:',
|
|
'updated tasks:',
|
|
'updated json:',
|
|
'response:',
|
|
'output:'
|
|
];
|
|
let prefixFound = false;
|
|
for (const prefix of commonPrefixes) {
|
|
if (cleanedResponse.toLowerCase().startsWith(prefix)) {
|
|
cleanedResponse = cleanedResponse.substring(prefix.length).trim();
|
|
parseMethodUsed = 'prefix';
|
|
report('info', `Stripped prefix: "${prefix.trim()}"`);
|
|
prefixFound = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!prefixFound) {
|
|
report(
|
|
'warn',
|
|
'Response does not appear to contain [], JSON code block, or known prefix. Attempting raw parse.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Step 4: Attempt final parse ---
|
|
let parsedTasks;
|
|
try {
|
|
parsedTasks = JSON.parse(cleanedResponse);
|
|
} catch (parseError) {
|
|
report('error', `Failed to parse JSON array: ${parseError.message}`);
|
|
report(
|
|
'error',
|
|
`Extraction method used: ${parseMethodUsed}` // Log which method failed
|
|
);
|
|
report(
|
|
'error',
|
|
`Problematic JSON string (first 500 chars): ${cleanedResponse.substring(0, 500)}`
|
|
);
|
|
report(
|
|
'error',
|
|
`Original Raw Response (first 500 chars): ${originalResponseForDebug.substring(0, 500)}`
|
|
);
|
|
throw new Error(
|
|
`Failed to parse JSON response array: ${parseError.message}`
|
|
);
|
|
}
|
|
|
|
// --- Step 5 & 6: Validate Array structure and Zod schema ---
|
|
if (!Array.isArray(parsedTasks)) {
|
|
report(
|
|
'error',
|
|
`Parsed content is not an array. Type: ${typeof parsedTasks}`
|
|
);
|
|
report(
|
|
'error',
|
|
`Parsed content sample: ${JSON.stringify(parsedTasks).substring(0, 200)}`
|
|
);
|
|
throw new Error('Parsed AI response is not a valid JSON array.');
|
|
}
|
|
|
|
report('info', `Successfully parsed ${parsedTasks.length} potential tasks.`);
|
|
if (expectedCount && parsedTasks.length !== expectedCount) {
|
|
report(
|
|
'warn',
|
|
`Expected ${expectedCount} tasks, but parsed ${parsedTasks.length}.`
|
|
);
|
|
}
|
|
|
|
const validationResult = updatedTaskArraySchema.safeParse(parsedTasks);
|
|
if (!validationResult.success) {
|
|
report('error', 'Parsed task array failed Zod validation.');
|
|
validationResult.error.errors.forEach((err) => {
|
|
report('error', ` - Path '${err.path.join('.')}': ${err.message}`);
|
|
});
|
|
throw new Error(
|
|
`AI response failed task structure validation: ${validationResult.error.message}`
|
|
);
|
|
}
|
|
|
|
report('info', 'Successfully validated task structure.');
|
|
return validationResult.data.slice(
|
|
0,
|
|
expectedCount || validationResult.data.length
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update tasks based on new context using the unified AI service.
|
|
* @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=false] - Whether to use the research AI role.
|
|
* @param {Object} context - Context object containing session and mcpLog.
|
|
* @param {Object} [context.session] - Session object from MCP server.
|
|
* @param {Object} [context.mcpLog] - MCP logger object.
|
|
* @param {string} [outputFormat='text'] - Output format ('text' or 'json').
|
|
* @param {string} [tag=null] - Tag associated with the tasks.
|
|
*/
|
|
async function updateTasks(
|
|
tasksPath,
|
|
fromId,
|
|
prompt,
|
|
useResearch = false,
|
|
context = {},
|
|
outputFormat = 'text' // Default to text for CLI
|
|
) {
|
|
const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context;
|
|
// Use mcpLog if available, otherwise use the imported consoleLog function
|
|
const logFn = mcpLog || consoleLog;
|
|
// Flag to easily check which logger type we have
|
|
const isMCP = !!mcpLog;
|
|
|
|
if (isMCP)
|
|
logFn.info(`updateTasks called with context: session=${!!session}`);
|
|
else logFn('info', `updateTasks called`); // CLI log
|
|
|
|
try {
|
|
if (isMCP) logFn.info(`Updating tasks from ID ${fromId}`);
|
|
else
|
|
logFn(
|
|
'info',
|
|
`Updating tasks from ID ${fromId} with prompt: "${prompt}"`
|
|
);
|
|
|
|
// Determine project root
|
|
const projectRoot = providedProjectRoot || findProjectRoot();
|
|
if (!projectRoot) {
|
|
throw new Error('Could not determine project root directory');
|
|
}
|
|
|
|
// Determine the current tag - prioritize explicit tag, then context.tag, then current tag
|
|
const currentTag = tag || getCurrentTag(projectRoot) || 'master';
|
|
|
|
// --- Task Loading/Filtering (Updated to pass projectRoot and tag) ---
|
|
const data = readJSON(tasksPath, projectRoot, currentTag);
|
|
if (!data || !data.tasks)
|
|
throw new Error(`No valid tasks found in ${tasksPath}`);
|
|
const tasksToUpdate = data.tasks.filter(
|
|
(task) => task.id >= fromId && task.status !== 'done'
|
|
);
|
|
if (tasksToUpdate.length === 0) {
|
|
if (isMCP)
|
|
logFn.info(`No tasks to update (ID >= ${fromId} and not 'done').`);
|
|
else
|
|
logFn('info', `No tasks to update (ID >= ${fromId} and not 'done').`);
|
|
if (outputFormat === 'text') console.log(/* yellow message */);
|
|
return; // Nothing to do
|
|
}
|
|
// --- End Task Loading/Filtering ---
|
|
|
|
// --- Context Gathering ---
|
|
let gatheredContext = '';
|
|
try {
|
|
const contextGatherer = new ContextGatherer(projectRoot);
|
|
const allTasksFlat = flattenTasksWithSubtasks(data.tasks);
|
|
const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update');
|
|
const searchResults = fuzzySearch.findRelevantTasks(prompt, {
|
|
maxResults: 5,
|
|
includeSelf: true
|
|
});
|
|
const relevantTaskIds = fuzzySearch.getTaskIds(searchResults);
|
|
|
|
const tasksToUpdateIds = tasksToUpdate.map((t) => t.id.toString());
|
|
const finalTaskIds = [
|
|
...new Set([...tasksToUpdateIds, ...relevantTaskIds])
|
|
];
|
|
|
|
if (finalTaskIds.length > 0) {
|
|
const contextResult = await contextGatherer.gather({
|
|
tasks: finalTaskIds,
|
|
format: 'research'
|
|
});
|
|
gatheredContext = contextResult.context || '';
|
|
}
|
|
} catch (contextError) {
|
|
logFn(
|
|
'warn',
|
|
`Could not gather additional context: ${contextError.message}`
|
|
);
|
|
}
|
|
// --- End Context Gathering ---
|
|
|
|
// --- Display Tasks to Update (CLI Only - Unchanged) ---
|
|
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, 70, 20]
|
|
});
|
|
|
|
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 }
|
|
}
|
|
)
|
|
);
|
|
}
|
|
// --- End Display Tasks ---
|
|
|
|
// --- Build Prompts (Using PromptManager) ---
|
|
// Load prompts using PromptManager
|
|
const promptManager = getPromptManager();
|
|
const { systemPrompt, userPrompt } = await promptManager.loadPrompt(
|
|
'update-tasks',
|
|
{
|
|
tasks: tasksToUpdate,
|
|
updatePrompt: prompt,
|
|
useResearch,
|
|
projectContext: gatheredContext
|
|
}
|
|
);
|
|
// --- End Build Prompts ---
|
|
|
|
// --- AI Call ---
|
|
let loadingIndicator = null;
|
|
let aiServiceResponse = null;
|
|
|
|
if (!isMCP && outputFormat === 'text') {
|
|
loadingIndicator = startLoadingIndicator('Updating tasks with AI...\n');
|
|
}
|
|
|
|
try {
|
|
// Determine role based on research flag
|
|
const serviceRole = useResearch ? 'research' : 'main';
|
|
|
|
// Call the unified AI service
|
|
aiServiceResponse = await generateTextService({
|
|
role: serviceRole,
|
|
session: session,
|
|
projectRoot: projectRoot,
|
|
systemPrompt: systemPrompt,
|
|
prompt: userPrompt,
|
|
commandName: 'update-tasks',
|
|
outputType: isMCP ? 'mcp' : 'cli'
|
|
});
|
|
|
|
if (loadingIndicator)
|
|
stopLoadingIndicator(loadingIndicator, 'AI update complete.');
|
|
|
|
// Use the mainResult (text) for parsing
|
|
const parsedUpdatedTasks = parseUpdatedTasksFromText(
|
|
aiServiceResponse.mainResult,
|
|
tasksToUpdate.length,
|
|
logFn,
|
|
isMCP
|
|
);
|
|
|
|
// --- Update Tasks Data (Updated writeJSON call) ---
|
|
if (!Array.isArray(parsedUpdatedTasks)) {
|
|
// Should be caught by parser, but extra check
|
|
throw new Error(
|
|
'Parsed AI response for updated tasks was not an array.'
|
|
);
|
|
}
|
|
if (isMCP)
|
|
logFn.info(
|
|
`Received ${parsedUpdatedTasks.length} updated tasks from AI.`
|
|
);
|
|
else
|
|
logFn(
|
|
'info',
|
|
`Received ${parsedUpdatedTasks.length} updated tasks from AI.`
|
|
);
|
|
// Create a map for efficient lookup
|
|
const updatedTasksMap = new Map(
|
|
parsedUpdatedTasks.map((task) => [task.id, task])
|
|
);
|
|
|
|
let actualUpdateCount = 0;
|
|
data.tasks.forEach((task, index) => {
|
|
if (updatedTasksMap.has(task.id)) {
|
|
// Only update if the task was part of the set sent to AI
|
|
data.tasks[index] = updatedTasksMap.get(task.id);
|
|
actualUpdateCount++;
|
|
}
|
|
});
|
|
if (isMCP)
|
|
logFn.info(
|
|
`Applied updates to ${actualUpdateCount} tasks in the dataset.`
|
|
);
|
|
else
|
|
logFn(
|
|
'info',
|
|
`Applied updates to ${actualUpdateCount} tasks in the dataset.`
|
|
);
|
|
|
|
// Fix: Pass projectRoot and currentTag to writeJSON
|
|
writeJSON(tasksPath, data, projectRoot, currentTag);
|
|
if (isMCP)
|
|
logFn.info(
|
|
`Successfully updated ${actualUpdateCount} tasks in ${tasksPath}`
|
|
);
|
|
else
|
|
logFn(
|
|
'success',
|
|
`Successfully updated ${actualUpdateCount} tasks in ${tasksPath}`
|
|
);
|
|
// await generateTaskFiles(tasksPath, path.dirname(tasksPath));
|
|
|
|
if (outputFormat === 'text' && aiServiceResponse.telemetryData) {
|
|
displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
updatedTasks: parsedUpdatedTasks,
|
|
telemetryData: aiServiceResponse.telemetryData,
|
|
tagInfo: aiServiceResponse.tagInfo
|
|
};
|
|
} catch (error) {
|
|
if (loadingIndicator) stopLoadingIndicator(loadingIndicator);
|
|
if (isMCP) logFn.error(`Error during AI service call: ${error.message}`);
|
|
else logFn('error', `Error during AI service call: ${error.message}`);
|
|
if (error.message.includes('API key')) {
|
|
if (isMCP)
|
|
logFn.error(
|
|
'Please ensure API keys are configured correctly in .env or mcp.json.'
|
|
);
|
|
else
|
|
logFn(
|
|
'error',
|
|
'Please ensure API keys are configured correctly in .env or mcp.json.'
|
|
);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (loadingIndicator) stopLoadingIndicator(loadingIndicator);
|
|
}
|
|
} catch (error) {
|
|
// --- General Error Handling (Unchanged) ---
|
|
if (isMCP) logFn.error(`Error updating tasks: ${error.message}`);
|
|
else logFn('error', `Error updating tasks: ${error.message}`);
|
|
if (outputFormat === 'text') {
|
|
console.error(chalk.red(`Error: ${error.message}`));
|
|
if (getDebugFlag(session)) {
|
|
console.error(error);
|
|
}
|
|
process.exit(1);
|
|
} else {
|
|
throw error; // Re-throw for MCP/programmatic callers
|
|
}
|
|
// --- End General Error Handling ---
|
|
}
|
|
}
|
|
|
|
export default updateTasks;
|