Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion mcp-server/src/core/direct-functions/move-task-cross-tag.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,23 @@ export async function moveTaskCrossTagDirect(args, log, context = {}) {
{ projectRoot }
);

// Check if any tasks were renumbered during the move
const renumberedTasks = (result.movedTasks || []).filter(
(t) => t.newId !== undefined
);
let message = `Successfully moved ${sourceIds.length} task(s) from "${args.sourceTag}" to "${args.targetTag}"`;
if (renumberedTasks.length > 0) {
const renumberDetails = renumberedTasks
.map((t) => `${t.originalId} → ${t.newId}`)
.join(', ');
message += `. Renumbered to avoid ID collisions: ${renumberDetails}`;
}

return {
success: true,
data: {
...result,
message: `Successfully moved ${sourceIds.length} task(s) from "${args.sourceTag}" to "${args.targetTag}"`,
message,
moveOptions,
sourceTag: args.sourceTag,
targetTag: args.targetTag
Expand Down
10 changes: 9 additions & 1 deletion scripts/modules/config-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ const DEFAULTS = {
responseLanguage: 'English',
enableCodebaseAnalysis: true,
enableProxy: false,
anonymousTelemetry: true // Allow users to opt out of Sentry telemetry for local storage
anonymousTelemetry: true, // Allow users to opt out of Sentry telemetry for local storage
slimDoneTasks: true // Auto-slim completed tasks to reduce tasks.json size
},
claudeCode: {},
codexCli: {},
Expand Down Expand Up @@ -747,6 +748,12 @@ function getAnonymousTelemetryEnabled(explicitRoot = null) {
return config.anonymousTelemetry !== false; // Default true if undefined
}

function isSlimDoneTasksEnabled(explicitRoot = null) {
// Return boolean-safe value with default true (opt-in by default)
const config = getGlobalConfig(explicitRoot);
return config.slimDoneTasks !== false; // Default true if undefined
}

function isProxyEnabled(session = null, projectRoot = null) {
// Priority 1: Environment variable
const envFlag = resolveEnvVariable(
Expand Down Expand Up @@ -1305,6 +1312,7 @@ export {
getProxyEnabled,
isProxyEnabled,
getAnonymousTelemetryEnabled,
isSlimDoneTasksEnabled,
getParametersForRole,
getUserId,
// Operating mode
Expand Down
139 changes: 123 additions & 16 deletions scripts/modules/task-manager/move-task.js
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,23 @@ async function resolveDependencies(
};
}

/**
* Get the next available task ID in the target tag.
* Finds the maximum existing ID among target tag tasks and any already-assigned
* new IDs from the current move batch, then returns max + 1.
* @param {Object} rawData - Raw data object
* @param {string} targetTag - Target tag name
* @param {Map} idRemapping - Map of old ID -> new ID for tasks already processed in this batch
* @returns {number} Next available task ID
*/
function getNextAvailableId(rawData, targetTag, idRemapping) {
const existingIds = rawData[targetTag].tasks.map((t) => t.id);
const remappedIds = Array.from(idRemapping.values());
const allIds = [...existingIds, ...remappedIds];
if (allIds.length === 0) return 1;
return Math.max(...allIds) + 1;
}

/**
* Execute the actual move operation
* @param {Array} tasksToMove - Array of task IDs to move
Expand All @@ -836,6 +853,8 @@ async function executeMoveOperation(
) {
const { projectRoot } = context;
const movedTasks = [];
// Track ID remapping: oldId -> newId (only for tasks that needed renumbering)
const idRemapping = new Map();

// Move each task from source to target tag
for (const taskId of tasksToMove) {
Expand All @@ -860,22 +879,39 @@ async function executeMoveOperation(
const existingTaskIndex = rawData[targetTag].tasks.findIndex(
(t) => t.id === normalizedTaskId
);

let assignedId = normalizedTaskId;

if (existingTaskIndex !== -1) {
throw new MoveTaskError(
MOVE_ERROR_CODES.TASK_ALREADY_EXISTS,
`Task ${taskId} already exists in target tag "${targetTag}"`,
{
conflictingId: normalizedTaskId,
targetTag,
suggestions: [
'Choose a different target tag without conflicting IDs',
'Move a different set of IDs (avoid existing ones)',
'If needed, move within-tag to a new ID first, then cross-tag move'
]
}
// ID collision detected — auto-renumber to the next available ID
assignedId = getNextAvailableId(rawData, targetTag, idRemapping);
idRemapping.set(normalizedTaskId, assignedId);
log(
'info',
`Task ${normalizedTaskId} conflicts with existing ID in "${targetTag}", renumbered to ${assignedId}`
);
}
Comment on lines 885 to 893
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This auto-renumbering behavior on ID conflicts is a significant semantic change (previously a collision threw TASK_ALREADY_EXISTS). It’s also unrelated to the PR’s stated purpose (auto-slimming done tasks). Either split this change into a separate PR or update the PR description/issues to reflect the cross-tag move behavior change and its implications for users.

Copilot uses AI. Check for mistakes.

// Apply the new ID to the task
taskToMove.id = assignedId;

// Update internal subtask dependency references that pointed to the old parent ID
if (Array.isArray(taskToMove.subtasks)) {
taskToMove.subtasks.forEach((subtask) => {
if (Array.isArray(subtask.dependencies)) {
subtask.dependencies = subtask.dependencies.map((dep) => {
if (typeof dep === 'string' && dep.includes('.')) {
const [depParent, depSub] = dep.split('.');
if (parseInt(depParent, 10) === normalizedTaskId) {
return `${assignedId}.${depSub}`;
}
}
return dep;
});
}
});
}

// Remove from source tag
rawData[sourceTag].tasks.splice(sourceTaskIndex, 1);

Expand All @@ -887,13 +923,65 @@ async function executeMoveOperation(
);
rawData[targetTag].tasks.push(taskWithPreservedMetadata);

movedTasks.push({
const moveEntry = {
id: taskId,
fromTag: sourceTag,
toTag: targetTag
});
};
if (assignedId !== normalizedTaskId) {
moveEntry.originalId = normalizedTaskId;
moveEntry.newId = assignedId;
}
movedTasks.push(moveEntry);

log('info', `Moved task ${taskId} from "${sourceTag}" to "${targetTag}"`);
log(
'info',
`Moved task ${taskId} from "${sourceTag}" to "${targetTag}"${assignedId !== normalizedTaskId ? ` (renumbered to ${assignedId})` : ''}`
);
}

// After all tasks are moved, update cross-references within the moved batch.
// If task A depended on task B and both were moved but B got renumbered,
// update A's dependency to point to B's new ID.
if (idRemapping.size > 0) {
for (const taskId of tasksToMove) {
const normalizedTaskId =
typeof taskId === 'string' ? parseInt(taskId, 10) : taskId;
// Find the task in the target tag (it may have been renumbered)
const finalId = idRemapping.get(normalizedTaskId) || normalizedTaskId;
const movedTask = rawData[targetTag].tasks.find(
(t) => t.id === finalId
);
if (movedTask && Array.isArray(movedTask.dependencies)) {
movedTask.dependencies = movedTask.dependencies.map((dep) => {
const normalizedDep = normalizeDependency(dep);
if (
Number.isFinite(normalizedDep) &&
idRemapping.has(normalizedDep)
) {
return idRemapping.get(normalizedDep);
}
return dep;
});
Comment on lines +955 to +965
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency remapping uses normalizeDependency(dep), which collapses dotted subtask dependencies like "2.1" to the parent task ID (2). If an ID is remapped, this code will replace a subtask dependency string with a numeric task ID, losing the .subtask portion and breaking dependency semantics. Preserve dotted dependency strings by only remapping the parent portion (and keep the original type/format) when applying idRemapping.

Copilot uses AI. Check for mistakes.
}
// Also update subtask dependencies that reference remapped IDs
if (movedTask && Array.isArray(movedTask.subtasks)) {
movedTask.subtasks.forEach((subtask) => {
if (Array.isArray(subtask.dependencies)) {
subtask.dependencies = subtask.dependencies.map((dep) => {
const normalizedDep = normalizeDependency(dep);
if (
Number.isFinite(normalizedDep) &&
idRemapping.has(normalizedDep)
) {
return idRemapping.get(normalizedDep);
}
return dep;
});
}
Comment on lines +968 to +981
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue for subtask dependencies: normalizeDependency(dep) will turn strings like "5.1" into 5, so when an ID is remapped this can overwrite a subtask dependency with a parent task ID and drop the subtask suffix. When remapping, detect dotted dependency strings and rewrite only the parent part (e.g., ${newParent}.${subId}) rather than replacing the whole value with a number.

Copilot uses AI. Check for mistakes.
});
}
}
}

return { rawData, movedTasks };
Expand Down Expand Up @@ -922,8 +1010,19 @@ async function finalizeMove(
// Write the updated data
writeJSON(tasksPath, rawData, projectRoot, null);

// Check if any tasks were renumbered during the move
const renumberedTasks = movedTasks.filter((t) => t.newId !== undefined);

let message = `Successfully moved ${movedTasks.length} tasks from "${sourceTag}" to "${targetTag}"`;
if (renumberedTasks.length > 0) {
const renumberDetails = renumberedTasks
.map((t) => `${t.originalId} → ${t.newId}`)
.join(', ');
message += `. Renumbered to avoid ID collisions: ${renumberDetails}`;
}

const response = {
message: `Successfully moved ${movedTasks.length} tasks from "${sourceTag}" to "${targetTag}"`,
message,
movedTasks
};

Expand All @@ -938,6 +1037,14 @@ async function finalizeMove(
];
}

// If tasks were renumbered, suggest validating dependencies
if (renumberedTasks.length > 0) {
if (!response.tips) response.tips = [];
response.tips.push(
'Some tasks were renumbered to avoid ID collisions. Run "task-master validate-dependencies" to verify dependency integrity.'
);
}

return response;
}

Expand Down
9 changes: 7 additions & 2 deletions scripts/modules/task-manager/set-task-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
TASK_STATUS_OPTIONS,
isValidTaskStatus
} from '../../../src/constants/task-status.js';
import { getDebugFlag } from '../config-manager.js';
import { getDebugFlag, isSlimDoneTasksEnabled } from '../config-manager.js';
import { validateTaskDependencies } from '../dependency-manager.js';
import { displayBanner } from '../ui.js';
import {
Expand Down Expand Up @@ -84,6 +84,9 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) {
const taskIds = taskIdInput.split(',').map((id) => id.trim());
const updatedTasks = [];

// Check if auto-slim on done is enabled
const slimOnDone = isSlimDoneTasksEnabled(projectRoot);

// Update each task and capture old status for display
for (const id of taskIds) {
// Capture old status before updating
Expand All @@ -106,7 +109,9 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) {
oldStatus = task?.status || 'pending';
}

await updateSingleTaskStatus(tasksPath, id, newStatus, data, !isMcpMode);
await updateSingleTaskStatus(tasksPath, id, newStatus, data, !isMcpMode, {
slimOnDone
});
updatedTasks.push({ id, oldStatus, newStatus });
}

Expand Down
Loading
Loading