fix(core): Implement Boundary-First Tag Resolution (#943)

* refactor(context): Standardize tag and projectRoot handling across all task tools

This commit unifies context management by adopting a boundary-first resolution strategy. All task-scoped tools now resolve `tag` and `projectRoot` at their entry point and forward these values to the underlying direct functions.

This approach centralizes context logic, ensuring consistent behavior and enhanced flexibility in multi-tag environments.

* fix(tag): Clean up tag handling in task functions and sync process

This commit refines the handling of the `tag` parameter across multiple functions, ensuring consistent context management. The `tag` is now passed more efficiently in `listTasksDirect`, `setTaskStatusDirect`, and `syncTasksToReadme`, improving clarity and reducing redundancy. Additionally, a TODO comment has been added in `sync-readme.js` to address future tag support enhancements.

* feat(tag): Implement Boundary-First Tag Resolution for consistent tag handling

This commit introduces Boundary-First Tag Resolution in the task manager, ensuring consistent and deterministic tag handling across CLI and MCP. This change resolves potential race conditions and improves the reliability of tag-specific operations.

Additionally, the `expandTask` function has been updated to use the resolved tag when writing JSON, enhancing data integrity during task updates.

* chore(biome): formatting

* fix(expand-task): Update writeJSON call to use tag instead of resolvedTag

* fix(commands): Enhance complexity report path resolution and task initialization
`resolveComplexityReportPath` function to streamline output path generation based on tag context and user-defined output.
- Improved clarity and maintainability of command handling by centralizing path resolution logic.

* Fix: unknown currentTag

* fix(task-manager): Update generateTaskFiles calls to include tag and projectRoot parameters

This commit modifies the `moveTask` and `updateSubtaskById` functions to pass the `tag` and `projectRoot` parameters to the `generateTaskFiles` function. This ensures that task files are generated with the correct context when requested, enhancing consistency in task management operations.

* fix(commands): Refactor tag handling and complexity report path resolution
This commit updates the `registerCommands` function to utilize `taskMaster.getCurrentTag()` for consistent tag retrieval across command actions. It also enhances the initialization of `TaskMaster` by passing the tag directly, improving clarity and maintainability. The complexity report path resolution is streamlined to ensure correct file naming based on the current tag context.

* fix(task-master): Update complexity report path expectations in tests
This commit modifies the `initTaskMaster` test to expect a valid string for the complexity report path, ensuring it matches the expected file naming convention. This change enhances test reliability by verifying the correct output format when the path is generated.

* fix(set-task-status): Enhance logging and tag resolution in task status updates
This commit improves the logging output in the `registerSetTaskStatusTool` function to include the tag context when setting task statuses. It also updates the tag handling by resolving the tag using the `resolveTag` utility, ensuring that the correct tag is used when updating task statuses. Additionally, the `setTaskStatus` function is modified to remove the tag parameter from the `readJSON` and `writeJSON` calls, streamlining the data handling process.

* fix(commands, expand-task, task-manager): Add complexity report option and enhance path handling
This commit introduces a new `--complexity-report` option in the `registerCommands` function, allowing users to specify a custom path for the complexity report. The `expandTask` function is updated to accept the `complexityReportPath` from the context, ensuring it is utilized correctly during task expansion. Additionally, the `setTaskStatus` function now includes the `tag` parameter in the `readJSON` and `writeJSON` calls, improving task status updates with proper context. The `initTaskMaster` function is also modified to create parent directories for output paths, enhancing file handling robustness.

* fix(expand-task): Add complexityReportPath to context for task expansion tests

This commit updates the test for the `expandTask` function by adding the `complexityReportPath` to the context object. This change ensures that the complexity report path is correctly utilized in the test, aligning with recent enhancements to complexity report handling in the task manager.

* chore: implement suggested changes

* fix(parse-prd): Clarify tag parameter description for task organization
Updated the documentation for the `tag` parameter in the `parse-prd.js` file to provide a clearer context on its purpose for organizing tasks into separate task lists.

* Fix Inconsistent tag resolution pattern.

* fix: Enhance complexity report path handling with tag support

This commit updates various functions to incorporate the `tag` parameter when resolving complexity report paths. The `expandTaskDirect`, `resolveComplexityReportPath`, and related tools now utilize the current tag context, improving consistency in task management. Additionally, the complexity report path is now correctly passed through the context in the `expand-task` and `set-task-status` tools, ensuring accurate report retrieval based on the active tag.

* Updated the JSDoc for the `tag` parameter in the `show-task.js` file.

* Remove redundant comment on tag parameter in readJSON call

* Remove unused import for getTagAwareFilePath

* Add missed complexityReportPath to args for task expansion

* fix(tests): Enhance research tests with tag-aware functionality

This commit updates the `research.test.js` file to improve the testing of the `performResearch` function by incorporating tag-aware functionality. Key changes include mocking the `findProjectRoot` to return a valid path, enhancing the `ContextGatherer` and `FuzzyTaskSearch` mocks, and adding comprehensive tests for tag parameter handling in various scenarios. The tests now cover passing different tag values, ensuring correct behavior when tags are provided, undefined, or null, and validating the integration of tags in task discovery and context gathering processes.

* Remove unused import for

* fix: Refactor complexity report path handling and improve argument destructuring

This commit enhances the `expandTaskDirect` function by improving the destructuring of arguments for better readability. It also updates the `analyze.js` and `analyze-task-complexity.js` files to utilize the new `resolveComplexityReportOutputPath` function, ensuring tag-aware resolution of output paths. Additionally, logging has been added to provide clarity on the report path being used.

* test: Add complexity report tag isolation tests and improve path handling

This commit introduces a new test file for complexity report tag isolation, ensuring that different tags maintain separate complexity reports. It enhances the existing tests in `analyze-task-complexity.test.js` by updating expectations to use `expect.stringContaining` for file paths, improving robustness against path changes. The new tests cover various scenarios, including path resolution and report generation for both master and feature tags, ensuring no cross-tag contamination occurs.

* Update scripts/modules/task-manager/list-tasks.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update scripts/modules/task-manager/list-tasks.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test(complexity-report): Fix tag slugification in filename expectations

- Update mocks to use slugifyTagForFilePath for cross-platform compatibility
- Replace raw tag values with slugified versions in expected filenames
- Fix test expecting 'feature/user-auth-v2' to expect 'feature-user-auth-v2'
- Align test with actual filename generation logic that sanitizes special chars

---------

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Parthy
2025-07-18 19:05:04 +02:00
committed by Ralph Khreish
parent 0451ebcc32
commit fd005c4c54
95 changed files with 3899 additions and 590 deletions

View File

@@ -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.

View File

@@ -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<Object>} - 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();

View File

@@ -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<Object>} - 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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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'
);

View File

@@ -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,

View File

@@ -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();

View File

@@ -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<Object>} - 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();

View File

@@ -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<Object>} - 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) {

View File

@@ -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
}
);

View File

@@ -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<Object>} - 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 {

View File

@@ -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<Object>} - 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,

View File

@@ -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();

View File

@@ -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

View File

@@ -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<Object>} - 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 {

View File

@@ -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<Object>} - 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

View File

@@ -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<Object>} - 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 }

View File

@@ -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<Object>} - 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,

View File

@@ -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<Object>} - 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'
},

View File

@@ -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<Object>} - 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'
},

View File

@@ -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<Object>} - Result object with success status and data/error information.

View File

@@ -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();

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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

View File

@@ -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 }

View File

@@ -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(

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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
);

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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
);

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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
);

View File

@@ -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 <file>',
'Path to the report file',
COMPLEXITY_REPORT_FILE
)
.option('--tag <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 <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 <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 <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 <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 <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 <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 <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 <status>',
'Show only tasks matching this status (e.g., pending, done)'
)
.option('-t, --tag <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 _<tag>.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,

View File

@@ -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}...`);

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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<object>} 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,

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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<Object>}>} - 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++;

View File

@@ -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<Object>} 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 {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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
);

View File

@@ -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...');

View File

@@ -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<Object|null>} - 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) {

View File

@@ -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<Object|null>} - 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 ---

View File

@@ -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}`

View File

@@ -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<string>} 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
);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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"

View File

@@ -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

View File

@@ -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'
);

View File

@@ -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');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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() }
});

View File

@@ -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

View File

@@ -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/);
});
});

View File

@@ -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

View File

@@ -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();

View File

@@ -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();
});
});

View File

@@ -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();
});
});
});

View File

@@ -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() }
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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'
);

View File

@@ -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();
});
});

View File

@@ -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/);
});
});

View File

@@ -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
});
});

View File

@@ -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$/
);
});
});
});