feat(move-tasks): Implement move command for tasks and subtasks

Adds a new CLI command and MCP tool to reorganize tasks and subtasks within the hierarchy. Features include:
- Moving tasks between different positions in the task list
- Converting tasks to subtasks and vice versa
- Moving subtasks between parents
- Moving multiple tasks at once with comma-separated IDs
- Creating placeholder tasks when moving to new IDs
- Validation to prevent accidental data loss

This is particularly useful for resolving merge conflicts when multiple team members create tasks on different branches.
This commit is contained in:
Eyal Toledano
2025-05-22 04:14:22 -04:00
parent edf0f23005
commit 04af16de27
18 changed files with 1201 additions and 29 deletions

View File

@@ -0,0 +1,93 @@
/**
* Direct function wrapper for moveTask
*/
import { moveTask } from '../../../../scripts/modules/task-manager.js';
import { findTasksJsonPath } from '../utils/path-utils.js';
import {
enableSilentMode,
disableSilentMode
} from '../../../../scripts/modules/utils.js';
/**
* Move a task or subtask to a new position
* @param {Object} args - Function arguments
* @param {string} args.tasksJsonPath - Explicit path to the tasks.json file
* @param {string} args.sourceId - ID of the task/subtask to move (e.g., '5' or '5.2')
* @param {string} args.destinationId - ID of the destination (e.g., '7' or '7.3')
* @param {string} args.file - Alternative path to the tasks.json file
* @param {string} args.projectRoot - Project root directory
* @param {Object} log - Logger object
* @returns {Promise<{success: boolean, data?: Object, error?: Object}>}
*/
export async function moveTaskDirect(args, log, context = {}) {
const { session } = context;
// Validate required parameters
if (!args.sourceId) {
return {
success: false,
error: {
message: 'Source ID is required',
code: 'MISSING_SOURCE_ID'
}
};
}
if (!args.destinationId) {
return {
success: false,
error: {
message: 'Destination ID is required',
code: 'MISSING_DESTINATION_ID'
}
};
}
try {
// Find tasks.json path if not provided
let tasksPath = args.tasksJsonPath || args.file;
if (!tasksPath) {
if (!args.projectRoot) {
return {
success: false,
error: {
message: 'Project root is required if tasksJsonPath is not provided',
code: 'MISSING_PROJECT_ROOT'
}
};
}
tasksPath = findTasksJsonPath(args, log);
}
// Enable silent mode to prevent console output during MCP operation
enableSilentMode();
// Call the core moveTask function, always generate files
const result = await moveTask(tasksPath, args.sourceId, args.destinationId, true);
// Restore console output
disableSilentMode();
return {
success: true,
data: {
movedTask: result.movedTask,
message: `Successfully moved task/subtask ${args.sourceId} to ${args.destinationId}`
}
};
} catch (error) {
// Restore console output in case of error
disableSilentMode();
log.error(`Failed to move task: ${error.message}`);
return {
success: false,
error: {
message: error.message,
code: 'MOVE_TASK_ERROR'
}
};
}
}

View File

@@ -30,6 +30,7 @@ import { addDependencyDirect } from './direct-functions/add-dependency.js';
import { removeTaskDirect } from './direct-functions/remove-task.js';
import { initializeProjectDirect } from './direct-functions/initialize-project.js';
import { modelsDirect } from './direct-functions/models.js';
import { moveTaskDirect } from './direct-functions/move-task.js';
// Re-export utility functions
export { findTasksJsonPath } from './utils/path-utils.js';
@@ -60,7 +61,8 @@ export const directFunctions = new Map([
['addDependencyDirect', addDependencyDirect],
['removeTaskDirect', removeTaskDirect],
['initializeProjectDirect', initializeProjectDirect],
['modelsDirect', modelsDirect]
['modelsDirect', modelsDirect],
['moveTaskDirect', moveTaskDirect]
]);
// Re-export all direct function implementations
@@ -89,5 +91,6 @@ export {
addDependencyDirect,
removeTaskDirect,
initializeProjectDirect,
modelsDirect
modelsDirect,
moveTaskDirect
};

View File

@@ -28,6 +28,7 @@ import { registerAddDependencyTool } from './add-dependency.js';
import { registerRemoveTaskTool } from './remove-task.js';
import { registerInitializeProjectTool } from './initialize-project.js';
import { registerModelsTool } from './models.js';
import { registerMoveTaskTool } from './move-task.js';
/**
* Register all Task Master tools with the MCP server
@@ -61,6 +62,7 @@ export function registerTaskMasterTools(server) {
registerRemoveTaskTool(server);
registerRemoveSubtaskTool(server);
registerClearSubtasksTool(server);
registerMoveTaskTool(server);
// Group 5: Task Analysis & Expansion
registerAnalyzeProjectComplexityTool(server);

View File

@@ -0,0 +1,124 @@
/**
* tools/move-task.js
* Tool for moving tasks or subtasks to a new position
*/
import { z } from 'zod';
import {
handleApiResult,
createErrorResponse,
withNormalizedProjectRoot
} from './utils.js';
import { moveTaskDirect } from '../core/task-master-core.js';
import { findTasksJsonPath } from '../core/utils/path-utils.js';
/**
* Register the moveTask tool with the MCP server
* @param {Object} server - FastMCP server instance
*/
export function registerMoveTaskTool(server) {
server.addTool({
name: 'move_task',
description: 'Move a task or subtask to a new position',
parameters: z.object({
from: z.string().describe(
'ID of the task/subtask to move (e.g., "5" or "5.2"). Can be comma-separated to move multiple tasks (e.g., "5,6,7")'
),
to: z.string().describe(
'ID of the destination (e.g., "7" or "7.3"). Must match the number of source IDs if comma-separated'
),
file: z
.string()
.optional()
.describe('Custom path to tasks.json file'),
projectRoot: z
.string()
.optional()
.describe('Root directory of the project (typically derived from session)')
}),
execute: withNormalizedProjectRoot(async (args, { log, session }) => {
try {
// Find tasks.json path if not provided
let tasksJsonPath = args.file;
if (!tasksJsonPath) {
tasksJsonPath = findTasksJsonPath(args, log);
}
// Parse comma-separated IDs
const fromIds = args.from.split(',').map(id => id.trim());
const toIds = args.to.split(',').map(id => id.trim());
// Validate matching IDs count
if (fromIds.length !== toIds.length) {
return createErrorResponse(
'The number of source and destination IDs must match',
'MISMATCHED_ID_COUNT'
);
}
// If moving multiple tasks
if (fromIds.length > 1) {
const results = [];
// Move tasks one by one, only generate files on the last move
for (let i = 0; i < fromIds.length; i++) {
const fromId = fromIds[i];
const toId = toIds[i];
// Skip if source and destination are the same
if (fromId === toId) {
log.info(`Skipping ${fromId} -> ${toId} (same ID)`);
continue;
}
const shouldGenerateFiles = i === fromIds.length - 1;
const result = await moveTaskDirect(
{
sourceId: fromId,
destinationId: toId,
tasksJsonPath,
projectRoot: args.projectRoot
},
log,
{ session }
);
if (!result.success) {
log.error(`Failed to move ${fromId} to ${toId}: ${result.error.message}`);
} else {
results.push(result.data);
}
}
return {
success: true,
data: {
moves: results,
message: `Successfully moved ${results.length} tasks`
}
};
} else {
// Moving a single task
return handleApiResult(
await moveTaskDirect(
{
sourceId: args.from,
destinationId: args.to,
tasksJsonPath,
projectRoot: args.projectRoot
},
log,
{ session }
),
log
);
}
} catch (error) {
return createErrorResponse(
`Failed to move task: ${error.message}`,
'MOVE_TASK_ERROR'
);
}
})
});
}