Ralph Khreish 444aa5ae19 Batch fixes before release (#1011)
* fix: improve projectRoot

* fix: improve task-master lang command

* feat: add documentation to the readme so more people can access it

* fix: expand command subtask dependency validation

* fix: update command more reliable with perplexity and other models

* chore: fix CI

* chore: implement requested changes

* chore: fix CI
2025-07-20 00:51:41 +03:00

586 lines
18 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
} 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
// Preprocessing schema that adds defaults before validation
const preprocessTaskSchema = z.preprocess((task) => {
// Ensure task is an object
if (typeof task !== 'object' || task === null) {
return {};
}
// Return task with defaults for missing fields
return {
...task,
// Add defaults for required fields if missing
id: task.id ?? 0,
title: task.title ?? 'Untitled Task',
description: task.description ?? '',
status: task.status ?? 'pending',
dependencies: Array.isArray(task.dependencies) ? task.dependencies : [],
// Optional fields - preserve undefined/null distinction
priority: task.hasOwnProperty('priority') ? task.priority : null,
details: task.hasOwnProperty('details') ? task.details : null,
testStrategy: task.hasOwnProperty('testStrategy')
? task.testStrategy
: null,
subtasks: Array.isArray(task.subtasks)
? task.subtasks
: task.subtasks === null
? null
: []
};
}, updatedTaskSchema);
const updatedTaskArraySchema = z.array(updatedTaskSchema);
const preprocessedTaskArraySchema = z.array(preprocessTaskSchema);
/**
* 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}.`
);
}
// Log missing fields for debugging before preprocessing
let hasWarnings = false;
parsedTasks.forEach((task, index) => {
const missingFields = [];
if (!task.hasOwnProperty('id')) missingFields.push('id');
if (!task.hasOwnProperty('status')) missingFields.push('status');
if (!task.hasOwnProperty('dependencies'))
missingFields.push('dependencies');
if (missingFields.length > 0) {
hasWarnings = true;
report(
'warn',
`Task ${index} is missing fields: ${missingFields.join(', ')} - will use defaults`
);
}
});
if (hasWarnings) {
report(
'warn',
'Some tasks were missing required fields. Applying defaults...'
);
}
// Use the preprocessing schema to add defaults and validate
const preprocessResult = preprocessedTaskArraySchema.safeParse(parsedTasks);
if (!preprocessResult.success) {
// This should rarely happen now since preprocessing adds defaults
report('error', 'Failed to validate task array even after preprocessing.');
preprocessResult.error.errors.forEach((err) => {
report('error', ` - Path '${err.path.join('.')}': ${err.message}`);
});
throw new Error(
`AI response failed validation: ${preprocessResult.error.message}`
);
}
report('info', 'Successfully validated and transformed task structure.');
return preprocessResult.data.slice(
0,
expectedCount || preprocessResult.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} [context.tag] - Tag for the task
* @param {string} [outputFormat='text'] - Output format ('text' or 'json').
*/
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');
}
// --- Task Loading/Filtering (Updated to pass projectRoot and tag) ---
const data = readJSON(tasksPath, projectRoot, tag);
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, tag);
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
const updatedTask = updatedTasksMap.get(task.id);
// Merge the updated task with the existing one to preserve fields like subtasks
data.tasks[index] = {
...task, // Keep all existing fields
...updatedTask, // Override with updated fields
// Ensure subtasks field is preserved if not provided by AI
subtasks:
updatedTask.subtasks !== undefined
? updatedTask.subtasks
: task.subtasks
};
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, tag);
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;