refactor(expand/all): Implement additive expansion and complexity report integration

Refactors the `expandTask` and `expandAllTasks` features to complete subtask 61.38 and enhance functionality based on subtask 61.37's refactor.

Key Changes:

- **Additive Expansion (`expandTask`, `expandAllTasks`):**

    - Modified `expandTask` default behavior to append newly generated subtasks to any existing ones.

    - Added a `force` flag (passed down from CLI/MCP via `--force` option/parameter) to `expandTask` and `expandAllTasks`. When `force` is true, existing subtasks are cleared before generating new ones.

    - Updated relevant CLI command (`expand`), MCP tool (`expand_task`, `expand_all`), and direct function wrappers (`expandTaskDirect`, `expandAllTasksDirect`) to handle and pass the `force` flag.

- **Complexity Report Integration (`expandTask`):**

    - `expandTask` now reads `scripts/task-complexity-report.json`.

    - If an analysis entry exists for the target task:

        - `recommendedSubtasks` is used to determine the number of subtasks to generate (unless `--num` is explicitly provided).

        - `expansionPrompt` is used as the primary prompt content for the AI.

        - `reasoning` is appended to any additional context provided.

    - If no report entry exists or the report is missing, it falls back to default subtask count (from config) and standard prompt generation.

- **`expandAllTasks` Orchestration:**

    - Refactored `expandAllTasks` to primarily iterate through eligible tasks (pending/in-progress, considering `force` flag and existing subtasks) and call the updated `expandTask` function for each.

    - Removed redundant logic (like complexity reading or explicit subtask clearing) now handled within `expandTask`.

    - Ensures correct context (`session`, `mcpLog`) and flags (`useResearch`, `force`) are passed down.

- **Configuration & Cleanup:**

    - Updated `.cursor/mcp.json` with new Perplexity/Anthropic API keys (old ones invalidated).

    - Completed refactoring of `expandTask` started in 61.37, confirming usage of `generateTextService` and appropriate prompts.

- **Task Management:**

    - Marked subtask 61.37 as complete.

    - Updated `.changeset/cuddly-zebras-matter.md` to reflect user-facing changes.

These changes finalize the refactoring of the task expansion features, making them more robust, configurable via complexity analysis, and aligned with the unified AI service architecture.
This commit is contained in:
Eyal Toledano
2025-04-25 02:57:08 -04:00
parent 99b1a0ad7a
commit ef782ff5bd
12 changed files with 1068 additions and 542 deletions

View File

@@ -1,334 +1,178 @@
import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
import boxen from 'boxen';
import { log, readJSON, writeJSON, truncate, isSilentMode } from '../utils.js';
import {
displayBanner,
startLoadingIndicator,
stopLoadingIndicator
} from '../ui.js';
import { getDefaultSubtasks } from '../config-manager.js';
import generateTaskFiles from './generate-task-files.js';
import { log, readJSON, writeJSON, isSilentMode } from '../utils.js';
import { startLoadingIndicator, stopLoadingIndicator } from '../ui.js';
import expandTask from './expand-task.js';
import { getDebugFlag } from '../config-manager.js';
/**
* Expand all pending tasks with subtasks
* Expand all eligible pending or in-progress tasks using the expandTask function.
* @param {string} tasksPath - Path to the tasks.json file
* @param {number} numSubtasks - Number of subtasks per task
* @param {boolean} useResearch - Whether to use research (Perplexity)
* @param {string} additionalContext - Additional context
* @param {boolean} forceFlag - Force regeneration for tasks with subtasks
* @param {Object} options - Options for expanding tasks
* @param {function} options.reportProgress - Function to report progress
* @param {Object} options.mcpLog - MCP logger object
* @param {Object} options.session - Session object from MCP
* @param {string} outputFormat - Output format (text or json)
* @param {number} [numSubtasks] - Optional: Target number of subtasks per task.
* @param {boolean} [useResearch=false] - Whether to use the research AI role.
* @param {string} [additionalContext=''] - Optional additional context.
* @param {boolean} [force=false] - Force expansion even if tasks already have subtasks.
* @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} [outputFormat='text'] - Output format ('text' or 'json'). MCP calls should use 'json'.
* @returns {Promise<{success: boolean, expandedCount: number, failedCount: number, skippedCount: number, tasksToExpand: number, message?: string}>} - Result summary.
*/
async function expandAllTasks(
tasksPath,
numSubtasks = getDefaultSubtasks(), // Use getter
numSubtasks, // Keep this signature, expandTask handles defaults
useResearch = false,
additionalContext = '',
forceFlag = false,
{ reportProgress, mcpLog, session } = {},
outputFormat = 'text'
force = false, // Keep force here for the filter logic
context = {},
outputFormat = 'text' // Assume text default for CLI
) {
// 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);
}
};
const { session, mcpLog } = context;
const isMCPCall = !!mcpLog; // Determine if called from MCP
// Only display banner and UI elements for text output (CLI)
if (outputFormat === 'text') {
displayBanner();
}
// Parse numSubtasks as integer if it's a string
if (typeof numSubtasks === 'string') {
numSubtasks = parseInt(numSubtasks, 10);
if (isNaN(numSubtasks)) {
numSubtasks = getDefaultSubtasks(); // Use getter
}
}
report(`Expanding all pending tasks with ${numSubtasks} subtasks each...`);
if (useResearch) {
report('Using research-backed AI for more detailed subtasks');
}
// Load tasks
let data;
try {
data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error('No valid tasks found');
}
} catch (error) {
report(`Error loading tasks: ${error.message}`, 'error');
throw error;
}
// Get all tasks that are pending/in-progress and don't have subtasks (or force regeneration)
const tasksToExpand = data.tasks.filter(
(task) =>
(task.status === 'pending' || task.status === 'in-progress') &&
(!task.subtasks || task.subtasks.length === 0 || forceFlag)
);
if (tasksToExpand.length === 0) {
report(
'No tasks eligible for expansion. Tasks should be in pending/in-progress status and not have subtasks already.',
'info'
);
// Return structured result for MCP
return {
success: true,
expandedCount: 0,
tasksToExpand: 0,
message: 'No tasks eligible for expansion'
};
}
report(`Found ${tasksToExpand.length} tasks to expand`);
// Check if we have a complexity report to prioritize complex tasks
let complexityReport;
const reportPath = path.join(
path.dirname(tasksPath),
'../scripts/task-complexity-report.json'
);
if (fs.existsSync(reportPath)) {
try {
complexityReport = readJSON(reportPath);
report('Using complexity analysis to prioritize tasks');
} catch (error) {
report(`Could not read complexity report: ${error.message}`, 'warn');
}
}
// Only create loading indicator if not in silent mode and outputFormat is 'text'
let loadingIndicator = null;
if (!isSilentMode() && outputFormat === 'text') {
loadingIndicator = startLoadingIndicator(
`Expanding ${tasksToExpand.length} tasks with ${numSubtasks} subtasks each`
);
}
let expandedCount = 0;
let expansionErrors = 0;
try {
// Sort tasks by complexity if report exists, otherwise by ID
if (complexityReport && complexityReport.complexityAnalysis) {
report('Sorting tasks by complexity...');
// Create a map of task IDs to complexity scores
const complexityMap = new Map();
complexityReport.complexityAnalysis.forEach((analysis) => {
complexityMap.set(analysis.taskId, analysis.complexityScore);
});
// Sort tasks by complexity score (high to low)
tasksToExpand.sort((a, b) => {
const scoreA = complexityMap.get(a.id) || 0;
const scoreB = complexityMap.get(b.id) || 0;
return scoreB - scoreA;
});
}
// Process each task
for (const task of tasksToExpand) {
if (loadingIndicator && outputFormat === 'text') {
loadingIndicator.text = `Expanding task ${task.id}: ${truncate(task.title, 30)} (${expandedCount + 1}/${tasksToExpand.length})`;
}
// Report progress to MCP if available
if (reportProgress) {
reportProgress({
status: 'processing',
current: expandedCount + 1,
total: tasksToExpand.length,
message: `Expanding task ${task.id}: ${truncate(task.title, 30)}`
// Use mcpLog if available, otherwise use the default console log wrapper respecting silent mode
const logger =
mcpLog ||
(outputFormat === 'json'
? {
// Basic logger for JSON output mode
info: (msg) => {},
warn: (msg) => {},
error: (msg) => console.error(`ERROR: ${msg}`), // Still log errors
debug: (msg) => {}
}
: {
// CLI logger respecting silent mode
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)
});
}
report(`Expanding task ${task.id}: ${truncate(task.title, 50)}`);
let loadingIndicator = null;
let expandedCount = 0;
let failedCount = 0;
// No skipped count needed now as the filter handles it upfront
let tasksToExpandCount = 0; // Renamed for clarity
// Check if task already has subtasks and forceFlag is enabled
if (task.subtasks && task.subtasks.length > 0 && forceFlag) {
report(
`Task ${task.id} already has ${task.subtasks.length} subtasks. Clearing them for regeneration.`
if (!isMCPCall && outputFormat === 'text') {
loadingIndicator = startLoadingIndicator(
'Analyzing tasks for expansion...'
);
}
try {
logger.info(`Reading tasks from ${tasksPath}`);
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`Invalid tasks data in ${tasksPath}`);
}
// --- Restore Original Filtering Logic ---
const tasksToExpand = data.tasks.filter(
(task) =>
(task.status === 'pending' || task.status === 'in-progress') && // Include 'in-progress'
(!task.subtasks || task.subtasks.length === 0 || force) // Check subtasks/force here
);
tasksToExpandCount = tasksToExpand.length; // Get the count from the filtered array
logger.info(`Found ${tasksToExpandCount} tasks eligible for expansion.`);
// --- End Restored Filtering Logic ---
if (loadingIndicator) {
stopLoadingIndicator(loadingIndicator, 'Analysis complete.');
}
if (tasksToExpandCount === 0) {
logger.info('No tasks eligible for expansion.');
// --- Fix: Restore success: true and add message ---
return {
success: true, // Indicate overall success despite no action
expandedCount: 0,
failedCount: 0,
skippedCount: 0,
tasksToExpand: 0,
message: 'No tasks eligible for expansion.'
};
// --- End Fix ---
}
// Iterate over the already filtered tasks
for (const task of tasksToExpand) {
// --- Remove Redundant Check ---
// The check below is no longer needed as the initial filter handles it
/*
if (task.subtasks && task.subtasks.length > 0 && !force) {
logger.info(
`Skipping task ${task.id}: Already has subtasks. Use --force to overwrite.`
);
task.subtasks = [];
skippedCount++;
continue;
}
*/
// --- End Removed Redundant Check ---
// Start indicator for individual task expansion in CLI mode
let taskIndicator = null;
if (!isMCPCall && outputFormat === 'text') {
taskIndicator = startLoadingIndicator(`Expanding task ${task.id}...`);
}
try {
// Get complexity analysis for this task if available
let taskAnalysis;
if (complexityReport && complexityReport.complexityAnalysis) {
taskAnalysis = complexityReport.complexityAnalysis.find(
(a) => a.taskId === task.id
);
}
let thisNumSubtasks = numSubtasks;
// Use recommended number of subtasks from complexity analysis if available
if (taskAnalysis && taskAnalysis.recommendedSubtasks) {
report(
`Using recommended ${taskAnalysis.recommendedSubtasks} subtasks based on complexity score ${taskAnalysis.complexityScore}/10 for task ${task.id}`
);
thisNumSubtasks = taskAnalysis.recommendedSubtasks;
}
// Generate prompt for subtask creation based on task details
const prompt = generateSubtaskPrompt(
task,
thisNumSubtasks,
additionalContext,
taskAnalysis
);
// Use AI to generate subtasks
const aiResponse = await getSubtasksFromAI(
prompt,
// Call the refactored expandTask function
await expandTask(
tasksPath,
task.id,
numSubtasks, // Pass numSubtasks, expandTask handles defaults/complexity
useResearch,
session,
mcpLog
additionalContext,
context, // Pass the whole context object { session, mcpLog }
force // Pass the force flag down
);
if (
aiResponse &&
aiResponse.subtasks &&
Array.isArray(aiResponse.subtasks) &&
aiResponse.subtasks.length > 0
) {
// Process and add the subtasks to the task
task.subtasks = aiResponse.subtasks.map((subtask, index) => ({
id: index + 1,
title: subtask.title || `Subtask ${index + 1}`,
description: subtask.description || 'No description provided',
status: 'pending',
dependencies: subtask.dependencies || [],
details: subtask.details || ''
}));
report(`Added ${task.subtasks.length} subtasks to task ${task.id}`);
expandedCount++;
} else if (aiResponse && aiResponse.error) {
// Handle error response
const errorMsg = `Failed to generate subtasks for task ${task.id}: ${aiResponse.error}`;
report(errorMsg, 'error');
// Add task ID to error info and provide actionable guidance
const suggestion = aiResponse.suggestion.replace('<id>', task.id);
report(`Suggestion: ${suggestion}`, 'info');
expansionErrors++;
} else {
report(`Failed to generate subtasks for task ${task.id}`, 'error');
report(
`Suggestion: Run 'task-master update-task --id=${task.id} --prompt="Generate subtasks for this task"' to manually create subtasks.`,
'info'
);
expansionErrors++;
expandedCount++;
if (taskIndicator) {
stopLoadingIndicator(taskIndicator, `Task ${task.id} expanded.`);
}
logger.info(`Successfully expanded task ${task.id}.`);
} catch (error) {
report(`Error expanding task ${task.id}: ${error.message}`, 'error');
expansionErrors++;
failedCount++;
if (taskIndicator) {
stopLoadingIndicator(
taskIndicator,
`Failed to expand task ${task.id}.`,
false
);
}
logger.error(`Failed to expand task ${task.id}: ${error.message}`);
// Continue to the next task
}
// Small delay to prevent rate limiting
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Save the updated tasks
writeJSON(tasksPath, data);
// Log final summary (removed skipped count from message)
logger.info(
`Expansion complete: ${expandedCount} expanded, ${failedCount} failed.`
);
// Generate task files
if (outputFormat === 'text') {
// Only perform file generation for CLI (text) mode
const outputDir = path.dirname(tasksPath);
await generateTaskFiles(tasksPath, outputDir);
}
// Return structured result for MCP
// Return summary (skippedCount is now 0) - Add success: true here as well for consistency
return {
success: true,
success: true, // Indicate overall success
expandedCount,
tasksToExpand: tasksToExpand.length,
expansionErrors,
message: `Successfully expanded ${expandedCount} out of ${tasksToExpand.length} tasks${expansionErrors > 0 ? ` (${expansionErrors} errors)` : ''}`
failedCount,
skippedCount: 0,
tasksToExpand: tasksToExpandCount
};
} catch (error) {
report(`Error expanding tasks: ${error.message}`, 'error');
throw error;
} finally {
// Stop the loading indicator if it was created
if (loadingIndicator && outputFormat === 'text') {
stopLoadingIndicator(loadingIndicator);
}
// Final progress report
if (reportProgress) {
reportProgress({
status: 'completed',
current: expandedCount,
total: tasksToExpand.length,
message: `Completed expanding ${expandedCount} out of ${tasksToExpand.length} tasks`
});
}
// Display completion message for CLI mode
if (outputFormat === 'text') {
console.log(
boxen(
chalk.white.bold(`Task Expansion Completed`) +
'\n\n' +
chalk.white(
`Expanded ${expandedCount} out of ${tasksToExpand.length} tasks`
) +
'\n' +
chalk.white(
`Each task now has detailed subtasks to guide implementation`
),
{
padding: 1,
borderColor: 'green',
borderStyle: 'round',
margin: { top: 1 }
}
)
);
// Suggest next actions
if (expandedCount > 0) {
console.log(chalk.bold('\nNext Steps:'));
console.log(
chalk.cyan(
`1. Run ${chalk.yellow('task-master list --with-subtasks')} to see all tasks with their subtasks`
)
);
console.log(
chalk.cyan(
`2. Run ${chalk.yellow('task-master next')} to find the next task to work on`
)
);
console.log(
chalk.cyan(
`3. Run ${chalk.yellow('task-master set-status --id=<taskId> --status=in-progress')} to start working on a task`
)
);
}
if (loadingIndicator)
stopLoadingIndicator(loadingIndicator, 'Error.', false);
logger.error(`Error during expand all operation: ${error.message}`);
if (!isMCPCall && getDebugFlag(session)) {
console.error(error); // Log full stack in debug CLI mode
}
// Re-throw error for the caller to handle, the direct function will format it
throw error; // Let direct function wrapper handle formatting
/* Original re-throw:
throw new Error(`Failed to expand all tasks: ${error.message}`);
*/
}
}