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

613 lines
20 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import {
log,
readJSON,
writeJSON,
isSilentMode,
getTagAwareFilePath
} from '../utils.js';
import {
startLoadingIndicator,
stopLoadingIndicator,
displayAiUsageSummary
} from '../ui.js';
import { generateTextService } from '../ai-services-unified.js';
import { getDefaultSubtasks, getDebugFlag } from '../config-manager.js';
import { getPromptManager } from '../prompt-manager.js';
import generateTaskFiles from './generate-task-files.js';
import { COMPLEXITY_REPORT_FILE } from '../../../src/constants/paths.js';
import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
import { flattenTasksWithSubtasks, findProjectRoot } from '../utils.js';
// --- Zod Schemas (Keep from previous step) ---
const subtaskSchema = z
.object({
id: z
.number()
.int()
.positive()
.describe('Sequential subtask ID starting from 1'),
title: z.string().min(5).describe('Clear, specific title for the subtask'),
description: z
.string()
.min(10)
.describe('Detailed description of the subtask'),
dependencies: z
.array(z.string())
.describe(
'Array of subtask dependencies within the same parent task. Use format ["parentTaskId.1", "parentTaskId.2"]. Subtasks can only depend on siblings, not external tasks.'
),
details: z.string().min(20).describe('Implementation details and guidance'),
status: z
.string()
.describe(
'The current status of the subtask (should be pending initially)'
),
testStrategy: z
.string()
.nullable()
.describe('Approach for testing this subtask')
.default('')
})
.strict();
const subtaskArraySchema = z.array(subtaskSchema);
const subtaskWrapperSchema = z.object({
subtasks: subtaskArraySchema.describe('The array of generated subtasks.')
});
// --- End Zod Schemas ---
/**
* Parse subtasks from AI's text response. Includes basic cleanup.
* @param {string} text - Response text from AI.
* @param {number} startId - Starting subtask ID expected.
* @param {number} expectedCount - Expected number of subtasks.
* @param {number} parentTaskId - Parent task ID for context.
* @param {Object} logger - Logging object (mcpLog or console log).
* @returns {Array} Parsed and potentially corrected subtasks array.
* @throws {Error} If parsing fails or JSON is invalid/malformed.
*/
function parseSubtasksFromText(
text,
startId,
expectedCount,
parentTaskId,
logger
) {
if (typeof text !== 'string') {
logger.error(
`AI response text is not a string. Received type: ${typeof text}, Value: ${text}`
);
throw new Error('AI response text is not a string.');
}
if (!text || text.trim() === '') {
throw new Error('AI response text is empty after trimming.');
}
const originalTrimmedResponse = text.trim(); // Store the original trimmed response
let jsonToParse = originalTrimmedResponse; // Initialize jsonToParse with it
logger.debug(
`Original AI Response for parsing (full length: ${jsonToParse.length}): ${jsonToParse.substring(0, 1000)}...`
);
// --- Pre-emptive cleanup for known AI JSON issues ---
// Fix for "dependencies": , or "dependencies":,
if (jsonToParse.includes('"dependencies":')) {
const malformedPattern = /"dependencies":\s*,/g;
if (malformedPattern.test(jsonToParse)) {
logger.warn('Attempting to fix malformed "dependencies": , issue.');
jsonToParse = jsonToParse.replace(
malformedPattern,
'"dependencies": [],'
);
logger.debug(
`JSON after fixing "dependencies": ${jsonToParse.substring(0, 500)}...`
);
}
}
// --- End pre-emptive cleanup ---
let parsedObject;
let primaryParseAttemptFailed = false;
// --- Attempt 1: Simple Parse (with optional Markdown cleanup) ---
logger.debug('Attempting simple parse...');
try {
// Check for markdown code block
const codeBlockMatch = jsonToParse.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
let contentToParseDirectly = jsonToParse;
if (codeBlockMatch && codeBlockMatch[1]) {
contentToParseDirectly = codeBlockMatch[1].trim();
logger.debug('Simple parse: Extracted content from markdown code block.');
} else {
logger.debug(
'Simple parse: No markdown code block found, using trimmed original.'
);
}
parsedObject = JSON.parse(contentToParseDirectly);
logger.debug('Simple parse successful!');
// Quick check if it looks like our target object
if (
!parsedObject ||
typeof parsedObject !== 'object' ||
!Array.isArray(parsedObject.subtasks)
) {
logger.warn(
'Simple parse succeeded, but result is not the expected {"subtasks": []} structure. Will proceed to advanced extraction.'
);
primaryParseAttemptFailed = true;
parsedObject = null; // Reset parsedObject so we enter the advanced logic
}
// If it IS the correct structure, we'll skip advanced extraction.
} catch (e) {
logger.warn(
`Simple parse failed: ${e.message}. Proceeding to advanced extraction logic.`
);
primaryParseAttemptFailed = true;
// jsonToParse is already originalTrimmedResponse if simple parse failed before modifying it for markdown
}
// --- Attempt 2: Advanced Extraction (if simple parse failed or produced wrong structure) ---
if (primaryParseAttemptFailed || !parsedObject) {
// Ensure we try advanced if simple parse gave wrong structure
logger.debug('Attempting advanced extraction logic...');
// Reset jsonToParse to the original full trimmed response for advanced logic
jsonToParse = originalTrimmedResponse;
// (Insert the more complex extraction logic here - the one we worked on with:
// - targetPattern = '{"subtasks":';
// - careful brace counting for that targetPattern
// - fallbacks to last '{' and '}' if targetPattern logic fails)
// This was the logic from my previous message. Let's assume it's here.
// This block should ultimately set `jsonToParse` to the best candidate string.
// Example snippet of that advanced logic's start:
const targetPattern = '{"subtasks":';
const patternStartIndex = jsonToParse.indexOf(targetPattern);
if (patternStartIndex !== -1) {
const openBraces = 0;
const firstBraceFound = false;
const extractedJsonBlock = '';
// ... (loop for brace counting as before) ...
// ... (if successful, jsonToParse = extractedJsonBlock) ...
// ... (if that fails, fallbacks as before) ...
} else {
// ... (fallback to last '{' and '}' if targetPattern not found) ...
}
// End of advanced logic excerpt
logger.debug(
`Advanced extraction: JSON string that will be parsed: ${jsonToParse.substring(0, 500)}...`
);
try {
parsedObject = JSON.parse(jsonToParse);
logger.debug('Advanced extraction parse successful!');
} catch (parseError) {
logger.error(
`Advanced extraction: Failed to parse JSON object: ${parseError.message}`
);
logger.error(
`Advanced extraction: Problematic JSON string for parse (first 500 chars): ${jsonToParse.substring(0, 500)}`
);
throw new Error(
// Re-throw a more specific error if advanced also fails
`Failed to parse JSON response object after both simple and advanced attempts: ${parseError.message}`
);
}
}
// --- Validation (applies to successfully parsedObject from either attempt) ---
if (
!parsedObject ||
typeof parsedObject !== 'object' ||
!Array.isArray(parsedObject.subtasks)
) {
logger.error(
`Final parsed content is not an object or missing 'subtasks' array. Content: ${JSON.stringify(parsedObject).substring(0, 200)}`
);
throw new Error(
'Parsed AI response is not a valid object containing a "subtasks" array after all attempts.'
);
}
const parsedSubtasks = parsedObject.subtasks;
if (expectedCount && parsedSubtasks.length !== expectedCount) {
logger.warn(
`Expected ${expectedCount} subtasks, but parsed ${parsedSubtasks.length}.`
);
}
let currentId = startId;
const validatedSubtasks = [];
const validationErrors = [];
for (const rawSubtask of parsedSubtasks) {
const correctedSubtask = {
...rawSubtask,
id: currentId,
dependencies: Array.isArray(rawSubtask.dependencies)
? rawSubtask.dependencies.filter(
(dep) =>
typeof dep === 'string' && dep.startsWith(`${parentTaskId}.`)
)
: [],
status: 'pending'
};
const result = subtaskSchema.safeParse(correctedSubtask);
if (result.success) {
validatedSubtasks.push(result.data);
} else {
logger.warn(
`Subtask validation failed for raw data: ${JSON.stringify(rawSubtask).substring(0, 100)}...`
);
result.error.errors.forEach((err) => {
const errorMessage = ` - Field '${err.path.join('.')}': ${err.message}`;
logger.warn(errorMessage);
validationErrors.push(`Subtask ${currentId}: ${errorMessage}`);
});
}
currentId++;
}
if (validationErrors.length > 0) {
logger.error(
`Found ${validationErrors.length} validation errors in the generated subtasks.`
);
logger.warn('Proceeding with only the successfully validated subtasks.');
}
if (validatedSubtasks.length === 0 && parsedSubtasks.length > 0) {
throw new Error(
'AI response contained potential subtasks, but none passed validation.'
);
}
return validatedSubtasks.slice(0, expectedCount || validatedSubtasks.length);
}
/**
* Expand a task into subtasks using the unified AI service (generateTextService).
* Appends new subtasks by default. Replaces existing subtasks if force=true.
* Integrates complexity report to determine subtask count and prompt if available,
* unless numSubtasks is explicitly provided.
* @param {string} tasksPath - Path to the tasks.json file
* @param {number} taskId - Task ID to expand
* @param {number | null | undefined} [numSubtasks] - Optional: Explicit target number of subtasks. If null/undefined, check complexity report or config default.
* @param {boolean} [useResearch=false] - Whether to use the research AI role.
* @param {string} [additionalContext=''] - Optional additional context.
* @param {Object} context - Context object containing session and mcpLog.
* @param {Object} [context.session] - Session object from MCP.
* @param {Object} [context.mcpLog] - MCP logger object.
* @param {string} [context.projectRoot] - Project root path
* @param {string} [context.tag] - Tag for the task
* @param {boolean} [force=false] - If true, replace existing subtasks; otherwise, append.
* @returns {Promise<Object>} The updated parent task object with new subtasks.
* @throws {Error} If task not found, AI service fails, or parsing fails.
*/
async function expandTask(
tasksPath,
taskId,
numSubtasks,
useResearch = false,
additionalContext = '',
context = {},
force = false
) {
const {
session,
mcpLog,
projectRoot: contextProjectRoot,
tag,
complexityReportPath
} = context;
const outputFormat = mcpLog ? 'json' : 'text';
// Determine projectRoot: Use from context if available, otherwise derive from tasksPath
const projectRoot = contextProjectRoot || findProjectRoot(tasksPath);
// Use mcpLog if available, otherwise use the default console log wrapper
const logger = mcpLog || {
info: (msg) => !isSilentMode() && log('info', msg),
warn: (msg) => !isSilentMode() && log('warn', msg),
error: (msg) => !isSilentMode() && log('error', msg),
debug: (msg) =>
!isSilentMode() && getDebugFlag(session) && log('debug', msg) // Use getDebugFlag
};
if (mcpLog) {
logger.info(`expandTask called with context: session=${!!session}`);
}
try {
// --- Task Loading/Filtering (Unchanged) ---
logger.info(`Reading tasks from ${tasksPath}`);
const data = readJSON(tasksPath, projectRoot, tag);
if (!data || !data.tasks)
throw new Error(`Invalid tasks data in ${tasksPath}`);
const taskIndex = data.tasks.findIndex(
(t) => t.id === parseInt(taskId, 10)
);
if (taskIndex === -1) throw new Error(`Task ${taskId} not found`);
const task = data.tasks[taskIndex];
logger.info(
`Expanding task ${taskId}: ${task.title}${useResearch ? ' with research' : ''}`
);
// --- End Task Loading/Filtering ---
// --- Handle Force Flag: Clear existing subtasks if force=true ---
if (force && Array.isArray(task.subtasks) && task.subtasks.length > 0) {
logger.info(
`Force flag set. Clearing existing ${task.subtasks.length} subtasks for task ${taskId}.`
);
task.subtasks = []; // Clear existing subtasks
}
// --- End Force Flag Handling ---
// --- Context Gathering ---
let gatheredContext = '';
try {
const contextGatherer = new ContextGatherer(projectRoot, tag);
const allTasksFlat = flattenTasksWithSubtasks(data.tasks);
const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'expand-task');
const searchQuery = `${task.title} ${task.description}`;
const searchResults = fuzzySearch.findRelevantTasks(searchQuery, {
maxResults: 5,
includeSelf: true
});
const relevantTaskIds = fuzzySearch.getTaskIds(searchResults);
const finalTaskIds = [
...new Set([taskId.toString(), ...relevantTaskIds])
];
if (finalTaskIds.length > 0) {
const contextResult = await contextGatherer.gather({
tasks: finalTaskIds,
format: 'research'
});
gatheredContext = contextResult.context || '';
}
} catch (contextError) {
logger.warn(`Could not gather context: ${contextError.message}`);
}
// --- End Context Gathering ---
// --- Complexity Report Integration ---
let finalSubtaskCount;
let complexityReasoningContext = '';
let taskAnalysis = null;
logger.info(
`Looking for complexity report at: ${complexityReportPath}${tag !== 'master' ? ` (tag-specific for '${tag}')` : ''}`
);
try {
if (fs.existsSync(complexityReportPath)) {
const complexityReport = readJSON(complexityReportPath);
taskAnalysis = complexityReport?.complexityAnalysis?.find(
(a) => a.taskId === task.id
);
if (taskAnalysis) {
logger.info(
`Found complexity analysis for task ${task.id}: Score ${taskAnalysis.complexityScore}`
);
if (taskAnalysis.reasoning) {
complexityReasoningContext = `\nComplexity Analysis Reasoning: ${taskAnalysis.reasoning}`;
}
} else {
logger.info(
`No complexity analysis found for task ${task.id} in report.`
);
}
} else {
logger.info(
`Complexity report not found at ${complexityReportPath}. Skipping complexity check.`
);
}
} catch (reportError) {
logger.warn(
`Could not read or parse complexity report: ${reportError.message}. Proceeding without it.`
);
}
// Determine final subtask count
const explicitNumSubtasks = parseInt(numSubtasks, 10);
if (!Number.isNaN(explicitNumSubtasks) && explicitNumSubtasks >= 0) {
finalSubtaskCount = explicitNumSubtasks;
logger.info(
`Using explicitly provided subtask count: ${finalSubtaskCount}`
);
} else if (taskAnalysis?.recommendedSubtasks) {
finalSubtaskCount = parseInt(taskAnalysis.recommendedSubtasks, 10);
logger.info(
`Using subtask count from complexity report: ${finalSubtaskCount}`
);
} else {
finalSubtaskCount = getDefaultSubtasks(session);
logger.info(`Using default number of subtasks: ${finalSubtaskCount}`);
}
if (Number.isNaN(finalSubtaskCount) || finalSubtaskCount < 0) {
logger.warn(
`Invalid subtask count determined (${finalSubtaskCount}), defaulting to 3.`
);
finalSubtaskCount = 3;
}
// Determine prompt content AND system prompt
const nextSubtaskId = (task.subtasks?.length || 0) + 1;
// Load prompts using PromptManager
const promptManager = getPromptManager();
// Combine all context sources into a single additionalContext parameter
let combinedAdditionalContext = '';
if (additionalContext || complexityReasoningContext) {
combinedAdditionalContext =
`\n\n${additionalContext}${complexityReasoningContext}`.trim();
}
if (gatheredContext) {
combinedAdditionalContext =
`${combinedAdditionalContext}\n\n# Project Context\n\n${gatheredContext}`.trim();
}
// Ensure expansionPrompt is a string (handle both string and object formats)
let expansionPromptText = undefined;
if (taskAnalysis?.expansionPrompt) {
if (typeof taskAnalysis.expansionPrompt === 'string') {
expansionPromptText = taskAnalysis.expansionPrompt;
} else if (
typeof taskAnalysis.expansionPrompt === 'object' &&
taskAnalysis.expansionPrompt.text
) {
expansionPromptText = taskAnalysis.expansionPrompt.text;
}
}
// Ensure gatheredContext is a string (handle both string and object formats)
let gatheredContextText = gatheredContext;
if (typeof gatheredContext === 'object' && gatheredContext !== null) {
if (gatheredContext.data) {
gatheredContextText = gatheredContext.data;
} else if (gatheredContext.text) {
gatheredContextText = gatheredContext.text;
} else {
gatheredContextText = JSON.stringify(gatheredContext);
}
}
const promptParams = {
task: task,
subtaskCount: finalSubtaskCount,
nextSubtaskId: nextSubtaskId,
additionalContext: additionalContext,
complexityReasoningContext: complexityReasoningContext,
gatheredContext: gatheredContextText || '',
useResearch: useResearch,
expansionPrompt: expansionPromptText || undefined
};
let variantKey = 'default';
if (expansionPromptText) {
variantKey = 'complexity-report';
logger.info(
`Using expansion prompt from complexity report for task ${task.id}.`
);
} else if (useResearch) {
variantKey = 'research';
logger.info(`Using research variant for task ${task.id}.`);
} else {
logger.info(`Using standard prompt generation for task ${task.id}.`);
}
const { systemPrompt, userPrompt: promptContent } =
await promptManager.loadPrompt('expand-task', promptParams, variantKey);
// --- End Complexity Report / Prompt Logic ---
// --- AI Subtask Generation using generateTextService ---
let generatedSubtasks = [];
let loadingIndicator = null;
if (outputFormat === 'text') {
loadingIndicator = startLoadingIndicator(
`Generating ${finalSubtaskCount || 'appropriate number of'} subtasks...\n`
);
}
let responseText = '';
let aiServiceResponse = null;
try {
const role = useResearch ? 'research' : 'main';
// Call generateTextService with the determined prompts and telemetry params
aiServiceResponse = await generateTextService({
prompt: promptContent,
systemPrompt: systemPrompt,
role,
session,
projectRoot,
commandName: 'expand-task',
outputType: outputFormat
});
responseText = aiServiceResponse.mainResult;
// Parse Subtasks
generatedSubtasks = parseSubtasksFromText(
responseText,
nextSubtaskId,
finalSubtaskCount,
task.id,
logger
);
logger.info(
`Successfully parsed ${generatedSubtasks.length} subtasks from AI response.`
);
} catch (error) {
if (loadingIndicator) stopLoadingIndicator(loadingIndicator);
logger.error(
`Error during AI call or parsing for task ${taskId}: ${error.message}`, // Added task ID context
'error'
);
// Log raw response in debug mode if parsing failed
if (
error.message.includes('Failed to parse valid subtasks') &&
getDebugFlag(session)
) {
logger.error(`Raw AI Response that failed parsing:\n${responseText}`);
}
throw error;
} finally {
if (loadingIndicator) stopLoadingIndicator(loadingIndicator);
}
// --- Task Update & File Writing ---
// Ensure task.subtasks is an array before appending
if (!Array.isArray(task.subtasks)) {
task.subtasks = [];
}
// Append the newly generated and validated subtasks
task.subtasks.push(...generatedSubtasks);
// --- End Change: Append instead of replace ---
data.tasks[taskIndex] = task; // Assign the modified task back
writeJSON(tasksPath, data, projectRoot, tag);
// await generateTaskFiles(tasksPath, path.dirname(tasksPath));
// Display AI Usage Summary for CLI
if (
outputFormat === 'text' &&
aiServiceResponse &&
aiServiceResponse.telemetryData
) {
displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli');
}
// Return the updated task object AND telemetry data
return {
task,
telemetryData: aiServiceResponse?.telemetryData,
tagInfo: aiServiceResponse?.tagInfo
};
} catch (error) {
// Catches errors from file reading, parsing, AI call etc.
logger.error(`Error expanding task ${taskId}: ${error.message}`, 'error');
if (outputFormat === 'text' && getDebugFlag(session)) {
console.error(error); // Log full stack in debug CLI mode
}
throw error; // Re-throw for the caller
}
}
export default expandTask;