fix(tasks): Improve next task logic to be subtask-aware
This commit is contained in:
10
.changeset/nine-rocks-sink.md
Normal file
10
.changeset/nine-rocks-sink.md
Normal 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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user