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:
@@ -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}`);
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user