diff --git a/.changeset/cool-glasses-invite.md b/.changeset/cool-glasses-invite.md new file mode 100644 index 00000000..68eced60 --- /dev/null +++ b/.changeset/cool-glasses-invite.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Implement Boundary-First Tag Resolution to ensure consistent and deterministic tag handling across CLI and MCP, resolving potential race conditions. diff --git a/mcp-server/src/core/direct-functions/add-dependency.js b/mcp-server/src/core/direct-functions/add-dependency.js index b88eb4c6..70525e4d 100644 --- a/mcp-server/src/core/direct-functions/add-dependency.js +++ b/mcp-server/src/core/direct-functions/add-dependency.js @@ -16,12 +16,14 @@ import { * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. * @param {string|number} args.id - Task ID to add dependency to * @param {string|number} args.dependsOn - Task ID that will become a dependency + * @param {string} args.tag - Tag for the task (optional) + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) * @param {Object} log - Logger object * @returns {Promise} - Result object with success status and data/error information */ export async function addDependencyDirect(args, log) { // Destructure expected args - const { tasksJsonPath, id, dependsOn } = args; + const { tasksJsonPath, id, dependsOn, tag, projectRoot } = args; try { log.info(`Adding dependency with args: ${JSON.stringify(args)}`); @@ -76,8 +78,11 @@ export async function addDependencyDirect(args, log) { // Enable silent mode to prevent console logs from interfering with JSON response enableSilentMode(); + // Create context object + const context = { projectRoot, tag }; + // Call the core function using the provided path - await addDependency(tasksPath, taskId, dependencyId); + await addDependency(tasksPath, taskId, dependencyId, context); // Restore normal logging disableSilentMode(); diff --git a/mcp-server/src/core/direct-functions/add-task.js b/mcp-server/src/core/direct-functions/add-task.js index 476fd062..fc29e8a9 100644 --- a/mcp-server/src/core/direct-functions/add-task.js +++ b/mcp-server/src/core/direct-functions/add-task.js @@ -24,6 +24,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {string} [args.tasksJsonPath] - Path to the tasks.json file (resolved by tool) * @param {boolean} [args.research=false] - Whether to use research capabilities for task creation * @param {string} [args.projectRoot] - Project root path + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object * @param {Object} context - Additional context (session) * @returns {Promise} - Result object { success: boolean, data?: any, error?: { code: string, message: string } } @@ -36,7 +37,8 @@ export async function addTaskDirect(args, log, context = {}) { dependencies, priority, research, - projectRoot + projectRoot, + tag } = args; const { session } = context; // Destructure session from context @@ -121,7 +123,8 @@ export async function addTaskDirect(args, log, context = {}) { mcpLog, projectRoot, commandName: 'add-task', - outputType: 'mcp' + outputType: 'mcp', + tag }, 'json', // outputFormat manualTaskData, // Pass the manual task data @@ -147,7 +150,8 @@ export async function addTaskDirect(args, log, context = {}) { mcpLog, projectRoot, commandName: 'add-task', - outputType: 'mcp' + outputType: 'mcp', + tag }, 'json', // outputFormat null, // manualTaskData is null for AI creation diff --git a/mcp-server/src/core/direct-functions/analyze-task-complexity.js b/mcp-server/src/core/direct-functions/analyze-task-complexity.js index 8a5cfa60..c2500eef 100644 --- a/mcp-server/src/core/direct-functions/analyze-task-complexity.js +++ b/mcp-server/src/core/direct-functions/analyze-task-complexity.js @@ -22,6 +22,7 @@ import { createLogWrapper } from '../../tools/utils.js'; // Import the new utili * @param {number} [args.from] - Starting task ID in a range to analyze * @param {number} [args.to] - Ending task ID in a range to analyze * @param {string} [args.projectRoot] - Project root path. + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object * @param {Object} [context={}] - Context object containing session data * @param {Object} [context.session] - MCP session object @@ -37,7 +38,8 @@ export async function analyzeTaskComplexityDirect(args, log, context = {}) { projectRoot, ids, from, - to + to, + tag } = args; const logWrapper = createLogWrapper(log); @@ -91,7 +93,8 @@ export async function analyzeTaskComplexityDirect(args, log, context = {}) { projectRoot: projectRoot, // Pass projectRoot here id: ids, // Pass the ids parameter to the core function as 'id' from: from, // Pass from parameter - to: to // Pass to parameter + to: to, // Pass to parameter + tag // forward tag }; // --- End Initial Checks --- @@ -112,7 +115,9 @@ export async function analyzeTaskComplexityDirect(args, log, context = {}) { session, mcpLog: logWrapper, commandName: 'analyze-complexity', - outputType: 'mcp' + outputType: 'mcp', + projectRoot, + tag }); report = coreResult.report; } catch (error) { diff --git a/mcp-server/src/core/direct-functions/clear-subtasks.js b/mcp-server/src/core/direct-functions/clear-subtasks.js index 7aabb807..0fbb9546 100644 --- a/mcp-server/src/core/direct-functions/clear-subtasks.js +++ b/mcp-server/src/core/direct-functions/clear-subtasks.js @@ -18,6 +18,7 @@ import path from 'path'; * @param {string} [args.id] - Task IDs (comma-separated) to clear subtasks from * @param {boolean} [args.all] - Clear subtasks from all tasks * @param {string} [args.tag] - Tag context to operate on (defaults to current active tag) + * @param {string} [args.projectRoot] - Project root path (for MCP/env fallback) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ @@ -80,7 +81,7 @@ export async function clearSubtasksDirect(args, log) { }; } - const currentTag = data.tag || 'master'; + const currentTag = data.tag || tag; const tasks = data.tasks; // If all is specified, get all task IDs diff --git a/mcp-server/src/core/direct-functions/expand-all-tasks.js b/mcp-server/src/core/direct-functions/expand-all-tasks.js index 4d1a8a74..531b847e 100644 --- a/mcp-server/src/core/direct-functions/expand-all-tasks.js +++ b/mcp-server/src/core/direct-functions/expand-all-tasks.js @@ -18,6 +18,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {string} [args.prompt] - Additional context to guide subtask generation * @param {boolean} [args.force] - Force regeneration of subtasks for tasks that already have them * @param {string} [args.projectRoot] - Project root path. + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object from FastMCP * @param {Object} context - Context object containing session * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} @@ -25,7 +26,8 @@ import { createLogWrapper } from '../../tools/utils.js'; export async function expandAllTasksDirect(args, log, context = {}) { const { session } = context; // Extract session // Destructure expected args, including projectRoot - const { tasksJsonPath, num, research, prompt, force, projectRoot } = args; + const { tasksJsonPath, num, research, prompt, force, projectRoot, tag } = + args; // Create logger wrapper using the utility const mcpLog = createLogWrapper(log); @@ -44,7 +46,7 @@ export async function expandAllTasksDirect(args, log, context = {}) { enableSilentMode(); // Enable silent mode for the core function call try { log.info( - `Calling core expandAllTasks with args: ${JSON.stringify({ num, research, prompt, force, projectRoot })}` + `Calling core expandAllTasks with args: ${JSON.stringify({ num, research, prompt, force, projectRoot, tag })}` ); // Parse parameters (ensure correct types) @@ -60,7 +62,7 @@ export async function expandAllTasksDirect(args, log, context = {}) { useResearch, additionalContext, forceFlag, - { session, mcpLog, projectRoot }, + { session, mcpLog, projectRoot, tag }, 'json' ); diff --git a/mcp-server/src/core/direct-functions/expand-task.js b/mcp-server/src/core/direct-functions/expand-task.js index d231a95c..ccf51057 100644 --- a/mcp-server/src/core/direct-functions/expand-task.js +++ b/mcp-server/src/core/direct-functions/expand-task.js @@ -35,8 +35,17 @@ import { createLogWrapper } from '../../tools/utils.js'; export async function expandTaskDirect(args, log, context = {}) { const { session } = context; // Extract session // Destructure expected args, including projectRoot - const { tasksJsonPath, id, num, research, prompt, force, projectRoot, tag } = - args; + const { + tasksJsonPath, + id, + num, + research, + prompt, + force, + projectRoot, + tag, + complexityReportPath + } = args; // Log session root data for debugging log.info( @@ -192,6 +201,7 @@ export async function expandTaskDirect(args, log, context = {}) { useResearch, additionalContext, { + complexityReportPath, mcpLog, session, projectRoot, diff --git a/mcp-server/src/core/direct-functions/fix-dependencies.js b/mcp-server/src/core/direct-functions/fix-dependencies.js index 7bfceddf..5f7b61d5 100644 --- a/mcp-server/src/core/direct-functions/fix-dependencies.js +++ b/mcp-server/src/core/direct-functions/fix-dependencies.js @@ -53,10 +53,9 @@ export async function fixDependenciesDirect(args, log) { // Enable silent mode to prevent console logs from interfering with JSON response enableSilentMode(); + const options = { projectRoot, tag }; // Call the original command function using the provided path and proper context - await fixDependenciesCommand(tasksPath, { - context: { projectRoot, tag } - }); + await fixDependenciesCommand(tasksPath, options); // Restore normal logging disableSilentMode(); diff --git a/mcp-server/src/core/direct-functions/generate-task-files.js b/mcp-server/src/core/direct-functions/generate-task-files.js index e9b61dcc..f42a602a 100644 --- a/mcp-server/src/core/direct-functions/generate-task-files.js +++ b/mcp-server/src/core/direct-functions/generate-task-files.js @@ -13,12 +13,16 @@ import { * Direct function wrapper for generateTaskFiles with error handling. * * @param {Object} args - Command arguments containing tasksJsonPath and outputDir. + * @param {string} args.tasksJsonPath - Path to the tasks.json file. + * @param {string} args.outputDir - Path to the output directory. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object. * @returns {Promise} - Result object with success status and data/error information. */ export async function generateTaskFilesDirect(args, log) { // Destructure expected args - const { tasksJsonPath, outputDir } = args; + const { tasksJsonPath, outputDir, projectRoot, tag } = args; try { log.info(`Generating task files with args: ${JSON.stringify(args)}`); @@ -51,8 +55,12 @@ export async function generateTaskFilesDirect(args, log) { // Enable silent mode to prevent logs from being written to stdout enableSilentMode(); - // The function is synchronous despite being awaited elsewhere - generateTaskFiles(tasksPath, resolvedOutputDir); + // Pass projectRoot and tag so the core respects context + generateTaskFiles(tasksPath, resolvedOutputDir, { + projectRoot, + tag, + mcpLog: log + }); // Restore normal logging after task generation disableSilentMode(); diff --git a/mcp-server/src/core/direct-functions/list-tasks.js b/mcp-server/src/core/direct-functions/list-tasks.js index 36ccc01b..9511f43b 100644 --- a/mcp-server/src/core/direct-functions/list-tasks.js +++ b/mcp-server/src/core/direct-functions/list-tasks.js @@ -13,12 +13,19 @@ import { * Direct function wrapper for listTasks with error handling and caching. * * @param {Object} args - Command arguments (now expecting tasksJsonPath explicitly). + * @param {string} args.tasksJsonPath - Path to the tasks.json file. + * @param {string} args.reportPath - Path to the report file. + * @param {string} args.status - Status of the task. + * @param {boolean} args.withSubtasks - Whether to include subtasks. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object. * @returns {Promise} - Task list result { success: boolean, data?: any, error?: { code: string, message: string } }. */ export async function listTasksDirect(args, log, context = {}) { // Destructure the explicit tasksJsonPath from args - const { tasksJsonPath, reportPath, status, withSubtasks, projectRoot } = args; + const { tasksJsonPath, reportPath, status, withSubtasks, projectRoot, tag } = + args; const { session } = context; if (!tasksJsonPath) { @@ -52,8 +59,7 @@ export async function listTasksDirect(args, log, context = {}) { reportPath, withSubtasksFilter, 'json', - null, // tag - { projectRoot, session } // context + { projectRoot, session, tag } ); if (!resultData || !resultData.tasks) { diff --git a/mcp-server/src/core/direct-functions/move-task.js b/mcp-server/src/core/direct-functions/move-task.js index 9cc06d61..7042a051 100644 --- a/mcp-server/src/core/direct-functions/move-task.js +++ b/mcp-server/src/core/direct-functions/move-task.js @@ -17,12 +17,14 @@ import { * @param {string} args.destinationId - ID of the destination (e.g., '7' or '7.3' or '7,8,9') * @param {string} args.file - Alternative path to the tasks.json file * @param {string} args.projectRoot - Project root directory + * @param {string} args.tag - Tag for the task (optional) * @param {boolean} args.generateFiles - Whether to regenerate task files after moving (default: true) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: Object}>} */ export async function moveTaskDirect(args, log, context = {}) { const { session } = context; + const { projectRoot, tag } = args; // Validate required parameters if (!args.sourceId) { @@ -73,8 +75,8 @@ export async function moveTaskDirect(args, log, context = {}) { args.destinationId, generateFiles, { - projectRoot: args.projectRoot, - tag: args.tag + projectRoot, + tag } ); diff --git a/mcp-server/src/core/direct-functions/next-task.js b/mcp-server/src/core/direct-functions/next-task.js index be77525b..be3f08d9 100644 --- a/mcp-server/src/core/direct-functions/next-task.js +++ b/mcp-server/src/core/direct-functions/next-task.js @@ -18,12 +18,15 @@ import { * * @param {Object} args - Command arguments * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. + * @param {string} args.reportPath - Path to the report file. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise} - Next task result { success: boolean, data?: any, error?: { code: string, message: string } } */ export async function nextTaskDirect(args, log, context = {}) { // Destructure expected args - const { tasksJsonPath, reportPath, projectRoot } = args; + const { tasksJsonPath, reportPath, projectRoot, tag } = args; const { session } = context; if (!tasksJsonPath) { @@ -46,7 +49,7 @@ export async function nextTaskDirect(args, log, context = {}) { log.info(`Finding next task from ${tasksJsonPath}`); // Read tasks data using the provided path - const data = readJSON(tasksJsonPath, projectRoot); + const data = readJSON(tasksJsonPath, projectRoot, tag); if (!data || !data.tasks) { disableSilentMode(); // Disable before return return { diff --git a/mcp-server/src/core/direct-functions/parse-prd.js b/mcp-server/src/core/direct-functions/parse-prd.js index 7c269547..75a3337b 100644 --- a/mcp-server/src/core/direct-functions/parse-prd.js +++ b/mcp-server/src/core/direct-functions/parse-prd.js @@ -20,6 +20,13 @@ import { TASKMASTER_TASKS_FILE } from '../../../../src/constants/paths.js'; * Direct function wrapper for parsing PRD documents and generating tasks. * * @param {Object} args - Command arguments containing projectRoot, input, output, numTasks options. + * @param {string} args.input - Path to the input PRD file. + * @param {string} args.output - Path to the output directory. + * @param {string} args.numTasks - Number of tasks to generate. + * @param {boolean} args.force - Whether to force parsing. + * @param {boolean} args.append - Whether to append to the output file. + * @param {boolean} args.research - Whether to use research mode. + * @param {string} args.tag - Tag context for organizing tasks into separate task lists. * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. @@ -34,7 +41,8 @@ export async function parsePRDDirect(args, log, context = {}) { force, append, research, - projectRoot + projectRoot, + tag } = args; // Create the standard logger wrapper @@ -152,6 +160,7 @@ export async function parsePRDDirect(args, log, context = {}) { session, mcpLog: logWrapper, projectRoot, + tag, force, append, research, diff --git a/mcp-server/src/core/direct-functions/remove-dependency.js b/mcp-server/src/core/direct-functions/remove-dependency.js index 9726da13..d5d3d2e4 100644 --- a/mcp-server/src/core/direct-functions/remove-dependency.js +++ b/mcp-server/src/core/direct-functions/remove-dependency.js @@ -14,12 +14,14 @@ import { * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. * @param {string|number} args.id - Task ID to remove dependency from * @param {string|number} args.dependsOn - Task ID to remove as a dependency + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function removeDependencyDirect(args, log) { // Destructure expected args - const { tasksJsonPath, id, dependsOn } = args; + const { tasksJsonPath, id, dependsOn, projectRoot, tag } = args; try { log.info(`Removing dependency with args: ${JSON.stringify(args)}`); @@ -75,7 +77,10 @@ export async function removeDependencyDirect(args, log) { enableSilentMode(); // Call the core function using the provided tasksPath - await removeDependency(tasksPath, taskId, dependencyId); + await removeDependency(tasksPath, taskId, dependencyId, { + projectRoot, + tag + }); // Restore normal logging disableSilentMode(); diff --git a/mcp-server/src/core/direct-functions/remove-subtask.js b/mcp-server/src/core/direct-functions/remove-subtask.js index c71c8a51..3b2f16cd 100644 --- a/mcp-server/src/core/direct-functions/remove-subtask.js +++ b/mcp-server/src/core/direct-functions/remove-subtask.js @@ -15,12 +15,14 @@ import { * @param {string} args.id - Subtask ID in format "parentId.subtaskId" (required) * @param {boolean} [args.convert] - Whether to convert the subtask to a standalone task * @param {boolean} [args.skipGenerate] - Skip regenerating task files + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function removeSubtaskDirect(args, log) { // Destructure expected args - const { tasksJsonPath, id, convert, skipGenerate } = args; + const { tasksJsonPath, id, convert, skipGenerate, projectRoot, tag } = args; try { // Enable silent mode to prevent console logs from interfering with JSON response enableSilentMode(); @@ -82,7 +84,11 @@ export async function removeSubtaskDirect(args, log) { tasksPath, id, convertToTask, - generateFiles + generateFiles, + { + projectRoot, + tag + } ); // Restore normal logging diff --git a/mcp-server/src/core/direct-functions/remove-task.js b/mcp-server/src/core/direct-functions/remove-task.js index 33842249..63639454 100644 --- a/mcp-server/src/core/direct-functions/remove-task.js +++ b/mcp-server/src/core/direct-functions/remove-task.js @@ -20,7 +20,8 @@ import { * @param {Object} args - Command arguments * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. * @param {string} args.id - The ID(s) of the task(s) or subtask(s) to remove (comma-separated for multiple). - * @param {string} [args.tag] - Tag context to operate on (defaults to current active tag). + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise} - Remove task result { success: boolean, data?: any, error?: { code: string, message: string } } */ @@ -117,7 +118,7 @@ export async function removeTaskDirect(args, log, context = {}) { removedTasks: result.removedTasks, message: result.message, tasksPath: tasksJsonPath, - tag: data.tag || tag || 'master' + tag } }; } finally { diff --git a/mcp-server/src/core/direct-functions/research.js b/mcp-server/src/core/direct-functions/research.js index e6feee29..6b90c124 100644 --- a/mcp-server/src/core/direct-functions/research.js +++ b/mcp-server/src/core/direct-functions/research.js @@ -24,6 +24,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {string} [args.saveTo] - Automatically save to task/subtask ID (e.g., "15" or "15.2") * @param {boolean} [args.saveToFile=false] - Save research results to .taskmaster/docs/research/ directory * @param {string} [args.projectRoot] - Project root path + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object * @param {Object} context - Additional context (session) * @returns {Promise} - Result object { success: boolean, data?: any, error?: { code: string, message: string } } @@ -39,7 +40,8 @@ export async function researchDirect(args, log, context = {}) { detailLevel = 'medium', saveTo, saveToFile = false, - projectRoot + projectRoot, + tag } = args; const { session } = context; // Destructure session from context @@ -111,6 +113,7 @@ export async function researchDirect(args, log, context = {}) { includeProjectTree, detailLevel, projectRoot, + tag, saveToFile }; @@ -169,7 +172,8 @@ ${result.result}`; mcpLog, commandName: 'research-save', outputType: 'mcp', - projectRoot + projectRoot, + tag }, 'json' ); @@ -200,7 +204,8 @@ ${result.result}`; mcpLog, commandName: 'research-save', outputType: 'mcp', - projectRoot + projectRoot, + tag }, 'json', true // appendMode = true diff --git a/mcp-server/src/core/direct-functions/set-task-status.js b/mcp-server/src/core/direct-functions/set-task-status.js index aacd94fc..08dca8a7 100644 --- a/mcp-server/src/core/direct-functions/set-task-status.js +++ b/mcp-server/src/core/direct-functions/set-task-status.js @@ -14,6 +14,11 @@ import { nextTaskDirect } from './next-task.js'; * Direct function wrapper for setTaskStatus with error handling. * * @param {Object} args - Command arguments containing id, status, tasksJsonPath, and projectRoot. + * @param {string} args.id - The ID of the task to update. + * @param {string} args.status - The new status to set for the task. + * @param {string} args.tasksJsonPath - Path to the tasks.json file. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object. * @param {Object} context - Additional context (session) * @returns {Promise} - Result object with success status and data/error information. @@ -70,17 +75,12 @@ export async function setTaskStatusDirect(args, log, context = {}) { enableSilentMode(); // Enable silent mode before calling core function try { // Call the core function - await setTaskStatus( - tasksPath, - taskId, - newStatus, - { - mcpLog: log, - projectRoot, - session - }, + await setTaskStatus(tasksPath, taskId, newStatus, { + mcpLog: log, + projectRoot, + session, tag - ); + }); log.info(`Successfully set task ${taskId} status to ${newStatus}`); @@ -103,7 +103,8 @@ export async function setTaskStatusDirect(args, log, context = {}) { { tasksJsonPath: tasksJsonPath, reportPath: complexityReportPath, - projectRoot: projectRoot + projectRoot: projectRoot, + tag }, log, { session } diff --git a/mcp-server/src/core/direct-functions/show-task.js b/mcp-server/src/core/direct-functions/show-task.js index e1ea6b0c..9e168615 100644 --- a/mcp-server/src/core/direct-functions/show-task.js +++ b/mcp-server/src/core/direct-functions/show-task.js @@ -19,6 +19,7 @@ import { findTasksPath } from '../utils/path-utils.js'; * @param {string} args.reportPath - Explicit path to the complexity report file. * @param {string} [args.status] - Optional status to filter subtasks by. * @param {string} args.projectRoot - Absolute path to the project root directory (already normalized by tool). + * @param {string} [args.tag] - Tag for the task * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. @@ -26,7 +27,7 @@ import { findTasksPath } from '../utils/path-utils.js'; export async function showTaskDirect(args, log) { // This function doesn't need session context since it only reads data // Destructure projectRoot and other args. projectRoot is assumed normalized. - const { id, file, reportPath, status, projectRoot } = args; + const { id, file, reportPath, status, projectRoot, tag } = args; log.info( `Showing task direct function. ID: ${id}, File: ${file}, Status Filter: ${status}, ProjectRoot: ${projectRoot}` @@ -55,7 +56,7 @@ export async function showTaskDirect(args, log) { // --- Rest of the function remains the same, using tasksJsonPath --- try { - const tasksData = readJSON(tasksJsonPath, projectRoot); + const tasksData = readJSON(tasksJsonPath, projectRoot, tag); if (!tasksData || !tasksData.tasks) { return { success: false, diff --git a/mcp-server/src/core/direct-functions/update-subtask-by-id.js b/mcp-server/src/core/direct-functions/update-subtask-by-id.js index c1310294..fd9faa99 100644 --- a/mcp-server/src/core/direct-functions/update-subtask-by-id.js +++ b/mcp-server/src/core/direct-functions/update-subtask-by-id.js @@ -20,6 +20,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {string} args.prompt - Information to append to the subtask. * @param {boolean} [args.research] - Whether to use research role. * @param {string} [args.projectRoot] - Project root path. + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. @@ -27,7 +28,7 @@ import { createLogWrapper } from '../../tools/utils.js'; export async function updateSubtaskByIdDirect(args, log, context = {}) { const { session } = context; // Destructure expected args, including projectRoot - const { tasksJsonPath, id, prompt, research, projectRoot } = args; + const { tasksJsonPath, id, prompt, research, projectRoot, tag } = args; const logWrapper = createLogWrapper(log); @@ -112,6 +113,7 @@ export async function updateSubtaskByIdDirect(args, log, context = {}) { mcpLog: logWrapper, session, projectRoot, + tag, commandName: 'update-subtask', outputType: 'mcp' }, diff --git a/mcp-server/src/core/direct-functions/update-task-by-id.js b/mcp-server/src/core/direct-functions/update-task-by-id.js index 5eead3ea..b7b5570d 100644 --- a/mcp-server/src/core/direct-functions/update-task-by-id.js +++ b/mcp-server/src/core/direct-functions/update-task-by-id.js @@ -21,6 +21,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {boolean} [args.research] - Whether to use research role. * @param {boolean} [args.append] - Whether to append timestamped information instead of full update. * @param {string} [args.projectRoot] - Project root path. + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. @@ -28,7 +29,8 @@ import { createLogWrapper } from '../../tools/utils.js'; export async function updateTaskByIdDirect(args, log, context = {}) { const { session } = context; // Destructure expected args, including projectRoot - const { tasksJsonPath, id, prompt, research, append, projectRoot } = args; + const { tasksJsonPath, id, prompt, research, append, projectRoot, tag } = + args; const logWrapper = createLogWrapper(log); @@ -116,6 +118,7 @@ export async function updateTaskByIdDirect(args, log, context = {}) { mcpLog: logWrapper, session, projectRoot, + tag, commandName: 'update-task', outputType: 'mcp' }, diff --git a/mcp-server/src/core/direct-functions/update-tasks.js b/mcp-server/src/core/direct-functions/update-tasks.js index 36d1ef4e..ac05bfa0 100644 --- a/mcp-server/src/core/direct-functions/update-tasks.js +++ b/mcp-server/src/core/direct-functions/update-tasks.js @@ -15,6 +15,12 @@ import { * Direct function wrapper for updating tasks based on new context. * * @param {Object} args - Command arguments containing projectRoot, from, prompt, research options. + * @param {string} args.from - The ID of the task to update. + * @param {string} args.prompt - The prompt to update the task with. + * @param {boolean} args.research - Whether to use research mode. + * @param {string} args.tasksJsonPath - Path to the tasks.json file. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. diff --git a/mcp-server/src/core/direct-functions/validate-dependencies.js b/mcp-server/src/core/direct-functions/validate-dependencies.js index a99aa47f..4ab6f1d7 100644 --- a/mcp-server/src/core/direct-functions/validate-dependencies.js +++ b/mcp-server/src/core/direct-functions/validate-dependencies.js @@ -13,12 +13,14 @@ import fs from 'fs'; * Validate dependencies in tasks.json * @param {Object} args - Function arguments * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function validateDependenciesDirect(args, log) { // Destructure the explicit tasksJsonPath - const { tasksJsonPath } = args; + const { tasksJsonPath, projectRoot, tag } = args; if (!tasksJsonPath) { log.error('validateDependenciesDirect called without tasksJsonPath'); @@ -51,8 +53,9 @@ export async function validateDependenciesDirect(args, log) { // Enable silent mode to prevent console logs from interfering with JSON response enableSilentMode(); + const options = { projectRoot, tag }; // Call the original command function using the provided tasksPath - await validateDependenciesCommand(tasksPath); + await validateDependenciesCommand(tasksPath, options); // Restore normal logging disableSilentMode(); diff --git a/mcp-server/src/core/utils/path-utils.js b/mcp-server/src/core/utils/path-utils.js index be4c8462..9aa5ef6d 100644 --- a/mcp-server/src/core/utils/path-utils.js +++ b/mcp-server/src/core/utils/path-utils.js @@ -121,6 +121,7 @@ export function resolveComplexityReportPath(args, log = silentLogger) { // Get explicit path from args.complexityReport if provided const explicitPath = args?.complexityReport; const rawProjectRoot = args?.projectRoot; + const tag = args?.tag; // If explicit path is provided and absolute, use it directly if (explicitPath && path.isAbsolute(explicitPath)) { @@ -139,7 +140,11 @@ export function resolveComplexityReportPath(args, log = silentLogger) { // Use core findComplexityReportPath with explicit path and normalized projectRoot context if (projectRoot) { - return coreFindComplexityReportPath(explicitPath, { projectRoot }, log); + return coreFindComplexityReportPath( + explicitPath, + { projectRoot, tag }, + log + ); } // Fallback to core function without projectRoot context diff --git a/mcp-server/src/tools/add-dependency.js b/mcp-server/src/tools/add-dependency.js index 7b3a6bf4..d922a3ef 100644 --- a/mcp-server/src/tools/add-dependency.js +++ b/mcp-server/src/tools/add-dependency.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { addDependencyDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the addDependency tool with the MCP server @@ -33,14 +34,18 @@ export function registerAddDependencyTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info( `Adding dependency for task ${args.id} to depend on ${args.dependsOn}` ); - + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); let tasksJsonPath; try { tasksJsonPath = findTasksPath( @@ -61,7 +66,9 @@ export function registerAddDependencyTool(server) { tasksJsonPath: tasksJsonPath, // Pass other relevant args id: args.id, - dependsOn: args.dependsOn + dependsOn: args.dependsOn, + projectRoot: args.projectRoot, + tag: resolvedTag }, log // Remove context object diff --git a/mcp-server/src/tools/add-subtask.js b/mcp-server/src/tools/add-subtask.js index 8d1fda44..62ddffe8 100644 --- a/mcp-server/src/tools/add-subtask.js +++ b/mcp-server/src/tools/add-subtask.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { addSubtaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the addSubtask tool with the MCP server @@ -52,17 +53,21 @@ export function registerAddSubtaskTool(server) { .describe( 'Absolute path to the tasks file (default: tasks/tasks.json)' ), - tag: z.string().optional().describe('Tag context to operate on'), skipGenerate: z .boolean() .optional() .describe('Skip regenerating task files'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info(`Adding subtask with args: ${JSON.stringify(args)}`); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) @@ -91,7 +96,7 @@ export function registerAddSubtaskTool(server) { dependencies: args.dependencies, skipGenerate: args.skipGenerate, projectRoot: args.projectRoot, - tag: args.tag + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/add-task.js b/mcp-server/src/tools/add-task.js index 56e9e6c4..3ad43a94 100644 --- a/mcp-server/src/tools/add-task.js +++ b/mcp-server/src/tools/add-task.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { addTaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the addTask tool with the MCP server @@ -58,6 +59,7 @@ export function registerAddTaskTool(server) { projectRoot: z .string() .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on'), research: z .boolean() .optional() @@ -67,6 +69,11 @@ export function registerAddTaskTool(server) { try { log.info(`Starting add-task with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -93,7 +100,8 @@ export function registerAddTaskTool(server) { dependencies: args.dependencies, priority: args.priority, research: args.research, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/analyze.js b/mcp-server/src/tools/analyze.js index ff39e049..871c90da 100644 --- a/mcp-server/src/tools/analyze.js +++ b/mcp-server/src/tools/analyze.js @@ -13,7 +13,9 @@ import { } from './utils.js'; import { analyzeTaskComplexityDirect } from '../core/task-master-core.js'; // Assuming core functions are exported via task-master-core.js import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; import { COMPLEXITY_REPORT_FILE } from '../../../src/constants/paths.js'; +import { resolveComplexityReportOutputPath } from '../../../src/utils/path-utils.js'; /** * Register the analyze_project_complexity tool @@ -70,15 +72,22 @@ export function registerAnalyzeProjectComplexityTool(server) { .describe('Ending task ID in a range to analyze.'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { const toolName = 'analyze_project_complexity'; // Define tool name for logging + try { log.info( `Executing ${toolName} tool with args: ${JSON.stringify(args)}` ); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + let tasksJsonPath; try { tasksJsonPath = findTasksPath( @@ -93,9 +102,14 @@ export function registerAnalyzeProjectComplexityTool(server) { ); } - const outputPath = args.output - ? path.resolve(args.projectRoot, args.output) - : path.resolve(args.projectRoot, COMPLEXITY_REPORT_FILE); + const outputPath = resolveComplexityReportOutputPath( + args.output, + { + projectRoot: args.projectRoot, + tag: resolvedTag + }, + log + ); log.info(`${toolName}: Report output path: ${outputPath}`); @@ -123,6 +137,7 @@ export function registerAnalyzeProjectComplexityTool(server) { threshold: args.threshold, research: args.research, projectRoot: args.projectRoot, + tag: resolvedTag, ids: args.ids, from: args.from, to: args.to diff --git a/mcp-server/src/tools/clear-subtasks.js b/mcp-server/src/tools/clear-subtasks.js index 4bff2bcc..6a0d0bff 100644 --- a/mcp-server/src/tools/clear-subtasks.js +++ b/mcp-server/src/tools/clear-subtasks.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { clearSubtasksDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the clearSubtasks tool with the MCP server @@ -46,6 +47,11 @@ export function registerClearSubtasksTool(server) { try { log.info(`Clearing subtasks with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -65,8 +71,9 @@ export function registerClearSubtasksTool(server) { tasksJsonPath: tasksJsonPath, id: args.id, all: args.all, + projectRoot: args.projectRoot, - tag: args.tag || 'master' + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/complexity-report.js b/mcp-server/src/tools/complexity-report.js index 626bc815..0dfa401a 100644 --- a/mcp-server/src/tools/complexity-report.js +++ b/mcp-server/src/tools/complexity-report.js @@ -12,6 +12,7 @@ import { import { complexityReportDirect } from '../core/task-master-core.js'; import { COMPLEXITY_REPORT_FILE } from '../../../src/constants/paths.js'; import { findComplexityReportPath } from '../core/utils/path-utils.js'; +import { getCurrentTag } from '../../../scripts/modules/utils.js'; /** * Register the complexityReport tool with the MCP server @@ -38,12 +39,16 @@ export function registerComplexityReportTool(server) { `Getting complexity report with args: ${JSON.stringify(args)}` ); + const resolvedTag = getCurrentTag(args.projectRoot); + const pathArgs = { projectRoot: args.projectRoot, - complexityReport: args.file + complexityReport: args.file, + tag: resolvedTag }; const reportPath = findComplexityReportPath(pathArgs, log); + log.info('Reading complexity report from path: ', reportPath); if (!reportPath) { return createErrorResponse( diff --git a/mcp-server/src/tools/expand-all.js b/mcp-server/src/tools/expand-all.js index 4fa07a26..08823016 100644 --- a/mcp-server/src/tools/expand-all.js +++ b/mcp-server/src/tools/expand-all.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { expandAllTasksDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the expandAll tool with the MCP server @@ -57,7 +58,8 @@ export function registerExpandAllTool(server) { .optional() .describe( 'Absolute path to the project root directory (derived from session if possible)' - ) + ), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { @@ -65,6 +67,10 @@ export function registerExpandAllTool(server) { `Tool expand_all execution started with args: ${JSON.stringify(args)}` ); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); let tasksJsonPath; try { tasksJsonPath = findTasksPath( @@ -86,7 +92,8 @@ export function registerExpandAllTool(server) { research: args.research, prompt: args.prompt, force: args.force, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/expand-task.js b/mcp-server/src/tools/expand-task.js index 43d393cc..b6cd5b1f 100644 --- a/mcp-server/src/tools/expand-task.js +++ b/mcp-server/src/tools/expand-task.js @@ -10,7 +10,11 @@ import { withNormalizedProjectRoot } from './utils.js'; import { expandTaskDirect } from '../core/task-master-core.js'; -import { findTasksPath } from '../core/utils/path-utils.js'; +import { + findTasksPath, + findComplexityReportPath +} from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the expand-task tool with the MCP server @@ -51,7 +55,10 @@ export function registerExpandTaskTool(server) { execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info(`Starting expand-task with args: ${JSON.stringify(args)}`); - + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -66,6 +73,11 @@ export function registerExpandTaskTool(server) { ); } + const complexityReportPath = findComplexityReportPath( + { ...args, tag: resolvedTag }, + log + ); + const result = await expandTaskDirect( { tasksJsonPath: tasksJsonPath, @@ -74,8 +86,9 @@ export function registerExpandTaskTool(server) { research: args.research, prompt: args.prompt, force: args.force, + complexityReportPath, projectRoot: args.projectRoot, - tag: args.tag || 'master' + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/fix-dependencies.js b/mcp-server/src/tools/fix-dependencies.js index c46d18bc..92586355 100644 --- a/mcp-server/src/tools/fix-dependencies.js +++ b/mcp-server/src/tools/fix-dependencies.js @@ -11,7 +11,7 @@ import { } from './utils.js'; import { fixDependenciesDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; - +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the fixDependencies tool with the MCP server * @param {Object} server - FastMCP server instance @@ -31,6 +31,11 @@ export function registerFixDependenciesTool(server) { try { log.info(`Fixing dependencies with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -49,7 +54,7 @@ export function registerFixDependenciesTool(server) { { tasksJsonPath: tasksJsonPath, projectRoot: args.projectRoot, - tag: args.tag + tag: resolvedTag }, log ); diff --git a/mcp-server/src/tools/generate.js b/mcp-server/src/tools/generate.js index 766e7892..aab34355 100644 --- a/mcp-server/src/tools/generate.js +++ b/mcp-server/src/tools/generate.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { generateTaskFilesDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; import path from 'path'; /** @@ -30,12 +31,17 @@ export function registerGenerateTool(server) { .describe('Output directory (default: same directory as tasks file)'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info(`Generating task files with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -58,7 +64,8 @@ export function registerGenerateTool(server) { { tasksJsonPath: tasksJsonPath, outputDir: outputDir, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/get-task.js b/mcp-server/src/tools/get-task.js index 620e714e..7313e8ac 100644 --- a/mcp-server/src/tools/get-task.js +++ b/mcp-server/src/tools/get-task.js @@ -14,6 +14,7 @@ import { findTasksPath, findComplexityReportPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Custom processor function that removes allTasks from the response @@ -67,7 +68,8 @@ export function registerShowTaskTool(server) { .string() .describe( 'Absolute path to the project root directory (Optional, usually from session)' - ) + ), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { const { id, file, status, projectRoot } = args; @@ -76,6 +78,10 @@ export function registerShowTaskTool(server) { log.info( `Getting task details for ID: ${id}${status ? ` (filtering subtasks by status: ${status})` : ''} in root: ${projectRoot}` ); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Resolve the path to tasks.json using the NORMALIZED projectRoot from args let tasksJsonPath; @@ -99,7 +105,8 @@ export function registerShowTaskTool(server) { complexityReportPath = findComplexityReportPath( { projectRoot: projectRoot, - complexityReport: args.complexityReport + complexityReport: args.complexityReport, + tag: resolvedTag }, log ); @@ -113,7 +120,8 @@ export function registerShowTaskTool(server) { // Pass other relevant args id: id, status: status, - projectRoot: projectRoot + projectRoot: projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/get-tasks.js b/mcp-server/src/tools/get-tasks.js index 240f2ab2..8e8e3642 100644 --- a/mcp-server/src/tools/get-tasks.js +++ b/mcp-server/src/tools/get-tasks.js @@ -15,6 +15,8 @@ import { resolveComplexityReportPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; + /** * Register the getTasks tool with the MCP server * @param {Object} server - FastMCP server instance @@ -51,12 +53,17 @@ export function registerListTasksTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info(`Getting tasks with filters: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Resolve the path to tasks.json using new path utilities let tasksJsonPath; try { @@ -71,7 +78,10 @@ export function registerListTasksTool(server) { // Resolve the path to complexity report let complexityReportPath; try { - complexityReportPath = resolveComplexityReportPath(args, session); + complexityReportPath = resolveComplexityReportPath( + { ...args, tag: resolvedTag }, + session + ); } catch (error) { log.error(`Error finding complexity report: ${error.message}`); // This is optional, so we don't fail the operation @@ -84,7 +94,8 @@ export function registerListTasksTool(server) { status: args.status, withSubtasks: args.withSubtasks, reportPath: complexityReportPath, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/move-task.js b/mcp-server/src/tools/move-task.js index ded04ba3..36f5d166 100644 --- a/mcp-server/src/tools/move-task.js +++ b/mcp-server/src/tools/move-task.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { moveTaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the moveTask tool with the MCP server @@ -36,10 +37,15 @@ export function registerMoveTaskTool(server) { .string() .describe( 'Root directory of the project (typically derived from session)' - ) + ), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Find tasks.json path if not provided let tasksJsonPath = args.file; @@ -79,7 +85,8 @@ export function registerMoveTaskTool(server) { sourceId: fromId, destinationId: toId, tasksJsonPath, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } @@ -115,7 +122,8 @@ export function registerMoveTaskTool(server) { sourceId: args.from, destinationId: args.to, tasksJsonPath, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/next-task.js b/mcp-server/src/tools/next-task.js index b21ad968..b5453b08 100644 --- a/mcp-server/src/tools/next-task.js +++ b/mcp-server/src/tools/next-task.js @@ -14,6 +14,7 @@ import { resolveTasksPath, resolveComplexityReportPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the nextTask tool with the MCP server @@ -34,11 +35,16 @@ export function registerNextTaskTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info(`Finding next task with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Resolve the path to tasks.json using new path utilities let tasksJsonPath; @@ -54,7 +60,10 @@ export function registerNextTaskTool(server) { // Resolve the path to complexity report (optional) let complexityReportPath; try { - complexityReportPath = resolveComplexityReportPath(args, session); + complexityReportPath = resolveComplexityReportPath( + { ...args, tag: resolvedTag }, + session + ); } catch (error) { log.error(`Error finding complexity report: ${error.message}`); // This is optional, so we don't fail the operation @@ -65,7 +74,8 @@ export function registerNextTaskTool(server) { { tasksJsonPath: tasksJsonPath, reportPath: complexityReportPath, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/parse-prd.js b/mcp-server/src/tools/parse-prd.js index db11f4c0..6161d8f1 100644 --- a/mcp-server/src/tools/parse-prd.js +++ b/mcp-server/src/tools/parse-prd.js @@ -15,6 +15,7 @@ import { TASKMASTER_DOCS_DIR, TASKMASTER_TASKS_FILE } from '../../../src/constants/paths.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the parse_prd tool @@ -24,6 +25,7 @@ export function registerParsePRDTool(server) { server.addTool({ name: 'parse_prd', description: `Parse a Product Requirements Document (PRD) text file to automatically generate initial tasks. Reinitializing the project is not necessary to run this tool. It is recommended to run parse-prd after initializing the project and creating/importing a prd.txt file in the project root's ${TASKMASTER_DOCS_DIR} directory.`, + parameters: z.object({ input: z .string() @@ -33,6 +35,7 @@ export function registerParsePRDTool(server) { projectRoot: z .string() .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on'), output: z .string() .optional() @@ -63,7 +66,18 @@ export function registerParsePRDTool(server) { }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { - const result = await parsePRDDirect(args, log, { session }); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + const result = await parsePRDDirect( + { + ...args, + tag: resolvedTag + }, + log, + { session } + ); return handleApiResult( result, log, diff --git a/mcp-server/src/tools/remove-dependency.js b/mcp-server/src/tools/remove-dependency.js index 63fc767c..84c57462 100644 --- a/mcp-server/src/tools/remove-dependency.js +++ b/mcp-server/src/tools/remove-dependency.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { removeDependencyDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the removeDependency tool with the MCP server @@ -31,10 +32,15 @@ export function registerRemoveDependencyTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info( `Removing dependency for task ${args.id} from ${args.dependsOn} with args: ${JSON.stringify(args)}` ); @@ -57,7 +63,9 @@ export function registerRemoveDependencyTool(server) { { tasksJsonPath: tasksJsonPath, id: args.id, - dependsOn: args.dependsOn + dependsOn: args.dependsOn, + projectRoot: args.projectRoot, + tag: resolvedTag }, log ); diff --git a/mcp-server/src/tools/remove-subtask.js b/mcp-server/src/tools/remove-subtask.js index 4c3461bc..ae83e650 100644 --- a/mcp-server/src/tools/remove-subtask.js +++ b/mcp-server/src/tools/remove-subtask.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { removeSubtaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the removeSubtask tool with the MCP server @@ -44,10 +45,15 @@ export function registerRemoveSubtaskTool(server) { .describe('Skip regenerating task files'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info(`Removing subtask with args: ${JSON.stringify(args)}`); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) @@ -70,7 +76,8 @@ export function registerRemoveSubtaskTool(server) { id: args.id, convert: args.convert, skipGenerate: args.skipGenerate, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/remove-task.js b/mcp-server/src/tools/remove-task.js index c2b1c60c..93b2e8f6 100644 --- a/mcp-server/src/tools/remove-task.js +++ b/mcp-server/src/tools/remove-task.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { removeTaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the remove-task tool with the MCP server @@ -45,6 +46,11 @@ export function registerRemoveTaskTool(server) { try { log.info(`Removing task(s) with ID(s): ${args.id}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -66,7 +72,7 @@ export function registerRemoveTaskTool(server) { tasksJsonPath: tasksJsonPath, id: args.id, projectRoot: args.projectRoot, - tag: args.tag + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/research.js b/mcp-server/src/tools/research.js index 4e54b077..1fb61be9 100644 --- a/mcp-server/src/tools/research.js +++ b/mcp-server/src/tools/research.js @@ -10,6 +10,7 @@ import { withNormalizedProjectRoot } from './utils.js'; import { researchDirect } from '../core/task-master-core.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the research tool with the MCP server @@ -19,6 +20,7 @@ export function registerResearchTool(server) { server.addTool({ name: 'research', description: 'Perform AI-powered research queries with project context', + parameters: z.object({ query: z.string().describe('Research query/prompt (required)'), taskIds: z @@ -61,10 +63,15 @@ export function registerResearchTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info( `Starting research with query: "${args.query.substring(0, 100)}${args.query.length > 100 ? '...' : ''}"` ); @@ -80,7 +87,8 @@ export function registerResearchTool(server) { detailLevel: args.detailLevel || 'medium', saveTo: args.saveTo, saveToFile: args.saveToFile || false, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/set-task-status.js b/mcp-server/src/tools/set-task-status.js index ad6edb9b..ee293fed 100644 --- a/mcp-server/src/tools/set-task-status.js +++ b/mcp-server/src/tools/set-task-status.js @@ -18,6 +18,7 @@ import { findComplexityReportPath } from '../core/utils/path-utils.js'; import { TASK_STATUS_OPTIONS } from '../../../src/constants/task-status.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the setTaskStatus tool with the MCP server @@ -52,8 +53,15 @@ export function registerSetTaskStatusTool(server) { }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { - log.info(`Setting status of task(s) ${args.id} to: ${args.status}`); - + log.info( + `Setting status of task(s) ${args.id} to: ${args.status} ${ + args.tag ? `in tag: ${args.tag}` : 'in current tag' + }` + ); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -73,7 +81,8 @@ export function registerSetTaskStatusTool(server) { complexityReportPath = findComplexityReportPath( { projectRoot: args.projectRoot, - complexityReport: args.complexityReport + complexityReport: args.complexityReport, + tag: resolvedTag }, log ); @@ -88,7 +97,7 @@ export function registerSetTaskStatusTool(server) { status: args.status, complexityReportPath, projectRoot: args.projectRoot, - tag: args.tag + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/update-subtask.js b/mcp-server/src/tools/update-subtask.js index 867bf9e5..2624f4d1 100644 --- a/mcp-server/src/tools/update-subtask.js +++ b/mcp-server/src/tools/update-subtask.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { updateSubtaskByIdDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the update-subtask tool with the MCP server @@ -35,11 +36,17 @@ export function registerUpdateSubtaskTool(server) { file: z.string().optional().describe('Absolute path to the tasks file'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { const toolName = 'update_subtask'; + try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info(`Updating subtask with args: ${JSON.stringify(args)}`); let tasksJsonPath; @@ -61,7 +68,8 @@ export function registerUpdateSubtaskTool(server) { id: args.id, prompt: args.prompt, research: args.research, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/update-task.js b/mcp-server/src/tools/update-task.js index a45476eb..2fb1feb7 100644 --- a/mcp-server/src/tools/update-task.js +++ b/mcp-server/src/tools/update-task.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { updateTaskByIdDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the update-task tool with the MCP server @@ -43,11 +44,16 @@ export function registerUpdateTaskTool(server) { file: z.string().optional().describe('Absolute path to the tasks file'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { const toolName = 'update_task'; try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info( `Executing ${toolName} tool with args: ${JSON.stringify(args)}` ); @@ -74,7 +80,8 @@ export function registerUpdateTaskTool(server) { prompt: args.prompt, research: args.research, append: args.append, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/update.js b/mcp-server/src/tools/update.js index 475eb338..f81a3755 100644 --- a/mcp-server/src/tools/update.js +++ b/mcp-server/src/tools/update.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { updateTasksDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the update tool with the MCP server @@ -50,6 +51,11 @@ export function registerUpdateTool(server) { const toolName = 'update'; const { from, prompt, research, file, projectRoot, tag } = args; + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + try { log.info( `Executing ${toolName} tool with normalized root: ${projectRoot}` @@ -73,7 +79,7 @@ export function registerUpdateTool(server) { prompt: prompt, research: research, projectRoot: projectRoot, - tag: tag + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/validate-dependencies.js b/mcp-server/src/tools/validate-dependencies.js index 10a9f638..4b96c12b 100644 --- a/mcp-server/src/tools/validate-dependencies.js +++ b/mcp-server/src/tools/validate-dependencies.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { validateDependenciesDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the validateDependencies tool with the MCP server @@ -25,10 +26,15 @@ export function registerValidateDependenciesTool(server) { file: z.string().optional().describe('Absolute path to the tasks file'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info(`Validating dependencies with args: ${JSON.stringify(args)}`); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) @@ -47,7 +53,9 @@ export function registerValidateDependenciesTool(server) { const result = await validateDependenciesDirect( { - tasksJsonPath: tasksJsonPath + tasksJsonPath: tasksJsonPath, + projectRoot: args.projectRoot, + tag: resolvedTag }, log ); diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index f68d4706..d5f0e55e 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -826,7 +826,8 @@ function registerCommands(programInstance) { let taskMaster; try { const initOptions = { - prdPath: file || options.input || true + prdPath: file || options.input || true, + tag: options.tag }; // Only include tasksPath if output is explicitly specified if (options.output) { @@ -852,8 +853,7 @@ function registerCommands(programInstance) { const useAppend = append; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -966,7 +966,8 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const fromId = parseInt(options.from, 10); // Validation happens here @@ -976,8 +977,7 @@ function registerCommands(programInstance) { const tasksPath = taskMaster.getTasksPath(); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -1066,13 +1066,13 @@ function registerCommands(programInstance) { try { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const tasksPath = taskMaster.getTasksPath(); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -1238,13 +1238,13 @@ function registerCommands(programInstance) { try { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const tasksPath = taskMaster.getTasksPath(); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -1404,11 +1404,12 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const outputDir = options.output; - const tag = options.tag; + const tag = taskMaster.getCurrentTag(); console.log( chalk.blue(`Generating task files from: ${taskMaster.getTasksPath()}`) @@ -1444,12 +1445,12 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const taskId = options.id; const status = options.status; - const tag = options.tag; if (!taskId || !status) { console.error(chalk.red('Error: Both --id and --status are required')); @@ -1465,11 +1466,9 @@ function registerCommands(programInstance) { process.exit(1); } + const tag = taskMaster.getCurrentTag(); - // Resolve tag using standard pattern and show current tag context - const resolvedTag = - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; - displayCurrentTagIndicator(resolvedTag); + displayCurrentTagIndicator(tag); console.log( chalk.blue(`Setting status of task(s) ${taskId} to: ${status}`) @@ -1501,7 +1500,8 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const initOptions = { - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }; // Only pass complexityReportPath if user provided a custom path @@ -1513,9 +1513,7 @@ function registerCommands(programInstance) { const statusFilter = options.status; const withSubtasks = options.withSubtasks || false; - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; - + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -1535,8 +1533,7 @@ function registerCommands(programInstance) { taskMaster.getComplexityReportPath(), withSubtasks, 'text', - tag, - { projectRoot: taskMaster.getProjectRoot() } + { projectRoot: taskMaster.getProjectRoot(), tag } ); }); @@ -1565,18 +1562,29 @@ function registerCommands(programInstance) { 'Path to the tasks file (relative to project root)', TASKMASTER_TASKS_FILE // Allow file override ) // Allow file override + .option( + '-cr, --complexity-report ', + 'Path to the report file', + COMPLEXITY_REPORT_FILE + ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); - const tag = options.tag; + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + + if (options.complexityReport) { + initOptions.complexityReportPath = options.complexityReport; + } + + const taskMaster = initTaskMaster(initOptions); + + const tag = taskMaster.getCurrentTag(); // Show current tag context - displayCurrentTagIndicator( - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); if (options.all) { // --- Handle expand --all --- @@ -1589,7 +1597,11 @@ function registerCommands(programInstance) { options.research, // Pass research flag options.prompt, // Pass additional context options.force, // Pass force flag - { projectRoot: taskMaster.getProjectRoot(), tag } // Pass context with projectRoot and tag + { + projectRoot: taskMaster.getProjectRoot(), + tag, + complexityReportPath: taskMaster.getComplexityReportPath() + } // Pass context with projectRoot and tag // outputFormat defaults to 'text' in expandAllTasks for CLI ); } catch (error) { @@ -1616,7 +1628,11 @@ function registerCommands(programInstance) { options.num, options.research, options.prompt, - { projectRoot: taskMaster.getProjectRoot(), tag }, // Pass context with projectRoot and tag + { + projectRoot: taskMaster.getProjectRoot(), + tag, + complexityReportPath: taskMaster.getComplexityReportPath() + }, // Pass context with projectRoot and tag options.force // Pass the force flag down ); // expandTask logs its own success/failure for single task @@ -1669,34 +1685,28 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const initOptions = { - tasksPath: options.file || true // Tasks file is required to analyze + tasksPath: options.file || true, // Tasks file is required to analyze + tag: options.tag }; // Only include complexityReportPath if output is explicitly specified if (options.output) { initOptions.complexityReportPath = options.output; } + const taskMaster = initTaskMaster(initOptions); - const tag = options.tag; const modelOverride = options.model; const thresholdScore = parseFloat(options.threshold); const useResearch = options.research || false; // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const targetTag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(targetTag); - // Tag-aware output file naming: master -> task-complexity-report.json, other tags -> task-complexity-report_tagname.json - const baseOutputPath = - taskMaster.getComplexityReportPath() || - path.join(taskMaster.getProjectRoot(), COMPLEXITY_REPORT_FILE); - const outputPath = - options.output === COMPLEXITY_REPORT_FILE && targetTag !== 'master' - ? baseOutputPath.replace('.json', `_${targetTag}.json`) - : options.output || baseOutputPath; + // Use user's explicit output path if provided, otherwise use tag-aware default + const outputPath = taskMaster.getComplexityReportPath(); console.log( chalk.blue( @@ -1777,9 +1787,12 @@ function registerCommands(programInstance) { .option('--tag ', 'Specify tag context for task operations') .action(async (prompt, options) => { // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + + const taskMaster = initTaskMaster(initOptions); // Parameter validation if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) { @@ -1879,8 +1892,7 @@ function registerCommands(programInstance) { } } - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2113,17 +2125,17 @@ ${result.result} .action(async (options) => { const taskIds = options.id; const all = options.all; - const tag = options.tag; // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); + const tag = taskMaster.getCurrentTag(); + // Show current tag context - displayCurrentTagIndicator( - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); if (!taskIds && !all) { console.error( @@ -2219,15 +2231,16 @@ ${result.result} // Correctly determine projectRoot // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const projectRoot = taskMaster.getProjectRoot(); + const tag = taskMaster.getCurrentTag(); + // Show current tag context - displayCurrentTagIndicator( - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); let manualTaskData = null; if (isManualCreation) { @@ -2263,7 +2276,7 @@ ${result.result} const context = { projectRoot, - tag: options.tag, + tag, commandName: 'add-task', outputType: 'cli' }; @@ -2309,22 +2322,36 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tag = options.tag; + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + + if (options.report && options.report !== COMPLEXITY_REPORT_FILE) { + initOptions.complexityReportPath = options.report; + } // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag, + complexityReportPath: options.report || false }); + const tag = taskMaster.getCurrentTag(); + + const context = { + projectRoot: taskMaster.getProjectRoot(), + tag + }; + // Show current tag context - displayCurrentTagIndicator( - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); await displayNextTask( taskMaster.getTasksPath(), taskMaster.getComplexityReportPath(), - { projectRoot: taskMaster.getProjectRoot(), tag } + context ); }); @@ -2364,12 +2391,10 @@ ${result.result} const idArg = taskId || options.id; const statusFilter = options.status; - const tag = options.tag; + const tag = taskMaster.getCurrentTag(); // Show current tag context - displayCurrentTagIndicator( - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); if (!idArg) { console.error(chalk.red('Error: Please provide a task ID')); @@ -2398,8 +2423,7 @@ ${result.result} taskIds[0], taskMaster.getComplexityReportPath(), statusFilter, - tag, - { projectRoot: taskMaster.getProjectRoot() } + { projectRoot: taskMaster.getProjectRoot(), tag } ); } }); @@ -2417,17 +2441,19 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const taskMaster = initTaskMaster(initOptions); const taskId = options.id; const dependencyId = options.dependsOn; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2472,17 +2498,19 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const taskMaster = initTaskMaster(initOptions); const taskId = options.id; const dependencyId = options.dependsOn; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2527,14 +2555,16 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const taskMaster = initTaskMaster(initOptions); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2555,14 +2585,16 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const taskMaster = initTaskMaster(initOptions); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2583,26 +2615,21 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - // Initialize TaskMaster - const taskMaster = initTaskMaster({ - complexityReportPath: options.file || true - }); + const initOptions = { + tag: options.tag + }; - // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + if (options.file && options.file !== COMPLEXITY_REPORT_FILE) { + initOptions.complexityReportPath = options.file; + } + + // Initialize TaskMaster + const taskMaster = initTaskMaster(initOptions); // Show current tag context - displayCurrentTagIndicator(targetTag); + displayCurrentTagIndicator(taskMaster.getCurrentTag()); - // Tag-aware report file naming: master -> task-complexity-report.json, other tags -> task-complexity-report_tagname.json - const baseReportPath = taskMaster.getComplexityReportPath(); - const reportPath = - options.file === COMPLEXITY_REPORT_FILE && targetTag !== 'master' - ? baseReportPath.replace('.json', `_${targetTag}.json`) - : baseReportPath; - - await displayComplexityReport(reportPath); + await displayComplexityReport(taskMaster.getComplexityReportPath()); }); // add-subtask command @@ -2632,7 +2659,8 @@ ${result.result} .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const parentId = options.parent; @@ -2640,8 +2668,7 @@ ${result.result} const generateFiles = !options.skipGenerate; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2816,13 +2843,14 @@ ${result.result} .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const subtaskIds = options.id; const convertToTask = options.convert || false; const generateFiles = !options.skipGenerate; - const tag = options.tag; + const tag = taskMaster.getCurrentTag(); if (!subtaskIds) { console.error( @@ -3117,14 +3145,14 @@ ${result.result} .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const taskIdsString = options.id; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -3768,12 +3796,13 @@ Examples: .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const sourceId = options.from; const destinationId = options.to; - const tag = options.tag; + const tag = taskMaster.getCurrentTag(); if (!sourceId || !destinationId) { console.error( @@ -4201,15 +4230,19 @@ Examples: '-s, --status ', 'Show only tasks matching this status (e.g., pending, done)' ) + .option('-t, --tag ', 'Tag to use for the task list (default: master)') .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const withSubtasks = options.withSubtasks || false; const status = options.status || null; + const tag = taskMaster.getCurrentTag(); + console.log( chalk.blue( `📝 Syncing tasks to README.md${withSubtasks ? ' (with subtasks)' : ''}${status ? ` (status: ${status})` : ''}...` @@ -4219,7 +4252,8 @@ Examples: const success = await syncTasksToReadme(taskMaster.getProjectRoot(), { withSubtasks, status, - tasksPath: taskMaster.getTasksPath() + tasksPath: taskMaster.getTasksPath(), + tag }); if (!success) { @@ -4941,6 +4975,33 @@ async function runCLI(argv = process.argv) { } } +/** + * Resolve the final complexity-report path. + * Rules: + * 1. If caller passes --output, always respect it. + * 2. If no explicit output AND tag === 'master' → default report file + * 3. If no explicit output AND tag !== 'master' → append _.json + * + * @param {string|undefined} outputOpt --output value from CLI (may be undefined) + * @param {string} targetTag resolved tag (defaults to 'master') + * @param {string} projectRoot absolute project root + * @returns {string} absolute path for the report + */ +export function resolveComplexityReportPath({ + projectRoot, + tag = 'master', + output // may be undefined +}) { + // 1. user knows best + if (output) { + return path.isAbsolute(output) ? output : path.join(projectRoot, output); + } + + // 2. default naming + const base = path.join(projectRoot, COMPLEXITY_REPORT_FILE); + return tag !== 'master' ? base.replace('.json', `_${tag}.json`) : base; +} + export { registerCommands, setupCLI, diff --git a/scripts/modules/dependency-manager.js b/scripts/modules/dependency-manager.js index b2f005ff..4f43f894 100644 --- a/scripts/modules/dependency-manager.js +++ b/scripts/modules/dependency-manager.js @@ -27,6 +27,8 @@ import { generateTaskFiles } from './task-manager.js'; * @param {number|string} taskId - ID of the task to add dependency to * @param {number|string} dependencyId - ID of the task to add as dependency * @param {Object} context - Context object containing projectRoot and tag information + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task */ async function addDependency(tasksPath, taskId, dependencyId, context = {}) { log('info', `Adding dependency ${dependencyId} to task ${taskId}...`); @@ -214,6 +216,8 @@ async function addDependency(tasksPath, taskId, dependencyId, context = {}) { * @param {number|string} taskId - ID of the task to remove dependency from * @param {number|string} dependencyId - ID of the task to remove as dependency * @param {Object} context - Context object containing projectRoot and tag information + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task */ async function removeDependency(tasksPath, taskId, dependencyId, context = {}) { log('info', `Removing dependency ${dependencyId} from task ${taskId}...`); diff --git a/scripts/modules/sync-readme.js b/scripts/modules/sync-readme.js index a13083ca..619954d3 100644 --- a/scripts/modules/sync-readme.js +++ b/scripts/modules/sync-readme.js @@ -91,11 +91,12 @@ function createEndMarker() { * @param {string} options.status - Filter by status (e.g., 'pending', 'done') * @param {string} options.tasksPath - Custom path to tasks.json * @returns {boolean} - True if sync was successful, false otherwise + * TODO: Add tag support - this is not currently supported how we want to handle this - Parthy */ export async function syncTasksToReadme(projectRoot = null, options = {}) { try { const actualProjectRoot = projectRoot || findProjectRoot() || '.'; - const { withSubtasks = false, status, tasksPath } = options; + const { withSubtasks = false, status, tasksPath, tag } = options; // Get current tasks using the list-tasks functionality with markdown-readme format const tasksOutput = await listTasks( @@ -104,7 +105,8 @@ export async function syncTasksToReadme(projectRoot = null, options = {}) { status, null, withSubtasks, - 'markdown-readme' + 'markdown-readme', + { projectRoot, tag } ); if (!tasksOutput) { diff --git a/scripts/modules/task-manager/add-subtask.js b/scripts/modules/task-manager/add-subtask.js index b48a8dc9..be12c853 100644 --- a/scripts/modules/task-manager/add-subtask.js +++ b/scripts/modules/task-manager/add-subtask.js @@ -12,6 +12,8 @@ import generateTaskFiles from './generate-task-files.js'; * @param {Object} newSubtaskData - Data for creating a new subtask (used if existingTaskId is null) * @param {boolean} generateFiles - Whether to regenerate task files after adding the subtask * @param {Object} context - Context object containing projectRoot and tag information + * @param {string} context.projectRoot - Project root path + * @param {string} context.tag - Tag for the task * @returns {Object} The newly created or converted subtask */ async function addSubtask( @@ -22,13 +24,12 @@ async function addSubtask( generateFiles = true, context = {} ) { + const { projectRoot, tag } = context; try { log('info', `Adding subtask to parent task ${parentId}...`); - const currentTag = - context.tag || getCurrentTag(context.projectRoot) || 'master'; // Read the existing tasks with proper context - const data = readJSON(tasksPath, context.projectRoot, currentTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error(`Invalid or missing tasks file at ${tasksPath}`); } @@ -139,7 +140,7 @@ async function addSubtask( } // Write the updated tasks back to the file with proper context - writeJSON(tasksPath, data, context.projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); // Generate task files if requested if (generateFiles) { diff --git a/scripts/modules/task-manager/add-task.js b/scripts/modules/task-manager/add-task.js index 13db6968..324dc39e 100644 --- a/scripts/modules/task-manager/add-task.js +++ b/scripts/modules/task-manager/add-task.js @@ -22,8 +22,7 @@ import { truncate, ensureTagMetadata, performCompleteTagMigration, - markMigrationForNotice, - getCurrentTag + markMigrationForNotice } from '../utils.js'; import { generateObjectService } from '../ai-services-unified.js'; import { getDefaultPriority } from '../config-manager.js'; @@ -93,7 +92,7 @@ function getAllTasks(rawData) { * @param {string} [context.projectRoot] - Project root path (for MCP/env fallback) * @param {string} [context.commandName] - The name of the command being executed (for telemetry) * @param {string} [context.outputType] - The output type ('cli' or 'mcp', for telemetry) - * @param {string} [tag] - Tag for the task (optional) + * @param {string} [context.tag] - Tag for the task (optional) * @returns {Promise} An object containing newTaskId and telemetryData */ async function addTask( @@ -104,10 +103,10 @@ async function addTask( context = {}, outputFormat = 'text', // Default to text for CLI manualTaskData = null, - useResearch = false, - tag = null + useResearch = false ) { - const { session, mcpLog, projectRoot, commandName, outputType } = context; + const { session, mcpLog, projectRoot, commandName, outputType, tag } = + context; const isMCP = !!mcpLog; // Create a consistent logFn object regardless of context @@ -224,7 +223,7 @@ async function addTask( try { // Read the existing tasks - IMPORTANT: Read the raw data without tag resolution - let rawData = readJSON(tasksPath, projectRoot); // No tag parameter + let rawData = readJSON(tasksPath, projectRoot, tag); // No tag parameter // Handle the case where readJSON returns resolved data with _rawTaggedData if (rawData && rawData._rawTaggedData) { @@ -279,8 +278,7 @@ async function addTask( } // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = - tag || context.tag || getCurrentTag(projectRoot) || 'master'; + const targetTag = tag; // Ensure the target tag exists if (!rawData[targetTag]) { @@ -389,7 +387,7 @@ async function addTask( report(`Generating task data with AI with prompt:\n${prompt}`, 'info'); // --- Use the new ContextGatherer --- - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const gatherResult = await contextGatherer.gather({ semanticQuery: prompt, dependencyTasks: numericDependencies, diff --git a/scripts/modules/task-manager/analyze-task-complexity.js b/scripts/modules/task-manager/analyze-task-complexity.js index df5c65c4..35ceddd8 100644 --- a/scripts/modules/task-manager/analyze-task-complexity.js +++ b/scripts/modules/task-manager/analyze-task-complexity.js @@ -19,6 +19,7 @@ import { COMPLEXITY_REPORT_FILE, LEGACY_TASKS_FILE } from '../../../src/constants/paths.js'; +import { resolveComplexityReportOutputPath } from '../../../src/utils/path-utils.js'; import { ContextGatherer } from '../utils/contextGatherer.js'; import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; import { flattenTasksWithSubtasks } from '../utils.js'; @@ -71,6 +72,7 @@ Do not include any explanatory text, markdown formatting, or code block markers * @param {string|number} [options.threshold] - Complexity threshold * @param {boolean} [options.research] - Use research role * @param {string} [options.projectRoot] - Project root path (for MCP/env fallback). + * @param {string} [options.tag] - Tag for the task * @param {string} [options.id] - Comma-separated list of task IDs to analyze specifically * @param {number} [options.from] - Starting task ID in a range to analyze * @param {number} [options.to] - Ending task ID in a range to analyze @@ -84,7 +86,6 @@ Do not include any explanatory text, markdown formatting, or code block markers async function analyzeTaskComplexity(options, context = {}) { const { session, mcpLog } = context; const tasksPath = options.file || LEGACY_TASKS_FILE; - const outputPath = options.output || COMPLEXITY_REPORT_FILE; const thresholdScore = parseFloat(options.threshold || '5'); const useResearch = options.research || false; const projectRoot = options.projectRoot; @@ -109,6 +110,13 @@ async function analyzeTaskComplexity(options, context = {}) { } }; + // Resolve output path using tag-aware resolution + const outputPath = resolveComplexityReportOutputPath( + options.output, + { projectRoot, tag }, + reportLog + ); + if (outputFormat === 'text') { console.log( chalk.blue( @@ -220,7 +228,7 @@ async function analyzeTaskComplexity(options, context = {}) { let gatheredContext = ''; if (originalData && originalData.tasks.length > 0) { try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(originalData.tasks); const fuzzySearch = new FuzzyTaskSearch( allTasksFlat, @@ -535,7 +543,7 @@ async function analyzeTaskComplexity(options, context = {}) { } } - // Merge with existing report + // Merge with existing report - only keep entries from the current tag let finalComplexityAnalysis = []; if (existingReport && Array.isArray(existingReport.complexityAnalysis)) { @@ -544,10 +552,14 @@ async function analyzeTaskComplexity(options, context = {}) { complexityAnalysis.map((item) => item.taskId) ); - // Keep existing entries that weren't in this analysis run + // Keep existing entries that weren't in this analysis run AND belong to the current tag + // We determine tag membership by checking if the task ID exists in the current tag's tasks + const currentTagTaskIds = new Set(tasksData.tasks.map((t) => t.id)); const existingEntriesNotAnalyzed = existingReport.complexityAnalysis.filter( - (item) => !analyzedTaskIds.has(item.taskId) + (item) => + !analyzedTaskIds.has(item.taskId) && + currentTagTaskIds.has(item.taskId) // Only keep entries for tasks in current tag ); // Combine with new analysis @@ -557,7 +569,7 @@ async function analyzeTaskComplexity(options, context = {}) { ]; reportLog( - `Merged ${complexityAnalysis.length} new analyses with ${existingEntriesNotAnalyzed.length} existing entries`, + `Merged ${complexityAnalysis.length} new analyses with ${existingEntriesNotAnalyzed.length} existing entries from current tag`, 'info' ); } else { diff --git a/scripts/modules/task-manager/clear-subtasks.js b/scripts/modules/task-manager/clear-subtasks.js index 760b5581..db726123 100644 --- a/scripts/modules/task-manager/clear-subtasks.js +++ b/scripts/modules/task-manager/clear-subtasks.js @@ -11,6 +11,8 @@ import { displayBanner } from '../ui.js'; * @param {string} tasksPath - Path to the tasks.json file * @param {string} taskIds - Task IDs to clear subtasks from * @param {Object} context - Context object containing projectRoot and tag + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task */ function clearSubtasks(tasksPath, taskIds, context = {}) { const { projectRoot, tag } = context; diff --git a/scripts/modules/task-manager/expand-all-tasks.js b/scripts/modules/task-manager/expand-all-tasks.js index 8782fd44..8e5a2255 100644 --- a/scripts/modules/task-manager/expand-all-tasks.js +++ b/scripts/modules/task-manager/expand-all-tasks.js @@ -20,6 +20,8 @@ import boxen from 'boxen'; * @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} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task * @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, telemetryData: Array}>} - Result summary. */ @@ -32,12 +34,7 @@ async function expandAllTasks( context = {}, outputFormat = 'text' // Assume text default for CLI ) { - const { - session, - mcpLog, - projectRoot: providedProjectRoot, - tag: contextTag - } = context; + const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context; const isMCPCall = !!mcpLog; // Determine if called from MCP const projectRoot = providedProjectRoot || findProjectRoot(); @@ -79,7 +76,7 @@ async function expandAllTasks( try { logger.info(`Reading tasks from ${tasksPath}`); - const data = readJSON(tasksPath, projectRoot, contextTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error(`Invalid tasks data in ${tasksPath}`); } @@ -129,7 +126,7 @@ async function expandAllTasks( numSubtasks, useResearch, additionalContext, - { ...context, projectRoot, tag: data.tag || contextTag }, // Pass the whole context object with projectRoot and resolved tag + { ...context, projectRoot, tag: data.tag || tag }, // Pass the whole context object with projectRoot and resolved tag force ); expandedCount++; diff --git a/scripts/modules/task-manager/expand-task.js b/scripts/modules/task-manager/expand-task.js index c471afa6..f8ba362d 100644 --- a/scripts/modules/task-manager/expand-task.js +++ b/scripts/modules/task-manager/expand-task.js @@ -290,6 +290,8 @@ function parseSubtasksFromText( * @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} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task * @param {boolean} [force=false] - If true, replace existing subtasks; otherwise, append. * @returns {Promise} The updated parent task object with new subtasks. * @throws {Error} If task not found, AI service fails, or parsing fails. @@ -303,7 +305,13 @@ async function expandTask( context = {}, force = false ) { - const { session, mcpLog, projectRoot: contextProjectRoot, tag } = context; + const { + session, + mcpLog, + projectRoot: contextProjectRoot, + tag, + complexityReportPath + } = context; const outputFormat = mcpLog ? 'json' : 'text'; // Determine projectRoot: Use from context if available, otherwise derive from tasksPath @@ -350,7 +358,7 @@ async function expandTask( // --- Context Gathering --- let gatheredContext = ''; try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(data.tasks); const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'expand-task'); const searchQuery = `${task.title} ${task.description}`; @@ -379,17 +387,10 @@ async function expandTask( // --- Complexity Report Integration --- let finalSubtaskCount; let complexityReasoningContext = ''; - - // Use tag-aware complexity report path - const complexityReportPath = getTagAwareFilePath( - COMPLEXITY_REPORT_FILE, - tag, - projectRoot - ); let taskAnalysis = null; logger.info( - `Looking for complexity report at: ${complexityReportPath}${tag && tag !== 'master' ? ` (tag-specific for '${tag}')` : ''}` + `Looking for complexity report at: ${complexityReportPath}${tag !== 'master' ? ` (tag-specific for '${tag}')` : ''}` ); try { diff --git a/scripts/modules/task-manager/generate-task-files.js b/scripts/modules/task-manager/generate-task-files.js index d5dba0d6..581e9ec7 100644 --- a/scripts/modules/task-manager/generate-task-files.js +++ b/scripts/modules/task-manager/generate-task-files.js @@ -12,16 +12,20 @@ import { getDebugFlag } from '../config-manager.js'; * @param {string} tasksPath - Path to the tasks.json file * @param {string} outputDir - Output directory for task files * @param {Object} options - Additional options (mcpLog for MCP mode, projectRoot, tag) + * @param {string} [options.projectRoot] - Project root path + * @param {string} [options.tag] - Tag for the task + * @param {Object} [options.mcpLog] - MCP logger object * @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode */ function generateTaskFiles(tasksPath, outputDir, options = {}) { try { const isMcpMode = !!options?.mcpLog; + const { projectRoot, tag } = options; // 1. Read the raw data structure, ensuring we have all tags. // We call readJSON without a specific tag to get the resolved default view, // which correctly contains the full structure in `_rawTaggedData`. - const resolvedData = readJSON(tasksPath, options.projectRoot); + const resolvedData = readJSON(tasksPath, projectRoot, tag); if (!resolvedData) { throw new Error(`Could not read or parse tasks file: ${tasksPath}`); } @@ -29,13 +33,10 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { const rawData = resolvedData._rawTaggedData || resolvedData; // 2. Determine the target tag we need to generate files for. - const targetTag = options.tag || resolvedData.tag || 'master'; - const tagData = rawData[targetTag]; + const tagData = rawData[tag]; if (!tagData || !tagData.tasks) { - throw new Error( - `Tag '${targetTag}' not found or has no tasks in the data.` - ); + throw new Error(`Tag '${tag}' not found or has no tasks in the data.`); } const tasksForGeneration = tagData.tasks; @@ -46,15 +47,15 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { log( 'info', - `Preparing to regenerate ${tasksForGeneration.length} task files for tag '${targetTag}'` + `Preparing to regenerate ${tasksForGeneration.length} task files for tag '${tag}'` ); // 3. Validate dependencies using the FULL, raw data structure to prevent data loss. validateAndFixDependencies( rawData, // Pass the entire object with all tags tasksPath, - options.projectRoot, - targetTag // Provide the current tag context for the operation + projectRoot, + tag // Provide the current tag context for the operation ); const allTasksInTag = tagData.tasks; @@ -66,14 +67,14 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { const files = fs.readdirSync(outputDir); // Tag-aware file patterns: master -> task_001.txt, other tags -> task_001_tagname.txt const masterFilePattern = /^task_(\d+)\.txt$/; - const taggedFilePattern = new RegExp(`^task_(\\d+)_${targetTag}\\.txt$`); + const taggedFilePattern = new RegExp(`^task_(\\d+)_${tag}\\.txt$`); const orphanedFiles = files.filter((file) => { let match = null; let fileTaskId = null; // Check if file belongs to current tag - if (targetTag === 'master') { + if (tag === 'master') { match = file.match(masterFilePattern); if (match) { fileTaskId = parseInt(match[1], 10); @@ -94,7 +95,7 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { if (orphanedFiles.length > 0) { log( 'info', - `Found ${orphanedFiles.length} orphaned task files to remove for tag '${targetTag}'` + `Found ${orphanedFiles.length} orphaned task files to remove for tag '${tag}'` ); orphanedFiles.forEach((file) => { const filePath = path.join(outputDir, file); @@ -108,13 +109,13 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { } // Generate task files for the target tag - log('info', `Generating individual task files for tag '${targetTag}'...`); + log('info', `Generating individual task files for tag '${tag}'...`); tasksForGeneration.forEach((task) => { // Tag-aware file naming: master -> task_001.txt, other tags -> task_001_tagname.txt const taskFileName = - targetTag === 'master' + tag === 'master' ? `task_${task.id.toString().padStart(3, '0')}.txt` - : `task_${task.id.toString().padStart(3, '0')}_${targetTag}.txt`; + : `task_${task.id.toString().padStart(3, '0')}_${tag}.txt`; const taskPath = path.join(outputDir, taskFileName); @@ -174,7 +175,7 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { log( 'success', - `All ${tasksForGeneration.length} tasks for tag '${targetTag}' have been generated into '${outputDir}'.` + `All ${tasksForGeneration.length} tasks for tag '${tag}' have been generated into '${outputDir}'.` ); if (isMcpMode) { diff --git a/scripts/modules/task-manager/list-tasks.js b/scripts/modules/task-manager/list-tasks.js index e790fa62..718569dd 100644 --- a/scripts/modules/task-manager/list-tasks.js +++ b/scripts/modules/task-manager/list-tasks.js @@ -26,8 +26,9 @@ import { * @param {string} reportPath - Path to the complexity report * @param {boolean} withSubtasks - Whether to show subtasks * @param {string} outputFormat - Output format (text or json) - * @param {string} tag - Optional tag to override current tag resolution - * @param {Object} context - Optional context object containing projectRoot and other options + * @param {Object} context - Context object (required) + * @param {string} context.projectRoot - Project root path + * @param {string} context.tag - Tag for the task * @returns {Object} - Task list result for json format */ function listTasks( @@ -36,18 +37,18 @@ function listTasks( reportPath = null, withSubtasks = false, outputFormat = 'text', - tag = null, context = {} ) { + const { projectRoot, tag } = context; try { // Extract projectRoot from context if provided - const projectRoot = context.projectRoot || null; const data = readJSON(tasksPath, projectRoot, tag); // Pass projectRoot to readJSON if (!data || !data.tasks) { throw new Error(`No valid tasks found in ${tasksPath}`); } // Add complexity scores to tasks if report exists + // `reportPath` is already tag-aware (resolved at the CLI boundary). const complexityReport = readComplexityReport(reportPath); // Apply complexity scores to tasks if (complexityReport && complexityReport.complexityAnalysis) { diff --git a/scripts/modules/task-manager/move-task.js b/scripts/modules/task-manager/move-task.js index 19538330..fc82112f 100644 --- a/scripts/modules/task-manager/move-task.js +++ b/scripts/modules/task-manager/move-task.js @@ -1,11 +1,5 @@ import path from 'path'; -import { - log, - readJSON, - writeJSON, - getCurrentTag, - setTasksForTag -} from '../utils.js'; +import { log, readJSON, writeJSON, setTasksForTag } from '../utils.js'; import { isTaskDependentOn } from '../task-manager.js'; import generateTaskFiles from './generate-task-files.js'; @@ -27,6 +21,7 @@ async function moveTask( generateFiles = false, options = {} ) { + const { projectRoot, tag } = options; // Check if we have comma-separated IDs (batch move) const sourceIds = sourceId.split(',').map((id) => id.trim()); const destinationIds = destinationId.split(',').map((id) => id.trim()); @@ -53,7 +48,10 @@ async function moveTask( // Generate files once at the end if requested if (generateFiles) { - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + await generateTaskFiles(tasksPath, path.dirname(tasksPath), { + tag: tag, + projectRoot: projectRoot + }); } return { @@ -64,7 +62,7 @@ async function moveTask( // Single move logic // Read the raw data without tag resolution to preserve tagged structure - let rawData = readJSON(tasksPath, options.projectRoot); // No tag parameter + let rawData = readJSON(tasksPath, projectRoot, tag); // Handle the case where readJSON returns resolved data with _rawTaggedData if (rawData && rawData._rawTaggedData) { @@ -72,27 +70,19 @@ async function moveTask( rawData = rawData._rawTaggedData; } - // Determine the current tag - const currentTag = - options.tag || getCurrentTag(options.projectRoot) || 'master'; - // Ensure the tag exists in the raw data - if ( - !rawData || - !rawData[currentTag] || - !Array.isArray(rawData[currentTag].tasks) - ) { + if (!rawData || !rawData[tag] || !Array.isArray(rawData[tag].tasks)) { throw new Error( - `Invalid tasks file or tag "${currentTag}" not found at ${tasksPath}` + `Invalid tasks file or tag "${tag}" not found at ${tasksPath}` ); } // Get the tasks for the current tag - const tasks = rawData[currentTag].tasks; + const tasks = rawData[tag].tasks; log( 'info', - `Moving task/subtask ${sourceId} to ${destinationId} (tag: ${currentTag})` + `Moving task/subtask ${sourceId} to ${destinationId} (tag: ${tag})` ); // Parse source and destination IDs @@ -116,14 +106,17 @@ async function moveTask( } // Update the data structure with the modified tasks - rawData[currentTag].tasks = tasks; + rawData[tag].tasks = tasks; // Always write the data object, never the _rawTaggedData directly // The writeJSON function will filter out _rawTaggedData automatically - writeJSON(tasksPath, rawData, options.projectRoot, currentTag); + writeJSON(tasksPath, rawData, options.projectRoot, tag); if (generateFiles) { - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + await generateTaskFiles(tasksPath, path.dirname(tasksPath), { + tag: tag, + projectRoot: projectRoot + }); } return result; diff --git a/scripts/modules/task-manager/parse-prd.js b/scripts/modules/task-manager/parse-prd.js index 46d8e1ee..dcaf567c 100644 --- a/scripts/modules/task-manager/parse-prd.js +++ b/scripts/modules/task-manager/parse-prd.js @@ -76,7 +76,7 @@ async function parsePRD(prdPath, tasksPath, numTasks, options = {}) { const outputFormat = isMCP ? 'json' : 'text'; // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = tag || getCurrentTag(projectRoot) || 'master'; + const targetTag = tag; const logFn = mcpLog ? mcpLog diff --git a/scripts/modules/task-manager/remove-subtask.js b/scripts/modules/task-manager/remove-subtask.js index 596326df..7a9639af 100644 --- a/scripts/modules/task-manager/remove-subtask.js +++ b/scripts/modules/task-manager/remove-subtask.js @@ -9,6 +9,8 @@ import generateTaskFiles from './generate-task-files.js'; * @param {boolean} convertToTask - Whether to convert the subtask to a standalone task * @param {boolean} generateFiles - Whether to regenerate task files after removing the subtask * @param {Object} context - Context object containing projectRoot and tag information + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task * @returns {Object|null} The removed subtask if convertToTask is true, otherwise null */ async function removeSubtask( @@ -18,11 +20,12 @@ async function removeSubtask( generateFiles = true, context = {} ) { + const { projectRoot, tag } = context; try { log('info', `Removing subtask ${subtaskId}...`); // Read the existing tasks with proper context - const data = readJSON(tasksPath, context.projectRoot, context.tag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error(`Invalid or missing tasks file at ${tasksPath}`); } @@ -103,7 +106,7 @@ async function removeSubtask( } // Write the updated tasks back to the file with proper context - writeJSON(tasksPath, data, context.projectRoot, context.tag); + writeJSON(tasksPath, data, projectRoot, tag); // Generate task files if requested if (generateFiles) { diff --git a/scripts/modules/task-manager/remove-task.js b/scripts/modules/task-manager/remove-task.js index 0406482e..ecce2d64 100644 --- a/scripts/modules/task-manager/remove-task.js +++ b/scripts/modules/task-manager/remove-task.js @@ -9,6 +9,8 @@ import taskExists from './task-exists.js'; * @param {string} tasksPath - Path to the tasks file * @param {string} taskIds - Comma-separated string of task/subtask IDs to remove (e.g., '5,6.1,7') * @param {Object} context - Context object containing projectRoot and tag information + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task * @returns {Object} Result object with success status, messages, and removed task info */ async function removeTask(tasksPath, taskIds, context = {}) { @@ -32,7 +34,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { try { // Read the tasks file ONCE before the loop, preserving the full tagged structure - const rawData = readJSON(tasksPath, projectRoot); // Read raw data + const rawData = readJSON(tasksPath, projectRoot, tag); // Read raw data if (!rawData) { throw new Error(`Could not read tasks file at ${tasksPath}`); } @@ -40,19 +42,18 @@ async function removeTask(tasksPath, taskIds, context = {}) { // Use the full tagged data if available, otherwise use the data as is const fullTaggedData = rawData._rawTaggedData || rawData; - const currentTag = tag || rawData.tag || 'master'; - if (!fullTaggedData[currentTag] || !fullTaggedData[currentTag].tasks) { - throw new Error(`Tag '${currentTag}' not found or has no tasks.`); + if (!fullTaggedData[tag] || !fullTaggedData[tag].tasks) { + throw new Error(`Tag '${tag}' not found or has no tasks.`); } - const tasks = fullTaggedData[currentTag].tasks; // Work with tasks from the correct tag + const tasks = fullTaggedData[tag].tasks; // Work with tasks from the correct tag const tasksToDeleteFiles = []; // Collect IDs of main tasks whose files should be deleted for (const taskId of taskIdsToRemove) { // Check if the task ID exists *before* attempting removal if (!taskExists(tasks, taskId)) { - const errorMsg = `Task with ID ${taskId} in tag '${currentTag}' not found or already removed.`; + const errorMsg = `Task with ID ${taskId} in tag '${tag}' not found or already removed.`; results.errors.push(errorMsg); results.success = false; // Mark overall success as false if any error occurs continue; // Skip to the next ID @@ -94,7 +95,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { parentTask.subtasks.splice(subtaskIndex, 1); results.messages.push( - `Successfully removed subtask ${taskId} from tag '${currentTag}'` + `Successfully removed subtask ${taskId} from tag '${tag}'` ); } // Handle main task removal @@ -102,9 +103,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { const taskIdNum = parseInt(taskId, 10); const taskIndex = tasks.findIndex((t) => t.id === taskIdNum); if (taskIndex === -1) { - throw new Error( - `Task with ID ${taskId} not found in tag '${currentTag}'` - ); + throw new Error(`Task with ID ${taskId} not found in tag '${tag}'`); } // Store the task info before removal @@ -116,7 +115,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { tasks.splice(taskIndex, 1); results.messages.push( - `Successfully removed task ${taskId} from tag '${currentTag}'` + `Successfully removed task ${taskId} from tag '${tag}'` ); } } catch (innerError) { @@ -139,7 +138,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { ); // Update the tasks in the current tag of the full data structure - fullTaggedData[currentTag].tasks = tasks; + fullTaggedData[tag].tasks = tasks; // Remove dependencies from all tags for (const tagName in fullTaggedData) { @@ -171,7 +170,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { } // Save the updated raw data structure - writeJSON(tasksPath, fullTaggedData, projectRoot, currentTag); + writeJSON(tasksPath, fullTaggedData, projectRoot, tag); // Delete task files AFTER saving tasks.json for (const taskIdNum of tasksToDeleteFiles) { @@ -196,7 +195,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { try { await generateTaskFiles(tasksPath, path.dirname(tasksPath), { projectRoot, - tag: currentTag + tag }); results.messages.push('Task files regenerated successfully.'); } catch (genError) { diff --git a/scripts/modules/task-manager/research.js b/scripts/modules/task-manager/research.js index 9ab1adff..c84f7ff8 100644 --- a/scripts/modules/task-manager/research.js +++ b/scripts/modules/task-manager/research.js @@ -35,6 +35,7 @@ import { * @param {boolean} [options.includeProjectTree] - Include project file tree * @param {string} [options.detailLevel] - Detail level: 'low', 'medium', 'high' * @param {string} [options.projectRoot] - Project root directory + * @param {string} [options.tag] - Tag for the task * @param {boolean} [options.saveToFile] - Whether to save results to file (MCP mode) * @param {Object} [context] - Execution context * @param {Object} [context.session] - MCP session object @@ -59,6 +60,7 @@ async function performResearch( includeProjectTree = false, detailLevel = 'medium', projectRoot: providedProjectRoot, + tag, saveToFile = false } = options; @@ -101,7 +103,7 @@ async function performResearch( try { // Initialize context gatherer - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); // Auto-discover relevant tasks using fuzzy search to supplement provided tasks let finalTaskIds = [...taskIds]; // Start with explicitly provided tasks @@ -114,7 +116,7 @@ async function performResearch( 'tasks', 'tasks.json' ); - const tasksData = await readJSON(tasksPath, projectRoot); + const tasksData = await readJSON(tasksPath, projectRoot, tag); if (tasksData && tasksData.tasks && tasksData.tasks.length > 0) { // Flatten tasks to include subtasks for fuzzy search @@ -769,10 +771,7 @@ async function handleSaveToTask( return; } - // Validate ID exists - use tag from context - const { getCurrentTag } = await import('../utils.js'); - const tag = context.tag || getCurrentTag(projectRoot) || 'master'; - const data = readJSON(tasksPath, projectRoot, tag); + const data = readJSON(tasksPath, projectRoot, context.tag); if (!data || !data.tasks) { console.log(chalk.red('❌ No valid tasks found.')); return; @@ -806,7 +805,7 @@ async function handleSaveToTask( trimmedTaskId, conversationThread, false, // useResearch = false for simple append - { ...context, tag }, + context, 'text' ); @@ -833,7 +832,7 @@ async function handleSaveToTask( taskIdNum, conversationThread, false, // useResearch = false for simple append - { ...context, tag }, + context, 'text', true // appendMode = true ); diff --git a/scripts/modules/task-manager/set-task-status.js b/scripts/modules/task-manager/set-task-status.js index 218aad3d..18c18ced 100644 --- a/scripts/modules/task-manager/set-task-status.js +++ b/scripts/modules/task-manager/set-task-status.js @@ -7,7 +7,6 @@ import { readJSON, writeJSON, findTaskById, - getCurrentTag, ensureTagMetadata } from '../utils.js'; import { displayBanner } from '../ui.js'; @@ -26,16 +25,13 @@ import { * @param {string} taskIdInput - Task ID(s) to update * @param {string} newStatus - New status * @param {Object} options - Additional options (mcpLog for MCP mode, projectRoot for tag resolution) - * @param {string} tag - Optional tag to override current tag resolution + * @param {string} [options.projectRoot] - Project root path + * @param {string} [options.tag] - Optional tag to override current tag resolution + * @param {string} [options.mcpLog] - MCP logger object * @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode */ -async function setTaskStatus( - tasksPath, - taskIdInput, - newStatus, - options = {}, - tag = null -) { +async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) { + const { projectRoot, tag } = options; try { if (!isValidTaskStatus(newStatus)) { throw new Error( @@ -59,7 +55,7 @@ async function setTaskStatus( log('info', `Reading tasks from ${tasksPath}...`); // Read the raw data without tag resolution to preserve tagged structure - let rawData = readJSON(tasksPath, options.projectRoot); // No tag parameter + let rawData = readJSON(tasksPath, projectRoot, tag); // No tag parameter // Handle the case where readJSON returns resolved data with _rawTaggedData if (rawData && rawData._rawTaggedData) { @@ -67,24 +63,17 @@ async function setTaskStatus( rawData = rawData._rawTaggedData; } - // Determine the current tag - const currentTag = tag || getCurrentTag(options.projectRoot) || 'master'; - // Ensure the tag exists in the raw data - if ( - !rawData || - !rawData[currentTag] || - !Array.isArray(rawData[currentTag].tasks) - ) { + if (!rawData || !rawData[tag] || !Array.isArray(rawData[tag].tasks)) { throw new Error( - `Invalid tasks file or tag "${currentTag}" not found at ${tasksPath}` + `Invalid tasks file or tag "${tag}" not found at ${tasksPath}` ); } // Get the tasks for the current tag const data = { - tasks: rawData[currentTag].tasks, - tag: currentTag, + tasks: rawData[tag].tasks, + tag, _rawTaggedData: rawData }; @@ -123,16 +112,16 @@ async function setTaskStatus( } // Update the raw data structure with the modified tasks - rawData[currentTag].tasks = data.tasks; + rawData[tag].tasks = data.tasks; // Ensure the tag has proper metadata - ensureTagMetadata(rawData[currentTag], { - description: `Tasks for ${currentTag} context` + ensureTagMetadata(rawData[tag], { + description: `Tasks for ${tag} context` }); // Write the updated raw data back to the file // The writeJSON function will automatically filter out _rawTaggedData - writeJSON(tasksPath, rawData, options.projectRoot, currentTag); + writeJSON(tasksPath, rawData, projectRoot, tag); // Validate dependencies after status update log('info', 'Validating dependencies after status update...'); diff --git a/scripts/modules/task-manager/update-subtask-by-id.js b/scripts/modules/task-manager/update-subtask-by-id.js index ee12a81d..41efb01f 100644 --- a/scripts/modules/task-manager/update-subtask-by-id.js +++ b/scripts/modules/task-manager/update-subtask-by-id.js @@ -17,8 +17,7 @@ import { truncate, isSilentMode, findProjectRoot, - flattenTasksWithSubtasks, - getCurrentTag + flattenTasksWithSubtasks } from '../utils.js'; import { generateTextService } from '../ai-services-unified.js'; import { getDebugFlag } from '../config-manager.js'; @@ -37,6 +36,7 @@ import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; * @param {Object} [context.session] - Session object from MCP server. * @param {Object} [context.mcpLog] - MCP logger object. * @param {string} [context.projectRoot] - Project root path (needed for AI service key resolution). + * @param {string} [context.tag] - Tag for the task * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). Automatically 'json' if mcpLog is present. * @returns {Promise} - The updated subtask or null if update failed. */ @@ -92,10 +92,7 @@ async function updateSubtaskById( throw new Error('Could not determine project root directory'); } - // Determine the tag to use - const currentTag = tag || getCurrentTag(projectRoot) || 'master'; - - const data = readJSON(tasksPath, projectRoot, currentTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error( `No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.` @@ -142,7 +139,7 @@ async function updateSubtaskById( // --- Context Gathering --- let gatheredContext = ''; try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(data.tasks); const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-subtask'); const searchQuery = `${parentTask.title} ${subtask.title} ${prompt}`; @@ -331,13 +328,17 @@ async function updateSubtaskById( if (outputFormat === 'text' && getDebugFlag(session)) { console.log('>>> DEBUG: About to call writeJSON with updated data...'); } - writeJSON(tasksPath, data, projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); if (outputFormat === 'text' && getDebugFlag(session)) { console.log('>>> DEBUG: writeJSON call completed.'); } report('success', `Successfully updated subtask ${subtaskId}`); - // await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + // Updated function call to make sure if uncommented it will generate the task files for the updated subtask based on the tag + // await generateTaskFiles(tasksPath, path.dirname(tasksPath), { + // tag: tag, + // projectRoot: projectRoot + // }); if (outputFormat === 'text') { if (loadingIndicator) { diff --git a/scripts/modules/task-manager/update-task-by-id.js b/scripts/modules/task-manager/update-task-by-id.js index 19603897..f5c90fa7 100644 --- a/scripts/modules/task-manager/update-task-by-id.js +++ b/scripts/modules/task-manager/update-task-by-id.js @@ -12,8 +12,7 @@ import { truncate, isSilentMode, flattenTasksWithSubtasks, - findProjectRoot, - getCurrentTag + findProjectRoot } from '../utils.js'; import { @@ -262,6 +261,7 @@ function parseUpdatedTaskFromText(text, expectedTaskId, logFn, isMCP) { * @param {Object} [context.session] - Session object from MCP server. * @param {Object} [context.mcpLog] - MCP logger object. * @param {string} [context.projectRoot] - Project root path. + * @param {string} [context.tag] - Tag for the task * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). * @param {boolean} [appendMode=false] - If true, append to details instead of full update. * @returns {Promise} - The updated task or null if update failed. @@ -320,11 +320,8 @@ async function updateTaskById( throw new Error('Could not determine project root directory'); } - // Determine the tag to use - const currentTag = tag || getCurrentTag(projectRoot) || 'master'; - // --- Task Loading and Status Check (Keep existing) --- - const data = readJSON(tasksPath, projectRoot, currentTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) throw new Error(`No valid tasks found in ${tasksPath}.`); const taskIndex = data.tasks.findIndex((task) => task.id === taskId); @@ -364,7 +361,7 @@ async function updateTaskById( // --- Context Gathering --- let gatheredContext = ''; try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(data.tasks); const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-task'); const searchQuery = `${taskToUpdate.title} ${taskToUpdate.description} ${prompt}`; @@ -559,7 +556,7 @@ async function updateTaskById( // Write the updated task back to file data.tasks[taskIndex] = taskToUpdate; - writeJSON(tasksPath, data, projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); report('success', `Successfully appended to task ${taskId}`); // Display success message for CLI @@ -704,7 +701,7 @@ async function updateTaskById( // --- End Update Task Data --- // --- Write File and Generate (Unchanged) --- - writeJSON(tasksPath, data, projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); report('success', `Successfully updated task ${taskId}`); // await generateTaskFiles(tasksPath, path.dirname(tasksPath)); // --- End Write File --- diff --git a/scripts/modules/task-manager/update-tasks.js b/scripts/modules/task-manager/update-tasks.js index 43b854b2..726872ee 100644 --- a/scripts/modules/task-manager/update-tasks.js +++ b/scripts/modules/task-manager/update-tasks.js @@ -9,8 +9,7 @@ import { readJSON, writeJSON, truncate, - isSilentMode, - getCurrentTag + isSilentMode } from '../utils.js'; import { @@ -234,8 +233,8 @@ function parseUpdatedTasksFromText(text, expectedCount, logFn, isMCP) { * @param {Object} context - Context object containing session and mcpLog. * @param {Object} [context.session] - Session object from MCP server. * @param {Object} [context.mcpLog] - MCP logger object. + * @param {string} [context.tag] - Tag for the task * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). - * @param {string} [tag=null] - Tag associated with the tasks. */ async function updateTasks( tasksPath, @@ -269,11 +268,8 @@ async function updateTasks( throw new Error('Could not determine project root directory'); } - // Determine the current tag - prioritize explicit tag, then context.tag, then current tag - const currentTag = tag || getCurrentTag(projectRoot) || 'master'; - // --- Task Loading/Filtering (Updated to pass projectRoot and tag) --- - const data = readJSON(tasksPath, projectRoot, currentTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) throw new Error(`No valid tasks found in ${tasksPath}`); const tasksToUpdate = data.tasks.filter( @@ -292,7 +288,7 @@ async function updateTasks( // --- Context Gathering --- let gatheredContext = ''; try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(data.tasks); const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update'); const searchResults = fuzzySearch.findRelevantTasks(prompt, { @@ -478,7 +474,7 @@ async function updateTasks( ); // Fix: Pass projectRoot and currentTag to writeJSON - writeJSON(tasksPath, data, projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); if (isMCP) logFn.info( `Successfully updated ${actualUpdateCount} tasks in ${tasksPath}` diff --git a/scripts/modules/ui.js b/scripts/modules/ui.js index 7a6737ba..ad417ec1 100644 --- a/scripts/modules/ui.js +++ b/scripts/modules/ui.js @@ -1197,18 +1197,18 @@ async function displayNextTask( * @param {string|number} taskId - The ID of the task to display * @param {string} complexityReportPath - Path to the complexity report file * @param {string} [statusFilter] - Optional status to filter subtasks by - * @param {string} tag - Optional tag to override current tag resolution + * @param {object} context - Context object containing projectRoot and tag + * @param {string} context.projectRoot - Project root path + * @param {string} context.tag - Tag for the task */ async function displayTaskById( tasksPath, taskId, complexityReportPath = null, statusFilter = null, - tag = null, context = {} ) { - // Extract projectRoot from context - const projectRoot = context.projectRoot || null; + const { projectRoot, tag } = context; // Read the tasks file with proper projectRoot for tag resolution const data = readJSON(tasksPath, projectRoot, tag); @@ -2251,7 +2251,9 @@ function displayAiUsageSummary(telemetryData, outputType = 'cli') { * @param {Array} taskIds - Array of task IDs to display * @param {string} complexityReportPath - Path to complexity report * @param {string} statusFilter - Optional status filter for subtasks - * @param {Object} context - Optional context object containing projectRoot and tag + * @param {Object} context - Context object containing projectRoot and tag + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task */ async function displayMultipleTasksSummary( tasksPath, @@ -2602,7 +2604,6 @@ async function displayMultipleTasksSummary( choice.trim(), complexityReportPath, statusFilter, - tag, context ); } diff --git a/scripts/modules/utils.js b/scripts/modules/utils.js index 1f99ea07..e8a92dd9 100644 --- a/scripts/modules/utils.js +++ b/scripts/modules/utils.js @@ -1190,6 +1190,7 @@ function aggregateTelemetry(telemetryArray, overallCommandName) { } /** + * @deprecated Use TaskMaster.getCurrentTag() instead * Gets the current tag from state.json or falls back to defaultTag from config * @param {string} projectRoot - The project root directory (required) * @returns {string} The current tag name diff --git a/scripts/modules/utils/contextGatherer.js b/scripts/modules/utils/contextGatherer.js index 1a826dec..6848f067 100644 --- a/scripts/modules/utils/contextGatherer.js +++ b/scripts/modules/utils/contextGatherer.js @@ -21,7 +21,7 @@ const { encode } = pkg; * Context Gatherer class for collecting and formatting context from various sources */ export class ContextGatherer { - constructor(projectRoot) { + constructor(projectRoot, tag) { this.projectRoot = projectRoot; this.tasksPath = path.join( projectRoot, @@ -29,12 +29,13 @@ export class ContextGatherer { 'tasks', 'tasks.json' ); + this.tag = tag; this.allTasks = this._loadAllTasks(); } _loadAllTasks() { try { - const data = readJSON(this.tasksPath, this.projectRoot); + const data = readJSON(this.tasksPath, this.projectRoot, this.tag); const tasks = data?.tasks || []; return tasks; } catch (error) { @@ -958,10 +959,15 @@ export class ContextGatherer { /** * Factory function to create a context gatherer instance * @param {string} projectRoot - Project root directory + * @param {string} tag - Tag for the task * @returns {ContextGatherer} Context gatherer instance + * @throws {Error} If tag is not provided */ -export function createContextGatherer(projectRoot) { - return new ContextGatherer(projectRoot); +export function createContextGatherer(projectRoot, tag) { + if (!tag) { + throw new Error('Tag is required'); + } + return new ContextGatherer(projectRoot, tag); } export default ContextGatherer; diff --git a/src/task-master.js b/src/task-master.js index 3e92a4ca..51be8140 100644 --- a/src/task-master.js +++ b/src/task-master.js @@ -14,7 +14,8 @@ import { TASKMASTER_DOCS_DIR, TASKMASTER_REPORTS_DIR, TASKMASTER_CONFIG_FILE, - LEGACY_CONFIG_FILE + LEGACY_CONFIG_FILE, + COMPLEXITY_REPORT_FILE } from './constants/paths.js'; /** @@ -23,13 +24,16 @@ import { */ export class TaskMaster { #paths; + #tag; /** * The constructor is intended to be used only by the initTaskMaster factory function. * @param {object} paths - A pre-resolved object of all application paths. + * @param {string|undefined} tag - The current tag. */ - constructor(paths) { + constructor(paths, tag) { this.#paths = Object.freeze({ ...paths }); + this.#tag = tag; } /** @@ -64,7 +68,19 @@ export class TaskMaster { * @returns {string|null} The absolute path to the complexity report. */ getComplexityReportPath() { - return this.#paths.complexityReportPath; + if (this.#paths.complexityReportPath) { + return this.#paths.complexityReportPath; + } + + const complexityReportFile = + this.getCurrentTag() !== 'master' + ? COMPLEXITY_REPORT_FILE.replace( + '.json', + `_${this.getCurrentTag()}.json` + ) + : COMPLEXITY_REPORT_FILE; + + return path.join(this.#paths.projectRoot, complexityReportFile); } /** @@ -87,6 +103,45 @@ export class TaskMaster { getAllPaths() { return this.#paths; } + + /** + * Gets the current tag from state.json or falls back to defaultTag from config + * @returns {string} The current tag name + */ + getCurrentTag() { + if (this.#tag) { + return this.#tag; + } + + try { + // Try to read current tag from state.json using fs directly + if (fs.existsSync(this.#paths.statePath)) { + const rawState = fs.readFileSync(this.#paths.statePath, 'utf8'); + const stateData = JSON.parse(rawState); + if (stateData && stateData.currentTag) { + return stateData.currentTag; + } + } + } catch (error) { + // Ignore errors, fall back to default + } + + // Fall back to defaultTag from config using fs directly + try { + if (fs.existsSync(this.#paths.configPath)) { + const rawConfig = fs.readFileSync(this.#paths.configPath, 'utf8'); + const configData = JSON.parse(rawConfig); + if (configData && configData.global && configData.global.defaultTag) { + return configData.global.defaultTag; + } + } + } catch (error) { + // Ignore errors, use hardcoded default + } + + // Final fallback + return 'master'; + } } /** @@ -100,6 +155,7 @@ export class TaskMaster { * @param {string} [overrides.complexityReportPath] * @param {string} [overrides.configPath] * @param {string} [overrides.statePath] + * @param {string} [overrides.tag] * @returns {TaskMaster} An initialized TaskMaster instance. */ export function initTaskMaster(overrides = {}) { @@ -123,17 +179,33 @@ export function initTaskMaster(overrides = {}) { pathType, override, defaultPaths = [], - basePath = null + basePath = null, + createParentDirs = false ) => { if (typeof override === 'string') { const resolvedPath = path.isAbsolute(override) ? override : path.resolve(basePath || process.cwd(), override); - if (!fs.existsSync(resolvedPath)) { - throw new Error( - `${pathType} override path does not exist: ${resolvedPath}` - ); + if (createParentDirs) { + // For output paths, create parent directory if it doesn't exist + const parentDir = path.dirname(resolvedPath); + if (!fs.existsSync(parentDir)) { + try { + fs.mkdirSync(parentDir, { recursive: true }); + } catch (error) { + throw new Error( + `Could not create directory for ${pathType}: ${parentDir}. Error: ${error.message}` + ); + } + } + } else { + // Original validation logic + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `${pathType} override path does not exist: ${resolvedPath}` + ); + } } return resolvedPath; } @@ -289,9 +361,10 @@ export function initTaskMaster(overrides = {}) { 'task-complexity-report.json', 'complexity-report.json' ], - paths.projectRoot + paths.projectRoot, + true // Enable parent directory creation for output paths ); } - return new TaskMaster(paths); + return new TaskMaster(paths, overrides.tag); } diff --git a/src/utils/path-utils.js b/src/utils/path-utils.js index d764a235..a50f9481 100644 --- a/src/utils/path-utils.js +++ b/src/utils/path-utils.js @@ -271,7 +271,12 @@ export function findComplexityReportPath( '' // Project root ]; - const fileNames = ['task-complexity-report.json', 'complexity-report.json']; + const fileNames = ['task-complexity', 'complexity-report'].map((fileName) => { + if (args?.tag && args?.tag !== 'master') { + return `${fileName}_${args.tag}.json`; + } + return `${fileName}.json`; + }); for (const location of locations) { for (const fileName of fileNames) { @@ -353,6 +358,7 @@ export function resolveComplexityReportOutputPath( log = null ) { const logger = getLoggerOrDefault(log); + const tag = args?.tag; // 1. If explicit path is provided, use it if (explicitPath) { @@ -369,13 +375,19 @@ export function resolveComplexityReportOutputPath( // 2. Try to get project root from args (MCP) or find it const rawProjectRoot = args?.projectRoot || findProjectRoot() || process.cwd(); - - // 3. Normalize project root to prevent double .taskmaster paths const projectRoot = normalizeProjectRoot(rawProjectRoot); + // 3. Use tag-aware filename + let filename = 'task-complexity-report.json'; + if (tag && tag !== 'master') { + filename = `task-complexity-report_${tag}.json`; + } + // 4. Use new .taskmaster structure by default - const defaultPath = path.join(projectRoot, COMPLEXITY_REPORT_FILE); - logger.info?.(`Using default complexity report output path: ${defaultPath}`); + const defaultPath = path.join(projectRoot, '.taskmaster/reports', filename); + logger.info?.( + `Using tag-aware complexity report output path: ${defaultPath}` + ); // Ensure the directory exists const outputDir = path.dirname(defaultPath); diff --git a/tests/e2e/run_e2e.sh b/tests/e2e/run_e2e.sh index 854273ab..59eb0bc3 100755 --- a/tests/e2e/run_e2e.sh +++ b/tests/e2e/run_e2e.sh @@ -368,7 +368,7 @@ log_step() { log_success "Formatted complexity report saved to complexity_report_formatted.log" log_step "Expanding Task 1 (assuming it exists)" - cmd_output_expand1=$(task-master expand --id=1 2>&1) + cmd_output_expand1=$(task-master expand --id=1 --cr complexity_results.json 2>&1) exit_status_expand1=$? echo "$cmd_output_expand1" extract_and_sum_cost "$cmd_output_expand1" diff --git a/tests/unit/scripts/modules/task-manager/add-task.test.js b/tests/unit/scripts/modules/task-manager/add-task.test.js index 43897e66..33ee076d 100644 --- a/tests/unit/scripts/modules/task-manager/add-task.test.js +++ b/tests/unit/scripts/modules/task-manager/add-task.test.js @@ -237,7 +237,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -253,7 +254,8 @@ describe('addTask', () => { // Assert expect(readJSON).toHaveBeenCalledWith( 'tasks/tasks.json', - '/mock/project/root' + '/mock/project/root', + 'master' ); expect(generateObjectService).toHaveBeenCalledWith(expect.any(Object)); expect(writeJSON).toHaveBeenCalledWith( @@ -288,7 +290,8 @@ describe('addTask', () => { const validDependencies = [1, 2]; // These exist in sampleTasks const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -325,7 +328,8 @@ describe('addTask', () => { const invalidDependencies = [999]; // Non-existent task ID const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -367,7 +371,8 @@ describe('addTask', () => { const priority = 'high'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -396,7 +401,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -433,7 +439,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -457,7 +464,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act & Assert @@ -474,7 +482,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act & Assert @@ -491,7 +500,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act & Assert diff --git a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js index a5e64935..37916aee 100644 --- a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js +++ b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js @@ -305,7 +305,7 @@ describe('analyzeTaskComplexity', () => { ); expect(generateTextService).toHaveBeenCalledWith(expect.any(Object)); expect(mockWriteFileSync).toHaveBeenCalledWith( - 'scripts/task-complexity-report.json', + expect.stringContaining('task-complexity-report.json'), expect.stringContaining('"thresholdScore": 5'), 'utf8' ); @@ -362,7 +362,7 @@ describe('analyzeTaskComplexity', () => { }); expect(mockWriteFileSync).toHaveBeenCalledWith( - 'scripts/task-complexity-report.json', + expect.stringContaining('task-complexity-report.json'), expect.stringContaining('"thresholdScore": 7'), 'utf8' ); @@ -390,7 +390,7 @@ describe('analyzeTaskComplexity', () => { }); expect(mockWriteFileSync).toHaveBeenCalledWith( - 'scripts/task-complexity-report.json', + expect.stringContaining('task-complexity-report.json'), expect.stringContaining('"thresholdScore": 8'), 'utf8' ); diff --git a/tests/unit/scripts/modules/task-manager/clear-subtasks.test.js b/tests/unit/scripts/modules/task-manager/clear-subtasks.test.js index cf869f42..c8c6b9b1 100644 --- a/tests/unit/scripts/modules/task-manager/clear-subtasks.test.js +++ b/tests/unit/scripts/modules/task-manager/clear-subtasks.test.js @@ -103,6 +103,9 @@ describe('clearSubtasks', () => { jest.clearAllMocks(); mockExit.mockClear(); readJSON.mockImplementation((tasksPath, projectRoot, tag) => { + // Ensure tag contract is honoured + expect(tag).toBeDefined(); + expect(tag).toBe('master'); // Create a deep copy to avoid mutation issues between tests const sampleTasksCopy = JSON.parse(JSON.stringify(sampleTasks)); // Return the data for the 'master' tag, which is what the tests use @@ -121,12 +124,13 @@ describe('clearSubtasks', () => { // Arrange const taskId = '3'; const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskId); + clearSubtasks(tasksPath, taskId, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -142,7 +146,7 @@ describe('clearSubtasks', () => { }) }), undefined, - undefined + 'master' ); }); @@ -150,12 +154,13 @@ describe('clearSubtasks', () => { // Arrange const taskIds = '3,4'; const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskIds); + clearSubtasks(tasksPath, taskIds, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -169,7 +174,7 @@ describe('clearSubtasks', () => { }) }), undefined, - undefined + 'master' ); }); @@ -177,12 +182,13 @@ describe('clearSubtasks', () => { // Arrange const taskId = '1'; // Task 1 already has no subtasks const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskId); + clearSubtasks(tasksPath, taskId, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Should not write the file if no changes were made expect(writeJSON).not.toHaveBeenCalled(); expect(generateTaskFiles).not.toHaveBeenCalled(); @@ -192,12 +198,13 @@ describe('clearSubtasks', () => { // Arrange const taskId = '99'; // Non-existent task const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskId); + clearSubtasks(tasksPath, taskId, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(log).toHaveBeenCalledWith('error', 'Task 99 not found'); // Should not write the file if no changes were made expect(writeJSON).not.toHaveBeenCalled(); @@ -208,12 +215,13 @@ describe('clearSubtasks', () => { // Arrange const taskIds = '3,99'; // Mix of valid and invalid IDs const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskIds); + clearSubtasks(tasksPath, taskIds, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(log).toHaveBeenCalledWith('error', 'Task 99 not found'); // Since task 3 has subtasks that should be cleared, writeJSON should be called expect(writeJSON).toHaveBeenCalledWith( @@ -232,7 +240,7 @@ describe('clearSubtasks', () => { }) }), undefined, - undefined + 'master' ); }); @@ -244,7 +252,7 @@ describe('clearSubtasks', () => { // Act & Assert expect(() => { - clearSubtasks('tasks/tasks.json', '3'); + clearSubtasks('tasks/tasks.json', '3', { tag: 'master' }); }).toThrow('File read failed'); }); @@ -254,7 +262,7 @@ describe('clearSubtasks', () => { // Act & Assert expect(() => { - clearSubtasks('tasks/tasks.json', '3'); + clearSubtasks('tasks/tasks.json', '3', { tag: 'master' }); }).toThrow('process.exit called'); expect(log).toHaveBeenCalledWith('error', 'No valid tasks found.'); @@ -283,7 +291,7 @@ describe('clearSubtasks', () => { // Act & Assert expect(() => { - clearSubtasks('tasks/tasks.json', '3'); + clearSubtasks('tasks/tasks.json', '3', { tag: 'master' }); }).toThrow('File write failed'); }); }); diff --git a/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js b/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js new file mode 100644 index 00000000..93698e51 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js @@ -0,0 +1,1124 @@ +/** + * Tests for complexity report tag isolation functionality + * Verifies that different tags maintain separate complexity reports + */ + +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; + +// Mock the dependencies +jest.unstable_mockModule('../../../../../src/utils/path-utils.js', () => ({ + resolveComplexityReportOutputPath: jest.fn(), + findComplexityReportPath: jest.fn(), + findConfigPath: jest.fn(), + findPRDPath: jest.fn(() => '/mock/project/root/.taskmaster/docs/PRD.md'), + findTasksPath: jest.fn( + () => '/mock/project/root/.taskmaster/tasks/tasks.json' + ), + findProjectRoot: jest.fn(() => '/mock/project/root'), + normalizeProjectRoot: jest.fn((root) => root) +})); + +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + isSilentMode: jest.fn(() => false), + enableSilentMode: jest.fn(), + disableSilentMode: jest.fn(), + flattenTasksWithSubtasks: jest.fn((tasks) => tasks), + getTagAwareFilePath: jest.fn((basePath, tag, projectRoot) => { + if (tag && tag !== 'master') { + const dir = path.dirname(basePath); + const ext = path.extname(basePath); + const name = path.basename(basePath, ext); + return path.join(projectRoot || '.', dir, `${name}_${tag}${ext}`); + } + return path.join(projectRoot || '.', basePath); + }), + findTaskById: jest.fn((tasks, taskId) => { + if (!tasks || !Array.isArray(tasks)) { + return { task: null, originalSubtaskCount: null, originalSubtasks: null }; + } + const id = parseInt(taskId, 10); + const task = tasks.find((t) => t.id === id); + return task + ? { task, originalSubtaskCount: null, originalSubtasks: null } + : { task: null, originalSubtaskCount: null, originalSubtasks: null }; + }), + taskExists: jest.fn((tasks, taskId) => { + if (!tasks || !Array.isArray(tasks)) return false; + const id = parseInt(taskId, 10); + return tasks.some((t) => t.id === id); + }), + formatTaskId: jest.fn((id) => `Task ${id}`), + findCycles: jest.fn(() => []), + truncate: jest.fn((text) => text), + addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })), + aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {}), + ensureTagMetadata: jest.fn((tagObj) => tagObj), + getCurrentTag: jest.fn(() => 'master'), + markMigrationForNotice: jest.fn(), + performCompleteTagMigration: jest.fn(), + setTasksForTag: jest.fn(), + getTasksForTag: jest.fn((data, tag) => data[tag]?.tasks || []), + findProjectRoot: jest.fn(() => '/mock/project/root'), + readComplexityReport: jest.fn(), + findTaskInComplexityReport: jest.fn(), + resolveEnvVariable: jest.fn((varName) => `mock_${varName}`), + isEmpty: jest.fn(() => false), + normalizeProjectRoot: jest.fn((root) => root), + slugifyTagForFilePath: jest.fn((tagName) => { + if (!tagName || typeof tagName !== 'string') { + return 'unknown-tag'; + } + return tagName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + }), + createTagAwareFilePath: jest.fn((basePath, tag, projectRoot) => { + if (tag && tag !== 'master') { + const dir = path.dirname(basePath); + const ext = path.extname(basePath); + const name = path.basename(basePath, ext); + // Use the slugified tag + const slugifiedTag = tag.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + return path.join( + projectRoot || '.', + dir, + `${name}_${slugifiedTag}${ext}` + ); + } + return path.join(projectRoot || '.', basePath); + }), + CONFIG: { + defaultSubtasks: 3 + } +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/ai-services-unified.js', + () => ({ + generateTextService: jest.fn().mockImplementation((params) => { + const commandName = params?.commandName || 'default'; + + if (commandName === 'analyze-complexity') { + // Check if this is for a specific tag test by looking at the prompt + const isFeatureTag = + params?.prompt?.includes('feature') || params?.role === 'feature'; + const isMasterTag = + params?.prompt?.includes('master') || params?.role === 'master'; + + let taskTitle = 'Test Task'; + if (isFeatureTag) { + taskTitle = 'Feature Task 1'; + } else if (isMasterTag) { + taskTitle = 'Master Task 1'; + } + + return Promise.resolve({ + mainResult: JSON.stringify([ + { + taskId: 1, + taskTitle: taskTitle, + complexityScore: 7, + recommendedSubtasks: 4, + expansionPrompt: 'Break down this task', + reasoning: 'This task is moderately complex' + }, + { + taskId: 2, + taskTitle: 'Task 2', + complexityScore: 5, + recommendedSubtasks: 3, + expansionPrompt: 'Break down this task with a focus on task 2.', + reasoning: + 'Automatically added due to missing analysis in AI response.' + } + ]), + telemetryData: { + timestamp: new Date().toISOString(), + commandName: 'analyze-complexity', + modelUsed: 'claude-3-5-sonnet', + providerName: 'anthropic', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + totalCost: 0.012414, + currency: 'USD' + } + }); + } else { + // Default for expand-task and others + return Promise.resolve({ + mainResult: JSON.stringify({ + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'First subtask', + dependencies: [], + details: 'Implementation details', + status: 'pending', + testStrategy: 'Test strategy' + } + ] + }), + telemetryData: { + timestamp: new Date().toISOString(), + commandName: commandName || 'expand-task', + modelUsed: 'claude-3-5-sonnet', + providerName: 'anthropic', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + totalCost: 0.012414, + currency: 'USD' + } + }); + } + }), + generateObjectService: jest.fn().mockResolvedValue({ + mainResult: { + object: { + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'First subtask', + dependencies: [], + details: 'Implementation details', + status: 'pending', + testStrategy: 'Test strategy' + } + ] + } + }, + telemetryData: { + timestamp: new Date().toISOString(), + commandName: 'expand-task', + modelUsed: 'claude-3-5-sonnet', + providerName: 'anthropic', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + totalCost: 0.012414, + currency: 'USD' + } + }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/config-manager.js', + () => ({ + // Core config access + getConfig: jest.fn(() => ({ + models: { main: { provider: 'anthropic', modelId: 'claude-3-5-sonnet' } }, + global: { projectName: 'Test Project' } + })), + writeConfig: jest.fn(() => true), + ConfigurationError: class extends Error {}, + isConfigFilePresent: jest.fn(() => true), + + // Validation + validateProvider: jest.fn(() => true), + validateProviderModelCombination: jest.fn(() => true), + VALIDATED_PROVIDERS: ['anthropic', 'openai', 'perplexity'], + CUSTOM_PROVIDERS: { OLLAMA: 'ollama', BEDROCK: 'bedrock' }, + ALL_PROVIDERS: ['anthropic', 'openai', 'perplexity', 'ollama', 'bedrock'], + MODEL_MAP: { + anthropic: [ + { + id: 'claude-3-5-sonnet', + cost_per_1m_tokens: { input: 3, output: 15 } + } + ], + openai: [{ id: 'gpt-4', cost_per_1m_tokens: { input: 30, output: 60 } }] + }, + getAvailableModels: jest.fn(() => [ + { + id: 'claude-3-5-sonnet', + name: 'Claude 3.5 Sonnet', + provider: 'anthropic' + }, + { id: 'gpt-4', name: 'GPT-4', provider: 'openai' } + ]), + + // Role-specific getters + getMainProvider: jest.fn(() => 'anthropic'), + getMainModelId: jest.fn(() => 'claude-3-5-sonnet'), + getMainMaxTokens: jest.fn(() => 4000), + getMainTemperature: jest.fn(() => 0.7), + getResearchProvider: jest.fn(() => 'perplexity'), + getResearchModelId: jest.fn(() => 'sonar-pro'), + getResearchMaxTokens: jest.fn(() => 8700), + getResearchTemperature: jest.fn(() => 0.1), + getFallbackProvider: jest.fn(() => 'anthropic'), + getFallbackModelId: jest.fn(() => 'claude-3-5-sonnet'), + getFallbackMaxTokens: jest.fn(() => 4000), + getFallbackTemperature: jest.fn(() => 0.7), + getBaseUrlForRole: jest.fn(() => undefined), + + // Global setting getters + getLogLevel: jest.fn(() => 'info'), + getDebugFlag: jest.fn(() => false), + getDefaultNumTasks: jest.fn(() => 10), + getDefaultSubtasks: jest.fn(() => 5), + getDefaultPriority: jest.fn(() => 'medium'), + getProjectName: jest.fn(() => 'Test Project'), + getOllamaBaseURL: jest.fn(() => 'http://localhost:11434/api'), + getAzureBaseURL: jest.fn(() => undefined), + getBedrockBaseURL: jest.fn(() => undefined), + getParametersForRole: jest.fn(() => ({ + maxTokens: 4000, + temperature: 0.7 + })), + getUserId: jest.fn(() => '1234567890'), + + // API Key Checkers + isApiKeySet: jest.fn(() => true), + getMcpApiKeyStatus: jest.fn(() => true), + + // Additional functions + getAllProviders: jest.fn(() => ['anthropic', 'openai', 'perplexity']), + getVertexProjectId: jest.fn(() => undefined), + getVertexLocation: jest.fn(() => undefined) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/prompt-manager.js', + () => ({ + getPromptManager: jest.fn().mockReturnValue({ + loadPrompt: jest.fn().mockResolvedValue({ + systemPrompt: 'Mocked system prompt', + userPrompt: 'Mocked user prompt' + }) + }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/contextGatherer.js', + () => { + class MockContextGatherer { + constructor(projectRoot, tag) { + this.projectRoot = projectRoot; + this.tag = tag; + this.allTasks = []; + } + + async gather(options = {}) { + return { + context: 'Mock context gathered', + analysisData: null, + contextSections: 1, + finalTaskIds: options.tasks || [] + }; + } + } + + return { + default: MockContextGatherer, + ContextGatherer: MockContextGatherer, + createContextGatherer: jest.fn( + (projectRoot, tag) => new MockContextGatherer(projectRoot, tag) + ) + }; + } +); + +jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), + stopLoadingIndicator: jest.fn(), + displayAiUsageSummary: jest.fn(), + displayBanner: jest.fn(), + getStatusWithColor: jest.fn((status) => status), + succeedLoadingIndicator: jest.fn(), + failLoadingIndicator: jest.fn(), + warnLoadingIndicator: jest.fn(), + infoLoadingIndicator: jest.fn(), + displayContextAnalysis: jest.fn(), + createProgressBar: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + update: jest.fn() + })), + displayTable: jest.fn(), + displayBox: jest.fn(), + displaySuccess: jest.fn(), + displayError: jest.fn(), + displayWarning: jest.fn(), + displayInfo: jest.fn(), + displayTaskDetails: jest.fn(), + displayTaskList: jest.fn(), + displayComplexityReport: jest.fn(), + displayNextTask: jest.fn(), + displayDependencyStatus: jest.fn(), + displayMigrationNotice: jest.fn(), + formatDependenciesWithStatus: jest.fn((deps) => deps), + formatTaskId: jest.fn((id) => `Task ${id}`), + formatPriority: jest.fn((priority) => priority), + formatDuration: jest.fn((duration) => duration), + formatDate: jest.fn((date) => date), + formatComplexityScore: jest.fn((score) => score), + formatTelemetryData: jest.fn((data) => data), + formatContextSummary: jest.fn((context) => context), + formatTagName: jest.fn((tag) => tag), + formatFilePath: jest.fn((path) => path), + getComplexityWithColor: jest.fn((complexity) => complexity), + getPriorityWithColor: jest.fn((priority) => priority), + getTagWithColor: jest.fn((tag) => tag), + getDependencyWithColor: jest.fn((dep) => dep), + getTelemetryWithColor: jest.fn((data) => data), + getContextWithColor: jest.fn((context) => context) +})); + +// Mock fs module +const mockWriteFileSync = jest.fn(); +const mockExistsSync = jest.fn(); +const mockReadFileSync = jest.fn(); +const mockMkdirSync = jest.fn(); + +jest.unstable_mockModule('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync + }, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync +})); + +// Import the mocked modules +const { resolveComplexityReportOutputPath, findComplexityReportPath } = + await import('../../../../../src/utils/path-utils.js'); + +const { readJSON, writeJSON, getTagAwareFilePath } = await import( + '../../../../../scripts/modules/utils.js' +); + +const { generateTextService } = await import( + '../../../../../scripts/modules/ai-services-unified.js' +); + +// Import the modules under test +const { default: analyzeTaskComplexity } = await import( + '../../../../../scripts/modules/task-manager/analyze-task-complexity.js' +); + +const { default: expandTask } = await import( + '../../../../../scripts/modules/task-manager/expand-task.js' +); + +describe('Complexity Report Tag Isolation', () => { + const projectRoot = '/mock/project/root'; + const sampleTasks = { + tasks: [ + { + id: 1, + title: 'Task 1', + description: 'First task', + status: 'pending' + }, + { + id: 2, + title: 'Task 2', + description: 'Second task', + status: 'pending' + } + ] + }; + + const sampleComplexityReport = { + meta: { + generatedAt: new Date().toISOString(), + tasksAnalyzed: 2, + totalTasks: 2, + analysisCount: 2, + thresholdScore: 5, + projectName: 'Test Project', + usedResearch: false + }, + complexityAnalysis: [ + { + taskId: 1, + taskTitle: 'Task 1', + complexityScore: 7, + recommendedSubtasks: 4, + expansionPrompt: 'Break down this task', + reasoning: 'This task is moderately complex' + }, + { + taskId: 2, + taskTitle: 'Task 2', + complexityScore: 5, + recommendedSubtasks: 3, + expansionPrompt: 'Break down this task', + reasoning: 'This task is moderately complex' + } + ] + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock implementations + readJSON.mockReturnValue(sampleTasks); + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockImplementation(() => {}); + + // Mock resolveComplexityReportOutputPath to return tag-aware paths + resolveComplexityReportOutputPath.mockImplementation( + (explicitPath, args) => { + const tag = args?.tag; + if (explicitPath) { + return explicitPath; + } + + let filename = 'task-complexity-report.json'; + if (tag && tag !== 'master') { + // Use slugified tag for cross-platform compatibility + const slugifiedTag = tag + .replace(/[^a-zA-Z0-9_-]/g, '-') + .toLowerCase(); + filename = `task-complexity-report_${slugifiedTag}.json`; + } + + return path.join(projectRoot, '.taskmaster/reports', filename); + } + ); + + // Mock findComplexityReportPath to return tag-aware paths + findComplexityReportPath.mockImplementation((explicitPath, args) => { + const tag = args?.tag; + if (explicitPath) { + return explicitPath; + } + + let filename = 'task-complexity-report.json'; + if (tag && tag !== 'master') { + filename = `task-complexity-report_${tag}.json`; + } + + return path.join(projectRoot, '.taskmaster/reports', filename); + }); + }); + + describe('Path Resolution Tag Isolation', () => { + test('should resolve master tag to default filename', () => { + const result = resolveComplexityReportOutputPath(null, { + tag: 'master', + projectRoot + }); + expect(result).toBe( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + + test('should resolve non-master tag to tag-specific filename', () => { + const result = resolveComplexityReportOutputPath(null, { + tag: 'feature-auth', + projectRoot + }); + expect(result).toBe( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ) + ); + }); + + test('should resolve undefined tag to default filename', () => { + const result = resolveComplexityReportOutputPath(null, { projectRoot }); + expect(result).toBe( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + + test('should respect explicit path over tag-aware resolution', () => { + const explicitPath = '/custom/path/report.json'; + const result = resolveComplexityReportOutputPath(explicitPath, { + tag: 'feature-auth', + projectRoot + }); + expect(result).toBe(explicitPath); + }); + }); + + describe('Analysis Generation Tag Isolation', () => { + test('should generate master tag report to default location', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'master' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: 'master', + projectRoot + }), + expect.any(Function) + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ), + expect.any(String), + 'utf8' + ); + }); + + test('should generate feature tag report to tag-specific location', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: 'feature-auth', + projectRoot + }), + expect.any(Function) + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ), + expect.any(String), + 'utf8' + ); + }); + + test('should not overwrite master report when analyzing feature tag', async () => { + // First, analyze master tag + const masterOptions = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'master' + }; + + await analyzeTaskComplexity(masterOptions, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Clear mocks to verify separate calls + jest.clearAllMocks(); + readJSON.mockReturnValue(sampleTasks); + + // Then, analyze feature tag + const featureOptions = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(featureOptions, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Verify that the feature tag analysis wrote to its own file + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ), + expect.any(String), + 'utf8' + ); + + // Verify that it did NOT write to the master file + expect(mockWriteFileSync).not.toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ), + expect.any(String), + 'utf8' + ); + }); + }); + + describe('Report Reading Tag Isolation', () => { + test('should read master tag report from default location', async () => { + // Mock existing master report + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'master' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(mockExistsSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + + test('should read feature tag report from tag-specific location', async () => { + // Mock existing feature tag report + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report_feature-auth.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(mockExistsSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ) + ); + }); + + test('should not read master report when working with feature tag', async () => { + // Mock that feature tag report exists but master doesn't + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report_feature-auth.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Should check for feature tag report + expect(mockExistsSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ) + ); + + // Should NOT check for master report + expect(mockExistsSync).not.toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + }); + + describe('Expand Task Tag Isolation', () => { + test('should use tag-specific complexity report for expansion', async () => { + // Mock existing feature tag report + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report_feature-auth.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const tasksPath = path.join(projectRoot, 'tasks/tasks.json'); + const taskId = 1; + const numSubtasks = 3; + + await expandTask( + tasksPath, + taskId, + numSubtasks, + false, // useResearch + '', // additionalContext + { + projectRoot, + tag: 'feature-auth', + complexityReportPath: path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ), + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }, + false // force + ); + + // Should read from feature tag report + expect(readJSON).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ) + ); + }); + + test('should use master complexity report for master tag expansion', async () => { + // Mock existing master report + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const tasksPath = path.join(projectRoot, 'tasks/tasks.json'); + const taskId = 1; + const numSubtasks = 3; + + await expandTask( + tasksPath, + taskId, + numSubtasks, + false, // useResearch + '', // additionalContext + { + projectRoot, + tag: 'master', + complexityReportPath: path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ), + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }, + false // force + ); + + // Should read from master report + expect(readJSON).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + }); + + describe('Cross-Tag Contamination Prevention', () => { + test('should maintain separate reports for different tags', async () => { + // Create different complexity reports for different tags + const masterReport = { + ...sampleComplexityReport, + complexityAnalysis: [ + { + taskId: 1, + taskTitle: 'Master Task 1', + complexityScore: 8, + recommendedSubtasks: 5, + expansionPrompt: 'Master expansion', + reasoning: 'Master task reasoning' + } + ] + }; + + const featureReport = { + ...sampleComplexityReport, + complexityAnalysis: [ + { + taskId: 1, + taskTitle: 'Feature Task 1', + complexityScore: 6, + recommendedSubtasks: 3, + expansionPrompt: 'Feature expansion', + reasoning: 'Feature task reasoning' + } + ] + }; + + // Mock file system to return different reports for different paths + mockExistsSync.mockImplementation((filepath) => { + return filepath.includes('task-complexity-report'); + }); + + mockReadFileSync.mockImplementation((filepath) => { + if (filepath.includes('task-complexity-report_feature-auth.json')) { + return JSON.stringify(featureReport); + } else if (filepath.includes('task-complexity-report.json')) { + return JSON.stringify(masterReport); + } + return '{}'; + }); + + // Analyze master tag + const masterOptions = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'master' + }; + + await analyzeTaskComplexity(masterOptions, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Verify that master report was written to master location + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ), + expect.stringContaining('"taskTitle": "Test Task"'), + 'utf8' + ); + + // Clear mocks + jest.clearAllMocks(); + readJSON.mockReturnValue(sampleTasks); + + // Analyze feature tag + const featureOptions = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(featureOptions, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Verify that feature report was written to feature location + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ), + expect.stringContaining('"taskTitle": "Test Task"'), + 'utf8' + ); + }); + }); + + describe('Edge Cases', () => { + test('should handle empty tag gracefully', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: '' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: '', + projectRoot + }), + expect.any(Function) + ); + }); + + test('should handle null tag gracefully', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: null + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: null, + projectRoot + }), + expect.any(Function) + ); + }); + + test('should handle special characters in tag names', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature/user-auth-v2' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: 'feature/user-auth-v2', + projectRoot + }), + expect.any(Function) + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-user-auth-v2.json' + ), + expect.any(String), + 'utf8' + ); + }); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/expand-all-tasks.test.js b/tests/unit/scripts/modules/task-manager/expand-all-tasks.test.js index 1d858f05..e05b9355 100644 --- a/tests/unit/scripts/modules/task-manager/expand-all-tasks.test.js +++ b/tests/unit/scripts/modules/task-manager/expand-all-tasks.test.js @@ -198,7 +198,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -224,7 +225,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -267,7 +269,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -300,7 +303,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -326,7 +330,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ) @@ -347,7 +352,8 @@ describe('expandAllTasks', () => { false, { session: mockSession, - mcpLog: mockMcpLog + mcpLog: mockMcpLog, + tag: 'master' // No projectRoot provided, and findProjectRoot will return null }, 'json' @@ -384,7 +390,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -412,7 +419,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -441,7 +449,8 @@ describe('expandAllTasks', () => { '', false, { - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' // No mcpLog provided, should use CLI logger }, 'text' // CLI output format diff --git a/tests/unit/scripts/modules/task-manager/expand-task.test.js b/tests/unit/scripts/modules/task-manager/expand-task.test.js index e6521648..49fb11b0 100644 --- a/tests/unit/scripts/modules/task-manager/expand-task.test.js +++ b/tests/unit/scripts/modules/task-manager/expand-task.test.js @@ -700,7 +700,9 @@ describe('expandTask', () => { const context = { mcpLog: createMcpLogMock(), projectRoot: '/mock/project/root', - tag: 'feature-branch' + tag: 'feature-branch', + complexityReportPath: + '/mock/project/root/task-complexity-report_feature-branch.json' }; // Stub fs.existsSync to simulate complexity report exists for this tag diff --git a/tests/unit/scripts/modules/task-manager/generate-task-files.test.js b/tests/unit/scripts/modules/task-manager/generate-task-files.test.js index c3c64e49..d5e47b26 100644 --- a/tests/unit/scripts/modules/task-manager/generate-task-files.test.js +++ b/tests/unit/scripts/modules/task-manager/generate-task-files.test.js @@ -185,11 +185,12 @@ describe('generateTaskFiles', () => { const outputDir = 'tasks'; await generateTaskFiles(tasksPath, outputDir, { + tag: 'master', mcpLog: { info: jest.fn() } }); // Verify the data was read with new signature, defaulting to master - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Verify dependencies were validated with the raw tagged data expect(validateAndFixDependencies).toHaveBeenCalledWith( @@ -226,6 +227,7 @@ describe('generateTaskFiles', () => { // Call the function await generateTaskFiles('tasks/tasks.json', 'tasks', { + tag: 'master', mcpLog: { info: jest.fn() } }); @@ -271,6 +273,7 @@ describe('generateTaskFiles', () => { // Call the function await generateTaskFiles('tasks/tasks.json', 'tasks', { + tag: 'master', mcpLog: { info: jest.fn() } }); @@ -288,6 +291,7 @@ describe('generateTaskFiles', () => { // Call the function await generateTaskFiles('tasks/tasks.json', 'tasks', { + tag: 'master', mcpLog: { info: jest.fn() } }); diff --git a/tests/unit/scripts/modules/task-manager/list-tasks.test.js b/tests/unit/scripts/modules/task-manager/list-tasks.test.js index e05a14ce..483b8a1a 100644 --- a/tests/unit/scripts/modules/task-manager/list-tasks.test.js +++ b/tests/unit/scripts/modules/task-manager/list-tasks.test.js @@ -21,7 +21,8 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ tasks.find((t) => t.id === parseInt(id)) ), addComplexityToTask: jest.fn(), - readComplexityReport: jest.fn(() => null) + readComplexityReport: jest.fn(() => null), + getTagAwareFilePath: jest.fn((tag, path) => '/mock/tagged/report.json') })); jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ @@ -152,10 +153,12 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json'); + const result = listTasks(tasksPath, null, null, false, 'json', { + tag: 'master' + }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(result).toEqual( expect.objectContaining({ tasks: expect.arrayContaining([ @@ -175,10 +178,12 @@ describe('listTasks', () => { const statusFilter = 'pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Verify only pending tasks are returned expect(result.tasks).toHaveLength(1); @@ -192,7 +197,9 @@ describe('listTasks', () => { const statusFilter = 'done'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Verify only done tasks are returned @@ -206,7 +213,9 @@ describe('listTasks', () => { const statusFilter = 'review'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Verify only review tasks are returned @@ -220,7 +229,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, true, 'json'); + const result = listTasks(tasksPath, null, null, true, 'json', { + tag: 'master' + }); // Assert // Verify that the task with subtasks is included @@ -235,7 +246,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json'); + const result = listTasks(tasksPath, null, null, false, 'json', { + tag: 'master' + }); // Assert // For JSON output, subtasks should still be included in the data structure @@ -253,7 +266,9 @@ describe('listTasks', () => { const statusFilter = 'blocked'; // Status that doesn't exist in sample data // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Verify empty array is returned @@ -269,7 +284,7 @@ describe('listTasks', () => { // Act & Assert expect(() => { - listTasks(tasksPath, null, null, false, 'json'); + listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); }).toThrow('File not found'); }); @@ -278,10 +293,10 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - listTasks(tasksPath, null, null, false, 'json'); + listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Note: validateAndFixDependencies is not called by listTasks function // This test just verifies the function runs without error }); @@ -291,7 +306,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, 'pending', null, true, 'json'); + const result = listTasks(tasksPath, 'pending', null, true, 'json', { + tag: 'master' + }); // Assert // For JSON output, we don't call displayTaskList, so just verify the result structure @@ -310,7 +327,9 @@ describe('listTasks', () => { const statusFilter = 'in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert expect(result.tasks).toHaveLength(1); @@ -324,7 +343,9 @@ describe('listTasks', () => { const statusFilter = 'cancelled'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert expect(result.tasks).toHaveLength(1); @@ -337,7 +358,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json'); + const result = listTasks(tasksPath, null, null, false, 'json', { + tag: 'master' + }); // Assert expect(result).toEqual( @@ -363,10 +386,12 @@ describe('listTasks', () => { const statusFilter = 'done,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Should return tasks with 'done' or 'pending' status expect(result.tasks).toHaveLength(2); @@ -381,7 +406,9 @@ describe('listTasks', () => { const statusFilter = 'done,pending,in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return tasks with 'done', 'pending', or 'in-progress' status @@ -405,7 +432,9 @@ describe('listTasks', () => { const statusFilter = 'done, pending , in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should trim spaces and work correctly @@ -422,7 +451,9 @@ describe('listTasks', () => { const statusFilter = 'done,,pending,'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should ignore empty values and work with valid ones @@ -437,7 +468,9 @@ describe('listTasks', () => { const statusFilter = 'DONE,Pending,IN-PROGRESS'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should match case-insensitively @@ -454,7 +487,9 @@ describe('listTasks', () => { const statusFilter = 'blocked,deferred'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return empty array as no tasks have these statuses @@ -467,7 +502,9 @@ describe('listTasks', () => { const statusFilter = 'pending,'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should work the same as single status filter @@ -481,7 +518,9 @@ describe('listTasks', () => { const statusFilter = 'done,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return the original filter string @@ -494,7 +533,9 @@ describe('listTasks', () => { const statusFilter = 'all'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return all tasks when filter is 'all' @@ -508,7 +549,9 @@ describe('listTasks', () => { const statusFilter = 'done,nonexistent,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return only tasks with existing statuses @@ -523,7 +566,9 @@ describe('listTasks', () => { const statusFilter = 'review,cancelled'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return tasks with 'review' or 'cancelled' status diff --git a/tests/unit/scripts/modules/task-manager/move-task.test.js b/tests/unit/scripts/modules/task-manager/move-task.test.js new file mode 100644 index 00000000..344d19b2 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/move-task.test.js @@ -0,0 +1,94 @@ +import { jest } from '@jest/globals'; + +// --- Mocks --- +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + setTasksForTag: jest.fn(), + truncate: jest.fn((t) => t), + isSilentMode: jest.fn(() => false) +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn().mockResolvedValue() + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager.js', + () => ({ + isTaskDependentOn: jest.fn(() => false) + }) +); + +// fs not needed since move-task uses writeJSON + +const { readJSON, writeJSON, log } = await import( + '../../../../../scripts/modules/utils.js' +); +const generateTaskFiles = ( + await import( + '../../../../../scripts/modules/task-manager/generate-task-files.js' + ) +).default; + +const { default: moveTask } = await import( + '../../../../../scripts/modules/task-manager/move-task.js' +); + +const sampleTagged = () => ({ + master: { + tasks: [ + { id: 1, title: 'A' }, + { id: 2, title: 'B', subtasks: [{ id: 1, title: 'B.1' }] } + ], + metadata: {} + }, + feature: { + tasks: [{ id: 10, title: 'X' }], + metadata: {} + } +}); + +const clone = () => JSON.parse(JSON.stringify(sampleTagged())); + +describe('moveTask (unit)', () => { + beforeEach(() => { + jest.clearAllMocks(); + readJSON.mockImplementation((path, projectRoot, tag) => { + const data = clone(); + return { ...data[tag], tag, _rawTaggedData: data }; + }); + writeJSON.mockResolvedValue(); + log.mockImplementation(() => {}); + }); + + test('moves task to new ID in same tag', async () => { + await moveTask('tasks.json', '1', '3', false, { tag: 'master' }); + expect(writeJSON).toHaveBeenCalled(); + const written = writeJSON.mock.calls[0][1]; + const ids = written.master.tasks.map((t) => t.id); + expect(ids).toEqual(expect.arrayContaining([2, 3])); + expect(ids).not.toContain(1); + }); + + test('throws when counts of source and dest mismatch', async () => { + await expect( + moveTask('tasks.json', '1,2', '3', {}, { tag: 'master' }) + ).rejects.toThrow(/Number of source IDs/); + }); + + test('batch move calls generateTaskFiles once when flag true', async () => { + await moveTask('tasks.json', '1,2', '3,4', true, { tag: 'master' }); + expect(generateTaskFiles).toHaveBeenCalledTimes(1); + }); + + test('error when tag invalid', async () => { + await expect( + moveTask('tasks.json', '1', '2', false, { tag: 'ghost' }) + ).rejects.toThrow(/tag "ghost" not found/); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/parse-prd.test.js b/tests/unit/scripts/modules/task-manager/parse-prd.test.js index f322cad7..eb40bcc3 100644 --- a/tests/unit/scripts/modules/task-manager/parse-prd.test.js +++ b/tests/unit/scripts/modules/task-manager/parse-prd.test.js @@ -233,7 +233,9 @@ describe('parsePRD', () => { }); // Call the function - const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master' + }); // Verify fs.readFileSync was called with the correct arguments expect(fs.default.readFileSync).toHaveBeenCalledWith( @@ -276,7 +278,7 @@ describe('parsePRD', () => { }); // Call the function - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }); // Verify mkdir was called expect(fs.default.mkdirSync).toHaveBeenCalledWith('tasks', { @@ -299,6 +301,7 @@ describe('parsePRD', () => { // Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit) await expect( parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master', mcpLog: { info: jest.fn(), warn: jest.fn(), @@ -319,7 +322,7 @@ describe('parsePRD', () => { }); // Call the function - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }); }); test('should overwrite tasks.json when force flag is true', async () => { @@ -331,7 +334,10 @@ describe('parsePRD', () => { }); // Call the function with force=true to allow overwrite - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { force: true }); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + force: true, + tag: 'master' + }); // Verify prompt was NOT called (confirmation happens at CLI level, not in core function) expect(promptYesNo).not.toHaveBeenCalled(); @@ -354,6 +360,7 @@ describe('parsePRD', () => { // Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit) await expect( parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master', mcpLog: { info: jest.fn(), warn: jest.fn(), @@ -383,7 +390,7 @@ describe('parsePRD', () => { // Call the function without mcpLog (CLI mode) and expect it to throw an error // In test environment, process.exit is prevented and error is thrown instead await expect( - parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3) + parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }) ).rejects.toThrow( "Tag 'master' already contains 2 tasks. Use --force to overwrite or --append to add to existing tasks." ); @@ -411,6 +418,7 @@ describe('parsePRD', () => { // Call the function with append option const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 2, { + tag: 'master', append: true }); @@ -445,6 +453,7 @@ describe('parsePRD', () => { // Call the function with append option await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master', append: true }); @@ -462,7 +471,9 @@ describe('parsePRD', () => { }); // Call the function with numTasks=0 for dynamic generation - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, { + tag: 'master' + }); // Verify generateObjectService was called expect(generateObjectService).toHaveBeenCalled(); @@ -482,7 +493,9 @@ describe('parsePRD', () => { }); // Call the function with specific numTasks - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 5); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 5, { + tag: 'master' + }); // Verify generateObjectService was called expect(generateObjectService).toHaveBeenCalled(); @@ -502,7 +515,9 @@ describe('parsePRD', () => { }); // Call the function with numTasks=0 - should not throw error - const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0); + const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, { + tag: 'master' + }); // Verify it completed successfully expect(result).toEqual({ @@ -522,7 +537,9 @@ describe('parsePRD', () => { // Call the function with negative numTasks // Note: The main parse-prd.js module doesn't validate numTasks - validation happens at CLI/MCP level - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', -5); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', -5, { + tag: 'master' + }); // Verify generateObjectService was called expect(generateObjectService).toHaveBeenCalled(); @@ -543,7 +560,9 @@ describe('parsePRD', () => { }); // Call the function with null numTasks - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', null); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', null, { + tag: 'master' + }); // Verify generateObjectService was called with dynamic prompting expect(generateObjectService).toHaveBeenCalled(); @@ -560,7 +579,9 @@ describe('parsePRD', () => { }); // Call the function with invalid numTasks (string that's not a number) - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 'invalid'); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 'invalid', { + tag: 'master' + }); // Verify generateObjectService was called with dynamic prompting // Note: The main module doesn't validate - it just uses the value as-is diff --git a/tests/unit/scripts/modules/task-manager/remove-subtask.test.js b/tests/unit/scripts/modules/task-manager/remove-subtask.test.js index f82c9553..8ff6c382 100644 --- a/tests/unit/scripts/modules/task-manager/remove-subtask.test.js +++ b/tests/unit/scripts/modules/task-manager/remove-subtask.test.js @@ -19,10 +19,12 @@ const testRemoveSubtask = ( tasksPath, subtaskId, convertToTask = false, - generateFiles = true + generateFiles = true, + context = { tag: 'master' } ) => { + const { projectRoot = undefined, tag = 'master' } = context; // Read the existing tasks - const data = mockReadJSON(tasksPath); + const data = mockReadJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error(`Invalid or missing tasks file at ${tasksPath}`); } @@ -95,7 +97,7 @@ const testRemoveSubtask = ( } // Write the updated tasks back to the file - mockWriteJSON(tasksPath, data); + mockWriteJSON(tasksPath, data, projectRoot, tag); // Generate task files if requested if (generateFiles) { @@ -111,55 +113,66 @@ describe('removeSubtask function', () => { jest.clearAllMocks(); // Default mock implementations - mockReadJSON.mockImplementation(() => ({ - tasks: [ - { - id: 1, - title: 'Parent Task', - description: 'This is a parent task', - status: 'pending', - dependencies: [], - subtasks: [ - { - id: 1, - title: 'Subtask 1', - description: 'This is subtask 1', - status: 'pending', - dependencies: [], - parentTaskId: 1 - }, - { - id: 2, - title: 'Subtask 2', - description: 'This is subtask 2', - status: 'in-progress', - dependencies: [1], // Depends on subtask 1 - parentTaskId: 1 - } - ] - }, - { - id: 2, - title: 'Another Task', - description: 'This is another task', - status: 'pending', - dependencies: [1] - } - ] - })); + mockReadJSON.mockImplementation((p, root, tag) => { + expect(tag).toBeDefined(); + expect(tag).toBe('master'); + return { + tasks: [ + { + id: 1, + title: 'Parent Task', + description: 'This is a parent task', + status: 'pending', + dependencies: [], + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'This is subtask 1', + status: 'pending', + dependencies: [], + parentTaskId: 1 + }, + { + id: 2, + title: 'Subtask 2', + description: 'This is subtask 2', + status: 'in-progress', + dependencies: [1], // Depends on subtask 1 + parentTaskId: 1 + } + ] + }, + { + id: 2, + title: 'Another Task', + description: 'This is another task', + status: 'pending', + dependencies: [1] + } + ] + }; + }); // Setup success write response - mockWriteJSON.mockImplementation((path, data) => { + mockWriteJSON.mockImplementation((path, data, root, tag) => { + expect(tag).toBe('master'); return data; }); }); test('should remove a subtask from its parent task', async () => { // Execute the test version of removeSubtask to remove subtask 1.1 - testRemoveSubtask('tasks/tasks.json', '1.1', false, true); + testRemoveSubtask('tasks/tasks.json', '1.1', false, true, { + tag: 'master' + }); // Verify readJSON was called with the correct path - expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json'); + expect(mockReadJSON).toHaveBeenCalledWith( + 'tasks/tasks.json', + undefined, + 'master' + ); // Verify writeJSON was called with updated data expect(mockWriteJSON).toHaveBeenCalled(); @@ -170,7 +183,9 @@ describe('removeSubtask function', () => { test('should convert a subtask to a standalone task', async () => { // Execute the test version of removeSubtask to convert subtask 1.1 to a standalone task - const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true); + const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true, { + tag: 'master' + }); // Verify the result is the new task expect(result).toBeDefined(); @@ -187,9 +202,9 @@ describe('removeSubtask function', () => { test('should throw an error if subtask ID format is invalid', async () => { // Expect an error for invalid subtask ID format - expect(() => testRemoveSubtask('tasks/tasks.json', '1', false)).toThrow( - /Invalid subtask ID format/ - ); + expect(() => + testRemoveSubtask('tasks/tasks.json', '1', false, true, { tag: 'master' }) + ).toThrow(/Invalid subtask ID format/); // Verify writeJSON was not called expect(mockWriteJSON).not.toHaveBeenCalled(); @@ -197,9 +212,11 @@ describe('removeSubtask function', () => { test('should throw an error if parent task does not exist', async () => { // Expect an error for non-existent parent task - expect(() => testRemoveSubtask('tasks/tasks.json', '999.1', false)).toThrow( - /Parent task with ID 999 not found/ - ); + expect(() => + testRemoveSubtask('tasks/tasks.json', '999.1', false, true, { + tag: 'master' + }) + ).toThrow(/Parent task with ID 999 not found/); // Verify writeJSON was not called expect(mockWriteJSON).not.toHaveBeenCalled(); @@ -207,9 +224,11 @@ describe('removeSubtask function', () => { test('should throw an error if subtask does not exist', async () => { // Expect an error for non-existent subtask - expect(() => testRemoveSubtask('tasks/tasks.json', '1.999', false)).toThrow( - /Subtask 1.999 not found/ - ); + expect(() => + testRemoveSubtask('tasks/tasks.json', '1.999', false, true, { + tag: 'master' + }) + ).toThrow(/Subtask 1.999 not found/); // Verify writeJSON was not called expect(mockWriteJSON).not.toHaveBeenCalled(); @@ -217,45 +236,51 @@ describe('removeSubtask function', () => { test('should remove subtasks array if last subtask is removed', async () => { // Create a data object with just one subtask - mockReadJSON.mockImplementationOnce(() => ({ - tasks: [ - { - id: 1, - title: 'Parent Task', - description: 'This is a parent task', - status: 'pending', - dependencies: [], - subtasks: [ - { - id: 1, - title: 'Last Subtask', - description: 'This is the last subtask', - status: 'pending', - dependencies: [], - parentTaskId: 1 - } - ] - }, - { - id: 2, - title: 'Another Task', - description: 'This is another task', - status: 'pending', - dependencies: [1] - } - ] - })); + mockReadJSON.mockImplementationOnce((p, root, tag) => { + expect(tag).toBe('master'); + return { + tasks: [ + { + id: 1, + title: 'Parent Task', + description: 'This is a parent task', + status: 'pending', + dependencies: [], + subtasks: [ + { + id: 1, + title: 'Last Subtask', + description: 'This is the last subtask', + status: 'pending', + dependencies: [], + parentTaskId: 1 + } + ] + }, + { + id: 2, + title: 'Another Task', + description: 'This is another task', + status: 'pending', + dependencies: [1] + } + ] + }; + }); // Mock the behavior of writeJSON to capture the updated tasks data const updatedTasksData = { tasks: [] }; - mockWriteJSON.mockImplementation((path, data) => { + mockWriteJSON.mockImplementation((path, data, root, tag) => { + expect(tag).toBe('master'); // Store the data for assertions updatedTasksData.tasks = [...data.tasks]; return data; }); // Remove the last subtask - testRemoveSubtask('tasks/tasks.json', '1.1', false, true); + testRemoveSubtask('tasks/tasks.json', '1.1', false, true, { + tag: 'master' + }); // Verify writeJSON was called expect(mockWriteJSON).toHaveBeenCalled(); @@ -271,7 +296,9 @@ describe('removeSubtask function', () => { test('should not regenerate task files if generateFiles is false', async () => { // Execute the test version of removeSubtask with generateFiles = false - testRemoveSubtask('tasks/tasks.json', '1.1', false, false); + testRemoveSubtask('tasks/tasks.json', '1.1', false, false, { + tag: 'master' + }); // Verify writeJSON was called expect(mockWriteJSON).toHaveBeenCalled(); diff --git a/tests/unit/scripts/modules/task-manager/remove-task.test.js b/tests/unit/scripts/modules/task-manager/remove-task.test.js new file mode 100644 index 00000000..3b716195 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/remove-task.test.js @@ -0,0 +1,134 @@ +import { jest } from '@jest/globals'; + +// --- Mock dependencies BEFORE module import --- +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + CONFIG: { + model: 'mock-model', + maxTokens: 4000, + temperature: 0.7, + debug: false + }, + findTaskById: jest.fn(), + truncate: jest.fn((t) => t), + isSilentMode: jest.fn(() => false) +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn().mockResolvedValue() + }) +); + +// fs is used for file deletion side-effects – stub the methods we touch +jest.unstable_mockModule('fs', () => ({ + existsSync: jest.fn(() => true), + unlinkSync: jest.fn() +})); + +// path is fine to keep as real since only join/dirname used – no side effects + +// Import mocked modules +const { readJSON, writeJSON, log } = await import( + '../../../../../scripts/modules/utils.js' +); +const generateTaskFiles = ( + await import( + '../../../../../scripts/modules/task-manager/generate-task-files.js' + ) +).default; +const fs = await import('fs'); + +// Import module under test (AFTER mocks in place) +const { default: removeTask } = await import( + '../../../../../scripts/modules/task-manager/remove-task.js' +); + +// ---- Test data helpers ---- +const buildSampleTaggedTasks = () => ({ + master: { + tasks: [ + { id: 1, title: 'Task 1', status: 'pending', dependencies: [] }, + { id: 2, title: 'Task 2', status: 'pending', dependencies: [1] }, + { + id: 3, + title: 'Parent', + status: 'pending', + dependencies: [], + subtasks: [ + { id: 1, title: 'Sub 3.1', status: 'pending', dependencies: [] } + ] + } + ] + }, + other: { + tasks: [{ id: 99, title: 'Shadow', status: 'pending', dependencies: [1] }] + } +}); + +// Utility to deep clone sample each test +const getFreshData = () => JSON.parse(JSON.stringify(buildSampleTaggedTasks())); + +// ----- Tests ----- + +describe('removeTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + // readJSON returns deep copy so each test isolated + readJSON.mockImplementation(() => { + return { + ...getFreshData().master, + tag: 'master', + _rawTaggedData: getFreshData() + }; + }); + writeJSON.mockResolvedValue(); + log.mockImplementation(() => {}); + fs.unlinkSync.mockImplementation(() => {}); + }); + + test('removes a main task and cleans dependencies across tags', async () => { + const result = await removeTask('tasks/tasks.json', '1', { tag: 'master' }); + + // Expect success true + expect(result.success).toBe(true); + // writeJSON called with data where task 1 is gone in master & dependencies removed in other tags + const written = writeJSON.mock.calls[0][1]; + expect(written.master.tasks.find((t) => t.id === 1)).toBeUndefined(); + // deps removed from child tasks + const task2 = written.master.tasks.find((t) => t.id === 2); + expect(task2.dependencies).not.toContain(1); + const shadow = written.other.tasks.find((t) => t.id === 99); + expect(shadow.dependencies).not.toContain(1); + // Task file deletion attempted + expect(fs.unlinkSync).toHaveBeenCalled(); + }); + + test('removes a subtask only and leaves parent intact', async () => { + const result = await removeTask('tasks/tasks.json', '3.1', { + tag: 'master' + }); + + expect(result.success).toBe(true); + const written = writeJSON.mock.calls[0][1]; + const parent = written.master.tasks.find((t) => t.id === 3); + expect(parent.subtasks || []).toHaveLength(0); + // Ensure parent still exists + expect(parent).toBeDefined(); + // No task files should be deleted for subtasks + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + + test('handles non-existent task gracefully', async () => { + const result = await removeTask('tasks/tasks.json', '42', { + tag: 'master' + }); + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + // writeJSON not called because nothing changed + expect(writeJSON).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/research.test.js b/tests/unit/scripts/modules/task-manager/research.test.js new file mode 100644 index 00000000..28899bf3 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/research.test.js @@ -0,0 +1,663 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + findProjectRoot: jest.fn(() => '/test/project/root'), + log: jest.fn(), + readJSON: jest.fn(), + flattenTasksWithSubtasks: jest.fn(() => []), + isEmpty: jest.fn(() => false) +})); + +// Mock UI-affecting external libs to minimal no-op implementations +jest.unstable_mockModule('chalk', () => ({ + default: { + white: Object.assign( + jest.fn((text) => text), + { + bold: jest.fn((text) => text) + } + ), + cyan: Object.assign( + jest.fn((text) => text), + { + bold: jest.fn((text) => text) + } + ), + green: Object.assign( + jest.fn((text) => text), + { + bold: jest.fn((text) => text) + } + ), + yellow: jest.fn((text) => text), + red: jest.fn((text) => text), + gray: jest.fn((text) => text), + blue: Object.assign( + jest.fn((text) => text), + { + bold: jest.fn((text) => text) + } + ), + bold: jest.fn((text) => text) + } +})); + +jest.unstable_mockModule('boxen', () => ({ default: (text) => text })); + +jest.unstable_mockModule('inquirer', () => ({ + default: { prompt: jest.fn() } +})); + +jest.unstable_mockModule('cli-highlight', () => ({ + highlight: (code) => code +})); + +jest.unstable_mockModule('cli-table3', () => ({ + default: jest.fn().mockImplementation(() => ({ + push: jest.fn(), + toString: jest.fn(() => '') + })) +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/contextGatherer.js', + () => ({ + ContextGatherer: jest.fn().mockImplementation(() => ({ + gather: jest.fn().mockResolvedValue({ + context: 'Gathered context', + tokenBreakdown: { total: 500 } + }), + countTokens: jest.fn(() => 100) + })) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/fuzzyTaskSearch.js', + () => ({ + FuzzyTaskSearch: jest.fn().mockImplementation(() => ({ + findRelevantTasks: jest.fn(() => []), + getTaskIds: jest.fn(() => []) + })) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/ai-services-unified.js', + () => ({ + generateTextService: jest.fn().mockResolvedValue({ + mainResult: + 'Test research result with ```javascript\nconsole.log("test");\n```', + telemetryData: {} + }) + }) +); + +jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + displayAiUsageSummary: jest.fn(), + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), + stopLoadingIndicator: jest.fn() +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/prompt-manager.js', + () => ({ + getPromptManager: jest.fn().mockReturnValue({ + loadPrompt: jest.fn().mockResolvedValue({ + systemPrompt: 'System prompt', + userPrompt: 'User prompt' + }) + }) + }) +); + +const { performResearch } = await import( + '../../../../../scripts/modules/task-manager/research.js' +); + +// Import mocked modules for testing +const utils = await import('../../../../../scripts/modules/utils.js'); +const { ContextGatherer } = await import( + '../../../../../scripts/modules/utils/contextGatherer.js' +); +const { FuzzyTaskSearch } = await import( + '../../../../../scripts/modules/utils/fuzzyTaskSearch.js' +); +const { generateTextService } = await import( + '../../../../../scripts/modules/ai-services-unified.js' +); + +describe('performResearch project root validation', () => { + it('throws error when project root cannot be determined', async () => { + // Mock findProjectRoot to return null + utils.findProjectRoot.mockReturnValueOnce(null); + + await expect( + performResearch('Test query', {}, {}, 'json', false) + ).rejects.toThrow('Could not determine project root directory'); + }); +}); + +describe('performResearch tag-aware functionality', () => { + let mockContextGatherer; + let mockFuzzySearch; + let mockReadJSON; + let mockFlattenTasks; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Set up default mocks + utils.findProjectRoot.mockReturnValue('/test/project/root'); + utils.readJSON.mockResolvedValue({ + tasks: [ + { id: 1, title: 'Task 1', description: 'Description 1' }, + { id: 2, title: 'Task 2', description: 'Description 2' } + ] + }); + utils.flattenTasksWithSubtasks.mockReturnValue([ + { id: 1, title: 'Task 1', description: 'Description 1' }, + { id: 2, title: 'Task 2', description: 'Description 2' } + ]); + + // Set up ContextGatherer mock + mockContextGatherer = { + gather: jest.fn().mockResolvedValue({ + context: 'Gathered context', + tokenBreakdown: { total: 500 } + }), + countTokens: jest.fn(() => 100) + }; + ContextGatherer.mockImplementation(() => mockContextGatherer); + + // Set up FuzzyTaskSearch mock + mockFuzzySearch = { + findRelevantTasks: jest.fn(() => [ + { id: 1, title: 'Task 1', description: 'Description 1' } + ]), + getTaskIds: jest.fn(() => ['1']) + }; + FuzzyTaskSearch.mockImplementation(() => mockFuzzySearch); + + // Store references for easier access + mockReadJSON = utils.readJSON; + mockFlattenTasks = utils.flattenTasksWithSubtasks; + }); + + describe('tag parameter passing to ContextGatherer', () => { + it('passes tag parameter to ContextGatherer constructor', async () => { + const testTag = 'feature-branch'; + + await performResearch('Test query', { tag: testTag }, {}, 'json', false); + + expect(ContextGatherer).toHaveBeenCalledWith( + '/test/project/root', + testTag + ); + }); + + it('passes undefined tag when no tag is provided', async () => { + await performResearch('Test query', {}, {}, 'json', false); + + expect(ContextGatherer).toHaveBeenCalledWith( + '/test/project/root', + undefined + ); + }); + + it('passes empty string tag when empty string is provided', async () => { + await performResearch('Test query', { tag: '' }, {}, 'json', false); + + expect(ContextGatherer).toHaveBeenCalledWith('/test/project/root', ''); + }); + + it('passes null tag when null is provided', async () => { + await performResearch('Test query', { tag: null }, {}, 'json', false); + + expect(ContextGatherer).toHaveBeenCalledWith('/test/project/root', null); + }); + }); + + describe('tag-aware readJSON calls', () => { + it('calls readJSON with correct tag parameter for task discovery', async () => { + const testTag = 'development'; + + await performResearch('Test query', { tag: testTag }, {}, 'json', false); + + expect(mockReadJSON).toHaveBeenCalledWith( + expect.stringContaining('tasks.json'), + '/test/project/root', + testTag + ); + }); + + it('calls readJSON with undefined tag when no tag provided', async () => { + await performResearch('Test query', {}, {}, 'json', false); + + expect(mockReadJSON).toHaveBeenCalledWith( + expect.stringContaining('tasks.json'), + '/test/project/root', + undefined + ); + }); + + it('calls readJSON with provided projectRoot and tag', async () => { + const customProjectRoot = '/custom/project/root'; + const testTag = 'production'; + + await performResearch( + 'Test query', + { + projectRoot: customProjectRoot, + tag: testTag + }, + {}, + 'json', + false + ); + + expect(mockReadJSON).toHaveBeenCalledWith( + expect.stringContaining('tasks.json'), + customProjectRoot, + testTag + ); + }); + }); + + describe('context gathering behavior for different tags', () => { + it('calls contextGatherer.gather with correct parameters', async () => { + const options = { + taskIds: ['1', '2'], + filePaths: ['src/file.js'], + customContext: 'Custom context', + includeProjectTree: true, + tag: 'feature-branch' + }; + + await performResearch('Test query', options, {}, 'json', false); + + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: expect.arrayContaining(['1', '2']), + files: ['src/file.js'], + customContext: 'Custom context', + includeProjectTree: true, + format: 'research', + includeTokenCounts: true + }); + }); + + it('handles empty task discovery gracefully when readJSON fails', async () => { + mockReadJSON.mockRejectedValueOnce(new Error('File not found')); + + const result = await performResearch( + 'Test query', + { tag: 'test-tag' }, + {}, + 'json', + false + ); + + // Should still succeed even if task discovery fails + expect(result).toBeDefined(); + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: [], + files: [], + customContext: '', + includeProjectTree: false, + format: 'research', + includeTokenCounts: true + }); + }); + + it('combines provided taskIds with auto-discovered tasks', async () => { + const providedTaskIds = ['3', '4']; + const autoDiscoveredIds = ['1', '2']; + + mockFuzzySearch.getTaskIds.mockReturnValue(autoDiscoveredIds); + + await performResearch( + 'Test query', + { + taskIds: providedTaskIds, + tag: 'feature-branch' + }, + {}, + 'json', + false + ); + + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: expect.arrayContaining([ + ...providedTaskIds, + ...autoDiscoveredIds + ]), + files: [], + customContext: '', + includeProjectTree: false, + format: 'research', + includeTokenCounts: true + }); + }); + + it('removes duplicate tasks when auto-discovered tasks overlap with provided tasks', async () => { + const providedTaskIds = ['1', '2']; + const autoDiscoveredIds = ['2', '3']; // '2' is duplicate + + mockFuzzySearch.getTaskIds.mockReturnValue(autoDiscoveredIds); + + await performResearch( + 'Test query', + { + taskIds: providedTaskIds, + tag: 'feature-branch' + }, + {}, + 'json', + false + ); + + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: ['1', '2', '3'], // Should include '3' but not duplicate '2' + files: [], + customContext: '', + includeProjectTree: false, + format: 'research', + includeTokenCounts: true + }); + }); + }); + + describe('tag-aware fuzzy search', () => { + it('initializes FuzzyTaskSearch with flattened tasks from correct tag', async () => { + const testTag = 'development'; + const mockFlattenedTasks = [ + { id: 1, title: 'Dev Task 1' }, + { id: 2, title: 'Dev Task 2' } + ]; + + mockFlattenTasks.mockReturnValue(mockFlattenedTasks); + + await performResearch('Test query', { tag: testTag }, {}, 'json', false); + + expect(mockFlattenTasks).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }) + ]) + ); + expect(FuzzyTaskSearch).toHaveBeenCalledWith( + mockFlattenedTasks, + 'research' + ); + }); + + it('calls fuzzy search with correct parameters', async () => { + const testQuery = 'authentication implementation'; + + await performResearch( + testQuery, + { tag: 'feature-branch' }, + {}, + 'json', + false + ); + + expect(mockFuzzySearch.findRelevantTasks).toHaveBeenCalledWith( + testQuery, + { + maxResults: 8, + includeRecent: true, + includeCategoryMatches: true + } + ); + }); + + it('handles empty tasks data gracefully', async () => { + mockReadJSON.mockResolvedValueOnce({ tasks: [] }); + + await performResearch( + 'Test query', + { tag: 'empty-tag' }, + {}, + 'json', + false + ); + + // Should not call FuzzyTaskSearch when no tasks exist + expect(FuzzyTaskSearch).not.toHaveBeenCalled(); + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: [], + files: [], + customContext: '', + includeProjectTree: false, + format: 'research', + includeTokenCounts: true + }); + }); + + it('handles null tasks data gracefully', async () => { + mockReadJSON.mockResolvedValueOnce(null); + + await performResearch( + 'Test query', + { tag: 'null-tag' }, + {}, + 'json', + false + ); + + // Should not call FuzzyTaskSearch when data is null + expect(FuzzyTaskSearch).not.toHaveBeenCalled(); + }); + }); + + describe('error handling for invalid tags', () => { + it('continues execution when readJSON throws error for invalid tag', async () => { + mockReadJSON.mockRejectedValueOnce(new Error('Tag not found')); + + const result = await performResearch( + 'Test query', + { tag: 'invalid-tag' }, + {}, + 'json', + false + ); + + // Should still succeed and return a result + expect(result).toBeDefined(); + expect(mockContextGatherer.gather).toHaveBeenCalled(); + }); + + it('logs debug message when task discovery fails', async () => { + const mockLog = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() + }; + + mockReadJSON.mockRejectedValueOnce(new Error('File not found')); + + await performResearch( + 'Test query', + { tag: 'error-tag' }, + { mcpLog: mockLog }, + 'json', + false + ); + + expect(mockLog.debug).toHaveBeenCalledWith( + expect.stringContaining('Could not auto-discover tasks') + ); + }); + + it('handles ContextGatherer constructor errors gracefully', async () => { + ContextGatherer.mockImplementationOnce(() => { + throw new Error('Invalid tag provided'); + }); + + await expect( + performResearch('Test query', { tag: 'invalid-tag' }, {}, 'json', false) + ).rejects.toThrow('Invalid tag provided'); + }); + + it('handles ContextGatherer.gather errors gracefully', async () => { + mockContextGatherer.gather.mockRejectedValueOnce( + new Error('Gather failed') + ); + + await expect( + performResearch( + 'Test query', + { tag: 'gather-error-tag' }, + {}, + 'json', + false + ) + ).rejects.toThrow('Gather failed'); + }); + }); + + describe('MCP integration with tags', () => { + it('uses MCP logger when mcpLog is provided in context', async () => { + const mockMCPLog = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() + }; + + mockReadJSON.mockRejectedValueOnce(new Error('Test error')); + + await performResearch( + 'Test query', + { tag: 'mcp-tag' }, + { mcpLog: mockMCPLog }, + 'json', + false + ); + + expect(mockMCPLog.debug).toHaveBeenCalledWith( + expect.stringContaining('Could not auto-discover tasks') + ); + }); + + it('passes session to generateTextService when provided', async () => { + const mockSession = { userId: 'test-user', env: {} }; + + await performResearch( + 'Test query', + { tag: 'session-tag' }, + { session: mockSession }, + 'json', + false + ); + + expect(generateTextService).toHaveBeenCalledWith( + expect.objectContaining({ + session: mockSession + }) + ); + }); + }); + + describe('output format handling with tags', () => { + it('displays UI banner only in text format', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await performResearch('Test query', { tag: 'ui-tag' }, {}, 'text', false); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('🔍 AI Research Query') + ); + + consoleSpy.mockRestore(); + }); + + it('does not display UI banner in json format', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await performResearch('Test query', { tag: 'ui-tag' }, {}, 'json', false); + + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('🔍 AI Research Query') + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('comprehensive tag integration test', () => { + it('performs complete research flow with tag-aware functionality', async () => { + const testOptions = { + taskIds: ['1', '2'], + filePaths: ['src/main.js'], + customContext: 'Testing tag integration', + includeProjectTree: true, + detailLevel: 'high', + tag: 'integration-test', + projectRoot: '/custom/root' + }; + + const testContext = { + session: { userId: 'test-user' }, + mcpLog: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() + }, + commandName: 'test-research', + outputType: 'mcp' + }; + + // Mock successful task discovery + mockFuzzySearch.getTaskIds.mockReturnValue(['3', '4']); + + const result = await performResearch( + 'Integration test query', + testOptions, + testContext, + 'json', + false + ); + + // Verify ContextGatherer was initialized with correct tag + expect(ContextGatherer).toHaveBeenCalledWith( + '/custom/root', + 'integration-test' + ); + + // Verify readJSON was called with correct parameters + expect(mockReadJSON).toHaveBeenCalledWith( + expect.stringContaining('tasks.json'), + '/custom/root', + 'integration-test' + ); + + // Verify context gathering was called with combined tasks + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: ['1', '2', '3', '4'], + files: ['src/main.js'], + customContext: 'Testing tag integration', + includeProjectTree: true, + format: 'research', + includeTokenCounts: true + }); + + // Verify AI service was called with session + expect(generateTextService).toHaveBeenCalledWith( + expect.objectContaining({ + session: testContext.session, + role: 'research' + }) + ); + + expect(result).toBeDefined(); + }); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/set-task-status.test.js b/tests/unit/scripts/modules/task-manager/set-task-status.test.js index 6e252b00..72e75b95 100644 --- a/tests/unit/scripts/modules/task-manager/set-task-status.test.js +++ b/tests/unit/scripts/modules/task-manager/set-task-status.test.js @@ -234,11 +234,12 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '2', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -271,11 +272,12 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '3.1', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -308,11 +310,12 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '1,2', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -341,6 +344,7 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '3', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); @@ -379,7 +383,10 @@ describe('setTaskStatus', () => { // Act & Assert await expect( - setTaskStatus(tasksPath, '99', 'done', { mcpLog: { info: jest.fn() } }) + setTaskStatus(tasksPath, '99', 'done', { + tag: 'master', + mcpLog: { info: jest.fn() } + }) ).rejects.toThrow('Task 99 not found'); }); @@ -418,7 +425,10 @@ describe('setTaskStatus', () => { // Act & Assert await expect( - setTaskStatus(tasksPath, '3.1', 'done', { mcpLog: { info: jest.fn() } }) + setTaskStatus(tasksPath, '3.1', 'done', { + tag: 'master', + mcpLog: { info: jest.fn() } + }) ).rejects.toThrow('has no subtasks'); }); @@ -435,7 +445,10 @@ describe('setTaskStatus', () => { // Act & Assert await expect( - setTaskStatus(tasksPath, '3.99', 'done', { mcpLog: { info: jest.fn() } }) + setTaskStatus(tasksPath, '3.99', 'done', { + tag: 'master', + mcpLog: { info: jest.fn() } + }) ).rejects.toThrow('Subtask 99 not found'); }); @@ -492,6 +505,7 @@ describe('setTaskStatus', () => { // Act const result = await setTaskStatus(tasksPath, taskIds, newStatus, { + tag: 'master', mcpLog: { info: jest.fn() } }); @@ -555,6 +569,7 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '1', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); diff --git a/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js b/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js new file mode 100644 index 00000000..89027ed4 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js @@ -0,0 +1,201 @@ +import { jest } from '@jest/globals'; + +// Provide fs mock early so existsSync can be stubbed +jest.unstable_mockModule('fs', () => { + const mockFs = { + existsSync: jest.fn(() => true), + writeFileSync: jest.fn(), + readFileSync: jest.fn(), + unlinkSync: jest.fn() + }; + return { default: mockFs, ...mockFs }; +}); + +// --- Mock dependencies --- +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + isSilentMode: jest.fn(() => false), + findProjectRoot: jest.fn(() => '/project'), + flattenTasksWithSubtasks: jest.fn(() => []), + truncate: jest.fn((t) => t), + isEmpty: jest.fn(() => false), + resolveEnvVariable: jest.fn(), + findTaskById: jest.fn(), + getCurrentTag: jest.fn(() => 'master') +})); + +jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + getStatusWithColor: jest.fn((s) => s), + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), + stopLoadingIndicator: jest.fn(), + displayAiUsageSummary: jest.fn() +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn().mockResolvedValue() + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/ai-services-unified.js', + () => ({ + generateTextService: jest + .fn() + .mockResolvedValue({ mainResult: { content: '' }, telemetryData: {} }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/config-manager.js', + () => ({ + getDebugFlag: jest.fn(() => false) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/prompt-manager.js', + () => ({ + default: jest.fn().mockReturnValue({ + loadPrompt: jest.fn().mockReturnValue('Update the subtask') + }), + getPromptManager: jest.fn().mockReturnValue({ + loadPrompt: jest.fn().mockReturnValue('Update the subtask') + }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/contextGatherer.js', + () => ({ + ContextGatherer: jest.fn().mockImplementation(() => ({ + gather: jest.fn().mockReturnValue({ + fullContext: '', + summary: '' + }) + })) + }) +); + +// Import mocked utils to leverage mocks later +const { readJSON, log } = await import( + '../../../../../scripts/modules/utils.js' +); + +// Import function under test +const { default: updateSubtaskById } = await import( + '../../../../../scripts/modules/task-manager/update-subtask-by-id.js' +); + +describe('updateSubtaskById validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + test('throws error on invalid subtask id format', async () => { + await expect( + updateSubtaskById( + 'tasks/tasks.json', + 'invalid', + 'my prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Invalid subtask ID format'); + }); + + test('throws error when prompt is empty', async () => { + await expect( + updateSubtaskById( + 'tasks/tasks.json', + '1.1', + '', + false, + { tag: 'master' }, + 'json' + ) + ).rejects.toThrow('Prompt cannot be empty'); + }); + + test('throws error if tasks file does not exist', async () => { + // Mock fs.existsSync to return false via jest.spyOn (dynamic import of fs) + const fs = await import('fs'); + fs.existsSync.mockReturnValue(false); + await expect( + updateSubtaskById( + 'tasks/tasks.json', + '1.1', + 'prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Tasks file not found'); + }); + + test('throws error if parent task missing', async () => { + // Mock existsSync true + const fs = await import('fs'); + fs.existsSync.mockReturnValue(true); + // readJSON returns tasks without parent id 1 + readJSON.mockReturnValue({ tag: 'master', tasks: [] }); + await expect( + updateSubtaskById( + 'tasks/tasks.json', + '1.1', + 'prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Parent task with ID 1 not found'); + // log called with error level + expect(log).toHaveBeenCalled(); + }); + + test('successfully updates subtask with valid inputs', async () => { + const fs = await import('fs'); + const { writeJSON } = await import( + '../../../../../scripts/modules/utils.js' + ); + + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ + tag: 'master', + tasks: [ + { + id: 1, + title: 'Parent Task', + subtasks: [{ id: 1, title: 'Original subtask', status: 'pending' }] + } + ] + }); + + // updateSubtaskById doesn't return a value on success, it just executes + await expect( + updateSubtaskById( + 'tasks/tasks.json', + '1.1', + 'Update this subtask', + false, + { tag: 'master' }, + 'json' + ) + ).resolves.not.toThrow(); + + expect(writeJSON).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js b/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js new file mode 100644 index 00000000..bc3842c1 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js @@ -0,0 +1,121 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('fs', () => { + const mockFs = { + existsSync: jest.fn(() => true), + writeFileSync: jest.fn(), + readFileSync: jest.fn(), + unlinkSync: jest.fn() + }; + return { default: mockFs, ...mockFs }; +}); + +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + isSilentMode: jest.fn(() => false), + findProjectRoot: jest.fn(() => '/project'), + flattenTasksWithSubtasks: jest.fn(() => []), + truncate: jest.fn((t) => t), + isEmpty: jest.fn(() => false), + resolveEnvVariable: jest.fn(), + findTaskById: jest.fn(), + getCurrentTag: jest.fn(() => 'master') +})); + +jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + getStatusWithColor: jest.fn((s) => s), + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), + stopLoadingIndicator: jest.fn(), + displayAiUsageSummary: jest.fn() +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn().mockResolvedValue() + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/ai-services-unified.js', + () => ({ + generateTextService: jest + .fn() + .mockResolvedValue({ mainResult: { content: '{}' }, telemetryData: {} }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/config-manager.js', + () => ({ + getDebugFlag: jest.fn(() => false), + isApiKeySet: jest.fn(() => true) + }) +); + +const { readJSON, log } = await import( + '../../../../../scripts/modules/utils.js' +); +const { default: updateTaskById } = await import( + '../../../../../scripts/modules/task-manager/update-task-by-id.js' +); + +describe('updateTaskById validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + test('throws error if prompt is empty', async () => { + await expect( + updateTaskById( + 'tasks/tasks.json', + 1, + '', + false, + { tag: 'master' }, + 'json' + ) + ).rejects.toThrow('Prompt cannot be empty'); + }); + + test('throws error if task file missing', async () => { + const fs = await import('fs'); + fs.existsSync.mockReturnValue(false); + await expect( + updateTaskById( + 'tasks/tasks.json', + 1, + 'prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Tasks file not found'); + }); + + test('throws error when task ID not found', async () => { + const fs = await import('fs'); + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ tag: 'master', tasks: [] }); + await expect( + updateTaskById( + 'tasks/tasks.json', + 42, + 'prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Task with ID 42 not found'); + expect(log).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/update-tasks.test.js b/tests/unit/scripts/modules/task-manager/update-tasks.test.js index 09cc226b..f3be5e75 100644 --- a/tests/unit/scripts/modules/task-manager/update-tasks.test.js +++ b/tests/unit/scripts/modules/task-manager/update-tasks.test.js @@ -171,7 +171,7 @@ describe('updateTasks', () => { mockFromId, mockPrompt, false, // research - { projectRoot: '/mock/path' }, // context + { projectRoot: '/mock/path', tag: 'master' }, // context 'json' // output format ); @@ -241,7 +241,7 @@ describe('updateTasks', () => { mockFromId, mockPrompt, false, - { projectRoot: '/mock/path' }, + { projectRoot: '/mock/path', tag: 'master' }, 'json' ); diff --git a/tests/unit/task-manager/clear-subtasks.test.js b/tests/unit/task-manager/clear-subtasks.test.js new file mode 100644 index 00000000..e56c2293 --- /dev/null +++ b/tests/unit/task-manager/clear-subtasks.test.js @@ -0,0 +1,53 @@ +import fs from 'fs'; +import path from 'path'; +import clearSubtasks from '../../../scripts/modules/task-manager/clear-subtasks.js'; + +const TMP = path.join(process.cwd(), '.tmp_clear_subtasks'); +const TASKS = path.join(TMP, 'tasks.json'); + +function seed() { + fs.rmSync(TMP, { recursive: true, force: true }); + fs.mkdirSync(path.join(TMP, '.taskmaster'), { recursive: true }); + fs.writeFileSync( + TASKS, + JSON.stringify( + { + master: { + tasks: [ + { + id: 1, + title: 'Parent', + subtasks: [ + { id: 1, title: 'Sub1' }, + { id: 2, title: 'Sub2' } + ] + }, + { id: 2, title: 'Solo' } + ], + metadata: { created: new Date().toISOString() } + } + }, + null, + 2 + ) + ); +} + +describe('clearSubtasks', () => { + beforeEach(seed); + afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); + + it('clears subtasks for given task id', () => { + clearSubtasks(TASKS, '1', { projectRoot: TMP, tag: 'master' }); + const data = JSON.parse(fs.readFileSync(TASKS, 'utf8')); + const parent = data.master.tasks.find((t) => t.id === 1); + expect(parent.subtasks.length).toBe(0); + }); + + it('does nothing when task has no subtasks', () => { + clearSubtasks(TASKS, '2', { projectRoot: TMP, tag: 'master' }); + const data = JSON.parse(fs.readFileSync(TASKS, 'utf8')); + const solo = data.master.tasks.find((t) => t.id === 2); + expect(solo.subtasks).toBeUndefined(); + }); +}); diff --git a/tests/unit/task-manager/move-task.test.js b/tests/unit/task-manager/move-task.test.js new file mode 100644 index 00000000..8b2fb2a6 --- /dev/null +++ b/tests/unit/task-manager/move-task.test.js @@ -0,0 +1,54 @@ +import fs from 'fs'; +import path from 'path'; +import moveTask from '../../../scripts/modules/task-manager/move-task.js'; + +const TMP = path.join(process.cwd(), '.tmp_move_task'); +const TASKS = path.join(TMP, 'tasks.json'); + +function seed(initialTasks) { + fs.rmSync(TMP, { recursive: true, force: true }); + fs.mkdirSync(path.join(TMP, '.taskmaster'), { recursive: true }); + fs.writeFileSync( + TASKS, + JSON.stringify( + { + master: { + tasks: initialTasks, + metadata: { created: new Date().toISOString() } + } + }, + null, + 2 + ) + ); +} + +describe('moveTask basic scenarios', () => { + afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); + + it('moves a task to a new ID within same tag', async () => { + seed([ + { id: 1, title: 'A' }, + { id: 2, title: 'B' } + ]); + + await moveTask(TASKS, '1', '3', false, { projectRoot: TMP, tag: 'master' }); + + const data = JSON.parse(fs.readFileSync(TASKS, 'utf8')); + const ids = data.master.tasks.map((t) => t.id); + expect(ids).toEqual(expect.arrayContaining([2, 3])); + expect(ids).not.toContain(1); + }); + + it('refuses to move across tags', async () => { + // build dual-tag structure + seed([{ id: 1, title: 'task' }]); + const raw = JSON.parse(fs.readFileSync(TASKS, 'utf8')); + raw.other = { tasks: [], metadata: { created: new Date().toISOString() } }; + fs.writeFileSync(TASKS, JSON.stringify(raw, null, 2)); + + await expect( + moveTask(TASKS, '1', '2', false, { projectRoot: TMP, tag: 'other' }) + ).rejects.toThrow(/Source task/); + }); +}); diff --git a/tests/unit/task-manager/tag-boundary.test.js b/tests/unit/task-manager/tag-boundary.test.js new file mode 100644 index 00000000..86d9e937 --- /dev/null +++ b/tests/unit/task-manager/tag-boundary.test.js @@ -0,0 +1,278 @@ +import fs from 'fs'; +import path from 'path'; +import { + createTag, + useTag, + deleteTag +} from '../../../scripts/modules/task-manager/tag-management.js'; + +// Temporary workspace for each test run +const TEMP_DIR = path.join(process.cwd(), '.tmp_tag_boundary'); +const TASKS_PATH = path.join(TEMP_DIR, 'tasks.json'); +const STATE_PATH = path.join(TEMP_DIR, '.taskmaster', 'state.json'); + +function seedWorkspace() { + // Reset temp dir + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + fs.mkdirSync(path.join(TEMP_DIR, '.taskmaster'), { + recursive: true, + force: true + }); + + // Minimal master tag file + fs.writeFileSync( + TASKS_PATH, + JSON.stringify( + { + master: { + tasks: [{ id: 1, title: 'Seed task', status: 'pending' }], + metadata: { created: new Date().toISOString() } + } + }, + null, + 2 + ), + 'utf8' + ); + + // Initial state.json + fs.writeFileSync( + STATE_PATH, + JSON.stringify( + { currentTag: 'master', lastSwitched: new Date().toISOString() }, + null, + 2 + ), + 'utf8' + ); +} + +describe('Tag boundary resolution', () => { + beforeEach(seedWorkspace); + afterAll(() => fs.rmSync(TEMP_DIR, { recursive: true, force: true })); + + it('switches currentTag in state.json when useTag succeeds', async () => { + await createTag( + TASKS_PATH, + 'feature-x', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + await useTag( + TASKS_PATH, + 'feature-x', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + + const state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); + expect(state.currentTag).toBe('feature-x'); + }); + + it('throws error when switching to non-existent tag', async () => { + await expect( + useTag(TASKS_PATH, 'ghost', {}, { projectRoot: TEMP_DIR }, 'json') + ).rejects.toThrow(/does not exist/); + }); + + it('deleting active tag auto-switches back to master', async () => { + await createTag(TASKS_PATH, 'temp', {}, { projectRoot: TEMP_DIR }, 'json'); + await useTag(TASKS_PATH, 'temp', {}, { projectRoot: TEMP_DIR }, 'json'); + + // Delete the active tag with force flag (yes: true) + await deleteTag( + TASKS_PATH, + 'temp', + { yes: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + const state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); + expect(state.currentTag).toBe('master'); + + const tasksFile = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(tasksFile.temp).toBeUndefined(); + expect(tasksFile.master).toBeDefined(); + }); + + it('createTag with copyFromCurrent deep-copies tasks (mutation isolated)', async () => { + // create new tag with copy + await createTag( + TASKS_PATH, + 'alpha', + { copyFromCurrent: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + // mutate a field inside alpha tasks + const updatedData = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + updatedData.alpha.tasks[0].title = 'Changed in alpha'; + fs.writeFileSync(TASKS_PATH, JSON.stringify(updatedData, null, 2)); + + const finalData = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(finalData.master.tasks[0].title).toBe('Seed task'); + expect(finalData.alpha.tasks[0].title).toBe('Changed in alpha'); + }); + + it('addTask to non-master tag does not leak into master', async () => { + // create and switch + await createTag( + TASKS_PATH, + 'feature-api', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + + // Call addTask with manual data to avoid AI + const { default: addTask } = await import( + '../../../scripts/modules/task-manager/add-task.js' + ); + + await addTask( + TASKS_PATH, + 'Manual task', + [], + null, + { projectRoot: TEMP_DIR, tag: 'feature-api' }, + 'json', + { + title: 'API work', + description: 'Implement endpoint', + details: 'Details', + testStrategy: 'Tests' + }, + false + ); + + const data = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(data['feature-api'].tasks.length).toBe(1); // the new task only + expect(data.master.tasks.length).toBe(1); // still only seed + }); + + it('reserved tag names are rejected', async () => { + await expect( + createTag(TASKS_PATH, 'master', {}, { projectRoot: TEMP_DIR }, 'json') + ).rejects.toThrow(/reserved tag/i); + }); + + it('cannot delete the master tag', async () => { + await expect( + deleteTag( + TASKS_PATH, + 'master', + { yes: true }, + { projectRoot: TEMP_DIR }, + 'json' + ) + ).rejects.toThrow(/Cannot delete the "master" tag/); + }); + + it('copyTag deep copy – mutation does not affect source', async () => { + const { copyTag } = await import( + '../../../scripts/modules/task-manager/tag-management.js' + ); + + await createTag( + TASKS_PATH, + 'source', + { copyFromCurrent: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + await copyTag( + TASKS_PATH, + 'source', + 'clone', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + + // mutate clone task title + const data1 = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + data1.clone.tasks[0].title = 'Modified in clone'; + fs.writeFileSync(TASKS_PATH, JSON.stringify(data1, null, 2)); + + const data2 = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(data2.source.tasks[0].title).toBe('Seed task'); + expect(data2.clone.tasks[0].title).toBe('Modified in clone'); + }); + + it('adds task to tag derived from state.json when no explicit tag supplied', async () => { + // Create new tag and update state.json to make it current + await createTag( + TASKS_PATH, + 'feature-auto', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + const state1 = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); + state1.currentTag = 'feature-auto'; + fs.writeFileSync(STATE_PATH, JSON.stringify(state1, null, 2)); + + const { default: addTask } = await import( + '../../../scripts/modules/task-manager/add-task.js' + ); + const { resolveTag } = await import('../../../scripts/modules/utils.js'); + + const tag = resolveTag({ projectRoot: TEMP_DIR }); + + // Add task without passing tag -> should resolve to feature-auto + await addTask( + TASKS_PATH, + 'Auto task', + [], + null, + { projectRoot: TEMP_DIR, tag }, + 'json', + { + title: 'Auto task', + description: '-', + details: '-', + testStrategy: '-' + }, + false + ); + + const data = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(data['feature-auto'].tasks.length).toBe(1); + expect(data.master.tasks.length).toBe(1); // master unchanged + }); + + it('falls back to master when state.json lacks currentTag', async () => { + // wipe currentTag field + fs.writeFileSync(STATE_PATH, JSON.stringify({}, null, 2)); + + const { default: addTask } = await import( + '../../../scripts/modules/task-manager/add-task.js' + ); + const { resolveTag } = await import('../../../scripts/modules/utils.js'); + + const tag = resolveTag({ projectRoot: TEMP_DIR }); // should return master + + await addTask( + TASKS_PATH, + 'Fallback task', + [], + null, + { projectRoot: TEMP_DIR, tag }, + 'json', + { + title: 'Fallback', + description: '-', + details: '-', + testStrategy: '-' + }, + false + ); + + const data = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(data.master.tasks.length).toBe(2); // seed + new task + }); +}); diff --git a/tests/unit/task-master.test.js b/tests/unit/task-master.test.js index c3b9c48b..89655e4f 100644 --- a/tests/unit/task-master.test.js +++ b/tests/unit/task-master.test.js @@ -432,8 +432,10 @@ describe('initTaskMaster', () => { path.join(taskMasterDir, 'state.json') ); // PRD and complexity report paths are undefined when not provided - expect(taskMaster.getPrdPath()).toBeUndefined(); - expect(taskMaster.getComplexityReportPath()).toBeUndefined(); + expect(typeof taskMaster.getComplexityReportPath()).toBe('string'); + expect(taskMaster.getComplexityReportPath()).toMatch( + /task-complexity-report\.json$/ + ); }); }); });