fix(tasks): Improve next task logic to be subtask-aware

This commit is contained in:
Eyal Toledano
2025-04-28 00:27:19 -04:00
parent e789e9bbf2
commit 0c08767830
4 changed files with 170 additions and 9505 deletions

View File

@@ -0,0 +1,10 @@
---
'task-master-ai': patch
---
- Improves next command to be subtask-aware
- The logic for determining the "next task" (findNextTask function, used by task-master next and the next_task MCP tool) has been significantly improved. Previously, it only considered top-level tasks, making its recommendation less useful when a parent task containing subtasks was already marked 'in-progress'.
- The updated logic now prioritizes finding the next available subtask within any 'in-progress' parent task, considering subtask dependencies and priority.
- If no suitable subtask is found within active parent tasks, it falls back to recommending the next eligible top-level task based on the original criteria (status, dependencies, priority).
This change makes the next command much more relevant and helpful during the implementation phase of complex tasks.

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,120 @@
/** /**
* Find the next pending task based on dependencies * Return the next work item:
* @param {Object[]} tasks - The array of tasks * • Prefer an eligible SUBTASK that belongs to any parent task
* @returns {Object|null} The next task to work on or null if no eligible tasks * whose own status is `in-progress`.
* • If no such subtask exists, fall back to the best top-level task
* (previous behaviour).
*
* The function still exports the same name (`findNextTask`) so callers
* don't need to change. It now always returns an object with
* ─ id → number (task) or "parentId.subId" (subtask)
* ─ title → string
* ─ status → string
* ─ priority → string ("high" | "medium" | "low")
* ─ dependencies → array (all IDs expressed in the same dotted form)
* ─ parentId → number (present only when it's a subtask)
*
* @param {Object[]} tasks full array of top-level tasks, each may contain .subtasks[]
* @returns {Object|null} next work item or null if nothing is eligible
*/ */
function findNextTask(tasks) { function findNextTask(tasks) {
// Get all completed task IDs // ---------- helpers ----------------------------------------------------
const completedTaskIds = new Set(
tasks
.filter((t) => t.status === 'done' || t.status === 'completed')
.map((t) => t.id)
);
// Filter for pending tasks whose dependencies are all satisfied
const eligibleTasks = tasks.filter(
(task) =>
(task.status === 'pending' || task.status === 'in-progress') &&
task.dependencies && // Make sure dependencies array exists
task.dependencies.every((depId) => completedTaskIds.has(depId))
);
if (eligibleTasks.length === 0) {
return null;
}
// Sort eligible tasks by:
// 1. Priority (high > medium > low)
// 2. Dependencies count (fewer dependencies first)
// 3. ID (lower ID first)
const priorityValues = { high: 3, medium: 2, low: 1 }; const priorityValues = { high: 3, medium: 2, low: 1 };
const toFullSubId = (parentId, maybeDotId) => {
// "12.3" -> "12.3"
// 4 -> "12.4" (numeric / short form)
if (typeof maybeDotId === 'string' && maybeDotId.includes('.')) {
return maybeDotId;
}
return `${parentId}.${maybeDotId}`;
};
// ---------- build completed-ID set (tasks *and* subtasks) --------------
const completedIds = new Set();
tasks.forEach((t) => {
if (t.status === 'done' || t.status === 'completed') {
completedIds.add(String(t.id));
}
if (Array.isArray(t.subtasks)) {
t.subtasks.forEach((st) => {
if (st.status === 'done' || st.status === 'completed') {
completedIds.add(`${t.id}.${st.id}`);
}
});
}
});
// ---------- 1) look for eligible subtasks ------------------------------
const candidateSubtasks = [];
tasks
.filter((t) => t.status === 'in-progress' && Array.isArray(t.subtasks))
.forEach((parent) => {
parent.subtasks.forEach((st) => {
const stStatus = (st.status || 'pending').toLowerCase();
if (stStatus !== 'pending' && stStatus !== 'in-progress') return;
const fullDeps =
st.dependencies?.map((d) => toFullSubId(parent.id, d)) ?? [];
const depsSatisfied =
fullDeps.length === 0 ||
fullDeps.every((depId) => completedIds.has(String(depId)));
if (depsSatisfied) {
candidateSubtasks.push({
id: `${parent.id}.${st.id}`,
title: st.title || `Subtask ${st.id}`,
status: st.status || 'pending',
priority: st.priority || parent.priority || 'medium',
dependencies: fullDeps,
parentId: parent.id
});
}
});
});
if (candidateSubtasks.length > 0) {
// sort by priority → dep-count → parent-id → sub-id
candidateSubtasks.sort((a, b) => {
const pa = priorityValues[a.priority] ?? 2;
const pb = priorityValues[b.priority] ?? 2;
if (pb !== pa) return pb - pa;
if (a.dependencies.length !== b.dependencies.length)
return a.dependencies.length - b.dependencies.length;
// compare parent then sub-id numerically
const [aPar, aSub] = a.id.split('.').map(Number);
const [bPar, bSub] = b.id.split('.').map(Number);
if (aPar !== bPar) return aPar - bPar;
return aSub - bSub;
});
return candidateSubtasks[0];
}
// ---------- 2) fall back to top-level tasks (original logic) ------------
const eligibleTasks = tasks.filter((task) => {
const status = (task.status || 'pending').toLowerCase();
if (status !== 'pending' && status !== 'in-progress') return false;
const deps = task.dependencies ?? [];
return deps.every((depId) => completedIds.has(String(depId)));
});
if (eligibleTasks.length === 0) return null;
const nextTask = eligibleTasks.sort((a, b) => { const nextTask = eligibleTasks.sort((a, b) => {
// Sort by priority first const pa = priorityValues[a.priority || 'medium'] ?? 2;
const priorityA = priorityValues[a.priority || 'medium'] || 2; const pb = priorityValues[b.priority || 'medium'] ?? 2;
const priorityB = priorityValues[b.priority || 'medium'] || 2; if (pb !== pa) return pb - pa;
if (priorityB !== priorityA) { const da = (a.dependencies ?? []).length;
return priorityB - priorityA; // Higher priority first const db = (b.dependencies ?? []).length;
} if (da !== db) return da - db;
// If priority is the same, sort by dependency count return a.id - b.id;
if ( })[0];
a.dependencies &&
b.dependencies &&
a.dependencies.length !== b.dependencies.length
) {
return a.dependencies.length - b.dependencies.length; // Fewer dependencies first
}
// If dependency count is the same, sort by ID
return a.id - b.id; // Lower ID first
})[0]; // Return the first (highest priority) task
return nextTask; return nextTask;
} }

View File

@@ -258,13 +258,7 @@ function listTasks(
const avgDependenciesPerTask = totalDependencies / data.tasks.length; const avgDependenciesPerTask = totalDependencies / data.tasks.length;
// Find next task to work on // Find next task to work on
const nextTask = findNextTask(data.tasks); const nextItem = findNextTask(data.tasks);
const nextTaskInfo = nextTask
? `ID: ${chalk.cyan(nextTask.id)} - ${chalk.white.bold(truncate(nextTask.title, 40))}\n` +
`Priority: ${chalk.white(nextTask.priority || 'medium')} Dependencies: ${formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true)}`
: chalk.yellow(
'No eligible tasks found. All tasks are either completed or have unsatisfied dependencies.'
);
// Get terminal width - more reliable method // Get terminal width - more reliable method
let terminalWidth; let terminalWidth;
@@ -307,8 +301,8 @@ function listTasks(
`${chalk.blue('•')} ${chalk.white('Avg dependencies per task:')} ${avgDependenciesPerTask.toFixed(1)}\n\n` + `${chalk.blue('•')} ${chalk.white('Avg dependencies per task:')} ${avgDependenciesPerTask.toFixed(1)}\n\n` +
chalk.cyan.bold('Next Task to Work On:') + chalk.cyan.bold('Next Task to Work On:') +
'\n' + '\n' +
`ID: ${chalk.cyan(nextTask ? nextTask.id : 'N/A')} - ${nextTask ? chalk.white.bold(truncate(nextTask.title, 40)) : chalk.yellow('No task available')}\n` + `ID: ${chalk.cyan(nextItem ? nextItem.id : 'N/A')} - ${nextItem ? chalk.white.bold(truncate(nextItem.title, 40)) : chalk.yellow('No task available')}\n` +
`Priority: ${nextTask ? chalk.white(nextTask.priority || 'medium') : ''} Dependencies: ${nextTask ? formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true) : ''}`; `Priority: ${nextItem ? chalk.white(nextItem.priority || 'medium') : ''} Dependencies: ${nextItem ? formatDependenciesWithStatus(nextItem.dependencies, data.tasks, true) : ''}`;
// Calculate width for side-by-side display // Calculate width for side-by-side display
// Box borders, padding take approximately 4 chars on each side // Box borders, padding take approximately 4 chars on each side
@@ -588,12 +582,20 @@ function listTasks(
}; };
// Show next task box in a prominent color // Show next task box in a prominent color
if (nextTask) { if (nextItem) {
// Prepare subtasks section if they exist // Prepare subtasks section if they exist (Only tasks have .subtasks property)
let subtasksSection = ''; let subtasksSection = '';
if (nextTask.subtasks && nextTask.subtasks.length > 0) { // Check if the nextItem is a top-level task before looking for subtasks
const parentTaskForSubtasks = data.tasks.find(
(t) => String(t.id) === String(nextItem.id)
); // Find the original task object
if (
parentTaskForSubtasks &&
parentTaskForSubtasks.subtasks &&
parentTaskForSubtasks.subtasks.length > 0
) {
subtasksSection = `\n\n${chalk.white.bold('Subtasks:')}\n`; subtasksSection = `\n\n${chalk.white.bold('Subtasks:')}\n`;
subtasksSection += nextTask.subtasks subtasksSection += parentTaskForSubtasks.subtasks
.map((subtask) => { .map((subtask) => {
// Using a more simplified format for subtask status display // Using a more simplified format for subtask status display
const status = subtask.status || 'pending'; const status = subtask.status || 'pending';
@@ -608,26 +610,31 @@ function listTasks(
}; };
const statusColor = const statusColor =
statusColors[status.toLowerCase()] || chalk.white; statusColors[status.toLowerCase()] || chalk.white;
return `${chalk.cyan(`${nextTask.id}.${subtask.id}`)} [${statusColor(status)}] ${subtask.title}`; // Ensure subtask ID is displayed correctly using parent ID from the original task object
return `${chalk.cyan(`${parentTaskForSubtasks.id}.${subtask.id}`)} [${statusColor(status)}] ${subtask.title}`;
}) })
.join('\n'); .join('\n');
} }
console.log( console.log(
boxen( boxen(
chalk chalk.hex('#FF8800').bold(
.hex('#FF8800') // Use nextItem.id and nextItem.title
.bold( `🔥 Next Task to Work On: #${nextItem.id} - ${nextItem.title}`
`🔥 Next Task to Work On: #${nextTask.id} - ${nextTask.title}` ) +
) +
'\n\n' + '\n\n' +
`${chalk.white('Priority:')} ${priorityColors[nextTask.priority || 'medium'](nextTask.priority || 'medium')} ${chalk.white('Status:')} ${getStatusWithColor(nextTask.status, true)}\n` + // Use nextItem.priority, nextItem.status, nextItem.dependencies
`${chalk.white('Dependencies:')} ${nextTask.dependencies && nextTask.dependencies.length > 0 ? formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true) : chalk.gray('None')}\n\n` + `${chalk.white('Priority:')} ${priorityColors[nextItem.priority || 'medium'](nextItem.priority || 'medium')} ${chalk.white('Status:')} ${getStatusWithColor(nextItem.status, true)}\n` +
`${chalk.white('Description:')} ${nextTask.description}` + `${chalk.white('Dependencies:')} ${nextItem.dependencies && nextItem.dependencies.length > 0 ? formatDependenciesWithStatus(nextItem.dependencies, data.tasks, true) : chalk.gray('None')}\n\n` +
subtasksSection + // Use nextItem.description (Note: findNextTask doesn't return description, need to fetch original task/subtask for this)
// *** Fetching original item for description and details ***
`${chalk.white('Description:')} ${getWorkItemDescription(nextItem, data.tasks)}` +
subtasksSection + // <-- Subtasks are handled above now
'\n\n' + '\n\n' +
`${chalk.cyan('Start working:')} ${chalk.yellow(`task-master set-status --id=${nextTask.id} --status=in-progress`)}\n` + // Use nextItem.id
`${chalk.cyan('View details:')} ${chalk.yellow(`task-master show ${nextTask.id}`)}`, `${chalk.cyan('Start working:')} ${chalk.yellow(`task-master set-status --id=${nextItem.id} --status=in-progress`)}\n` +
// Use nextItem.id
`${chalk.cyan('View details:')} ${chalk.yellow(`task-master show ${nextItem.id}`)}`,
{ {
padding: { left: 2, right: 2, top: 1, bottom: 1 }, padding: { left: 2, right: 2, top: 1, bottom: 1 },
borderColor: '#FF8800', borderColor: '#FF8800',
@@ -635,8 +642,8 @@ function listTasks(
margin: { top: 1, bottom: 1 }, margin: { top: 1, bottom: 1 },
title: '⚡ RECOMMENDED NEXT TASK ⚡', title: '⚡ RECOMMENDED NEXT TASK ⚡',
titleAlignment: 'center', titleAlignment: 'center',
width: terminalWidth - 4, // Use full terminal width minus a small margin width: terminalWidth - 4,
fullscreen: false // Keep it expandable but not literally fullscreen fullscreen: false
} }
) )
); );
@@ -692,4 +699,21 @@ function listTasks(
} }
} }
// *** Helper function to get description for task or subtask ***
function getWorkItemDescription(item, allTasks) {
if (!item) return 'N/A';
if (item.parentId) {
// It's a subtask
const parent = allTasks.find((t) => t.id === item.parentId);
const subtask = parent?.subtasks?.find(
(st) => `${parent.id}.${st.id}` === item.id
);
return subtask?.description || 'No description available.';
} else {
// It's a top-level task
const task = allTasks.find((t) => String(t.id) === String(item.id));
return task?.description || 'No description available.';
}
}
export default listTasks; export default listTasks;