mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-11-14 09:05:04 +00:00
* feat: enhance move command with cross-tag functionality - Updated the `move` command to allow moving tasks between different tags, including options for handling dependencies. - Added new options: `--from-tag`, `--to-tag`, `--with-dependencies`, `--ignore-dependencies`, and `--force`. - Implemented validation for cross-tag moves and dependency checks. - Introduced helper functions in the dependency manager for validating and resolving cross-tag dependencies. - Added integration and unit tests to cover new functionality and edge cases. * fix: refactor cross-tag move logic and enhance validation - Moved the import of `moveTasksBetweenTags` to the correct location in `commands.js` for better clarity. - Added new helper functions in `dependency-manager.js` to improve validation and error handling for cross-tag moves. - Enhanced existing functions to ensure proper handling of task dependencies and conflicts. - Updated tests to cover new validation scenarios and ensure robust error messaging for invalid task IDs and tags. * fix: improve task ID handling and error messaging in cross-tag moves - Refactored `moveTasksBetweenTags` to normalize task IDs for comparison, ensuring consistent handling of string and numeric IDs. - Enhanced error messages for cases where source and target tags are the same but no destination is specified. - Updated tests to validate new behavior, including handling string dependencies correctly during cross-tag moves. - Cleaned up existing code for better readability and maintainability. * test: add comprehensive tests for cross-tag move and dependency validation - Introduced new test files for `move-cross-tag` and `cross-tag-dependencies` to cover various scenarios in cross-tag task movement. - Implemented tests for handling task movement with and without dependencies, including edge cases for error handling. - Enhanced existing tests in `fix-dependencies-command` and `move-task` to ensure robust validation of task IDs and dependencies. - Mocked necessary modules and functions to isolate tests and improve reliability. - Ensured coverage for both successful and failed cross-tag move operations, validating expected outcomes and error messages. * test: refactor cross-tag move tests for better clarity and reusability - Introduced a helper function `simulateCrossTagMove` to streamline cross-tag move test cases, reducing redundancy and improving readability. - Updated existing tests to utilize the new helper function, ensuring consistent handling of expected messages and options. - Enhanced test coverage for various scenarios, including handling of dependencies and flags. * feat: add cross-tag task movement functionality - Introduced new commands for moving tasks between different tags, enhancing project organization capabilities. - Updated README with usage examples for cross-tag movement, including options for handling dependencies. - Created comprehensive documentation for cross-tag task movement, detailing usage, error handling, and best practices. - Implemented core logic for cross-tag moves, including validation for dependencies and error handling. - Added integration and unit tests to ensure robust functionality and coverage for various scenarios, including edge cases. * fix: enhance error handling and logging in cross-tag task movement - Improved logging in `moveTaskCrossTagDirect` to include detailed arguments for better traceability. - Refactored error handling to utilize structured error objects, providing clearer suggestions for resolving cross-tag dependency conflicts and subtask movement restrictions. - Updated documentation to reflect changes in error handling and provide clearer guidance on task movement options. - Added integration tests for cross-tag movement scenarios, ensuring robust validation of error handling and task movement logic. - Cleaned up existing tests for clarity and reusability, enhancing overall test coverage. * feat: enhance dependency resolution and error handling in task movement - Added recursive dependency resolution for tasks in `moveTasksBetweenTags`, improving handling of complex task relationships. - Introduced helper functions to find all dependencies and reverse dependencies, ensuring comprehensive coverage during task moves. - Enhanced error messages in `validateSubtaskMove` and `displaySubtaskMoveError` for better clarity on movement restrictions. - Updated tests to cover new functionality, including integration tests for complex cross-tag movement scenarios and edge cases. - Refactored existing code for improved readability and maintainability, ensuring consistent handling of task IDs and dependencies. * feat: unify dependency traversal and enhance task management utilities - Introduced `traverseDependencies` utility for unified forward and reverse dependency traversal, improving code reusability and clarity. - Refactored `findAllDependenciesRecursively` to leverage the new utility, streamlining dependency resolution in task management. - Added `formatTaskIdForDisplay` helper for better task ID formatting in UI, enhancing user experience during error displays. - Updated tests to cover new utility functions and ensure robust validation of dependency handling across various scenarios. - Improved overall code organization and readability, ensuring consistent handling of task dependencies and IDs. * fix: improve validation for dependency parameters in `findAllDependenciesRecursively` - Added checks to ensure `sourceTasks` and `allTasks` are arrays, throwing errors if not, to prevent runtime issues. - Updated documentation comment for clarity on the function's purpose and parameters. * fix: remove `force` option from task movement parameters - Eliminated the `force` parameter from the `moveTaskCrossTagDirect` function and related tools, simplifying the task movement logic. - Updated documentation and tests to reflect the removal of the `force` option, ensuring clarity and consistency across the codebase. - Adjusted related functions and tests to focus on `ignoreDependencies` as the primary control for handling dependency conflicts during task moves. * Add cross-tag task movement functionality - Introduced functionality for organizing tasks across different contexts by enabling cross-tag movement. - Added `formatTaskIdForDisplay` helper to improve task ID formatting in UI error messages. - Updated relevant tests to incorporate new functionality and ensure accurate error displays during task movements. * Update scripts/modules/dependency-manager.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * refactor(dependency-manager): Fix subtask resolution and extract helper functions 1. Fix subtask finding logic (lines 1315-1330): - Correctly locate parent task by numeric ID - Search within parent's subtasks array instead of top-level tasks - Properly handle relative subtask references 2. Extract helper functions from getDependentTaskIds (lines 1440-1636): - Move findTasksThatDependOn as module-level function - Move taskDependsOnSource as module-level function - Move subtasksDependOnSource as module-level function - Improves readability, maintainability, and testability Both fixes address architectural issues and improve code organization. * refactor(dependency-manager): Enhance subtask resolution and dependency validation - Improved subtask resolution logic to correctly find parent tasks and their subtasks, ensuring accurate identification of dependencies. - Filtered out null/undefined dependencies before processing, enhancing robustness in dependency checks. - Updated comments for clarity on the logic flow and purpose of changes, improving code maintainability. * refactor(move-task): clarify destination ID description and improve skipped task handling - Updated the description for the destination ID to clarify its usage in cross-tag moves. - Simplified the handling of skipped tasks during multiple task movements, improving readability and logging. - Enhanced the API result response to include detailed information about moved and skipped tasks, ensuring better feedback for users. * refactor(commands): remove redundant tag validation logic - Eliminated the check for identical source and target tags in the task movement logic, simplifying the code. - This change streamlines the flow for within-tag moves, enhancing readability and maintainability. * refactor(commands): enhance move command logic and error handling - Introduced helper functions for better organization of cross-tag and within-tag move logic, improving code readability and maintainability. - Enhanced error handling with structured error objects, providing clearer feedback for dependency conflicts and invalid tag combinations. - Updated move command help output to include best practices and error resolution tips, ensuring users have comprehensive guidance during task movements. - Streamlined task movement logic to handle multiple tasks more effectively, including detailed logging of successful and failed moves. * test(dependency-manager): add subtasks to task structure and mock dependency traversal - Updated `circular-dependencies.test.js` to include subtasks in task definitions, enhancing test coverage for task structures with nested dependencies. - Mocked `traverseDependencies` in `fix-dependencies-command.test.js` to ensure consistent behavior during tests, improving reliability of dependency-related tests. * refactor(dependency-manager): extract subtask finding logic into helper function - Added `findSubtaskInParent` function to encapsulate subtask resolution within a parent task's subtasks array, improving code organization and readability. - Updated `findDependencyTask` to utilize the new helper function, streamlining the logic for finding subtasks and enhancing maintainability. - Enhanced comments for clarity on the purpose and functionality of the new subtask finding logic. * refactor(ui): enhance subtask ID validation and improve error handling - Added validation for subtask ID format in `formatDependenciesWithStatus` and `taskExists`, ensuring proper handling of invalid formats. - Updated error logging in `displaySubtaskMoveError` to provide warnings for unexpected task ID formats, improving user feedback. - Converted hints to a Set in `displayDependencyValidationHints` to ensure unique hints are displayed, enhancing clarity in the UI. * test(cli): remove redundant timing check in complex cross-tag scenarios - Eliminated the timing check for task completion within 5 seconds in `complex-cross-tag-scenarios.test.js`, streamlining the test logic. - This change focuses on verifying task success without unnecessary timing constraints, enhancing test clarity and maintainability. * test(integration): enhance task movement tests with mock file system - Added integration tests for moving tasks within the same tag and between different tags using the actual `moveTask` and `moveTasksBetweenTags` functions. - Implemented `mock-fs` to simulate file system interactions, improving test isolation and reliability. - Verified task movement success and ensured proper handling of subtasks and dependencies, enhancing overall test coverage for task management functionality. - Included error handling tests for missing tags and task IDs to ensure robustness in task movement operations. * test(unit): add comprehensive tests for moveTaskCrossTagDirect functionality - Introduced new test cases to verify mock functionality, ensuring that mocks for `findTasksPath` and `readJSON` are working as expected. - Added tests for parameter validation, error handling, and function call flow, including scenarios for missing project roots and identical source/target tags. - Enhanced coverage for ID parsing and move options, ensuring robust handling of various input conditions and improving overall test reliability. * test(integration): skip tests for dependency conflict handling and withDependencies option - Marked tests for handling dependency conflicts and the withDependencies option as skipped due to issues with the mock setup. - Added TODOs to address the mock-fs setup for complex dependency scenarios, ensuring future improvements in test reliability. * test(unit): expand cross-tag move command tests with comprehensive mocks - Added extensive mocks for various modules to enhance the testing of the cross-tag move functionality in `move-cross-tag.test.js`. - Implemented detailed test cases for handling cross-tag moves, including validation for missing parameters and identical source/target tags. - Improved error handling tests to ensure robust feedback for invalid operations, enhancing overall test reliability and coverage. * test(integration): add complex dependency scenarios to task movement tests - Introduced new integration tests for handling complex dependency scenarios in task movement, utilizing the actual `moveTasksBetweenTags` function. - Added tests for circular dependencies, nested dependency chains, and cross-tag dependency resolution, enhancing coverage and reliability. - Documented limitations of the mock-fs setup for complex scenarios and provided warnings in the test output to guide future improvements. - Skipped tests for dependency conflicts and the withDependencies option due to mock setup issues, with TODOs for resolution. * test(unit): refactor move-cross-tag tests with focused mock system - Simplified mocking in `move-cross-tag.test.js` by implementing a configuration-driven mock system, reducing the number of mocked modules from 20+ to 5 core functionalities. - Introduced a reusable mock factory to streamline the creation of mocks based on configuration, enhancing maintainability and clarity. - Added documentation for the new mock system, detailing usage examples and benefits, including reduced complexity and improved test focus. - Implemented tests to validate the mock configuration, ensuring flexibility in enabling/disabling specific mocks. * test(unit): clean up mocks and improve isEmpty function in fix-dependencies-command tests - Removed the mock for `traverseDependencies` as it was unnecessary, simplifying the test setup. - Updated the `isEmpty` function to clarify its behavior regarding null and undefined values, enhancing code readability and maintainability. * test(unit): update traverseDependencies mock for consistency across tests - Standardized the mock implementation of `traverseDependencies` in both `fix-dependencies-command.test.js` and `complexity-report-tag-isolation.test.js` to accept `sourceTasks`, `allTasks`, and `options` parameters, ensuring uniformity in test setups. - This change enhances clarity and maintainability of the tests by aligning the mock behavior across different test files. * fix(core): improve task movement error handling and ID normalization - Wrapped task movement logic in a try-finally block to ensure console output is restored even on errors, enhancing reliability. - Normalized source IDs to handle mixed string/number comparisons, preventing potential issues in dependency checks. - Added tests for ID type consistency to verify that the normalization fix works correctly across various scenarios, improving test coverage and robustness. * refactor(task-manager): restructure task movement logic for improved validation and execution - Renamed and refactored `moveTasksBetweenTags` to streamline the task movement process into distinct phases: validation, data preparation, dependency resolution, execution, and finalization. - Introduced `validateMove`, `prepareTaskData`, `resolveDependencies`, `executeMoveOperation`, and `finalizeMove` functions to enhance modularity and clarity. - Updated documentation comments to reflect changes in function responsibilities and parameters. - Added comprehensive unit tests for the new structure, ensuring robust validation and error handling across various scenarios. - Improved handling of dependencies and task existence checks during the move operation, enhancing overall reliability. * fix(move-task): streamline task movement logic and improve error handling - Refactored the task movement process to enhance clarity and maintainability by replacing `forEach` with a `for...of` loop for better async handling. - Consolidated error handling and result logging to ensure consistent feedback during task moves. - Updated the logic for generating files only on the last move, improving performance and reducing unnecessary operations. - Enhanced validation for skipped tasks, ensuring accurate reporting of moved and skipped tasks in the final result. * fix(docs): update error message formatting and enhance clarity in task movement documentation - Changed code block syntax from generic to `text` for better readability in error messages related to task movement and dependency conflicts. - Ensured consistent formatting across all error message examples to improve user understanding of task movement restrictions and resolutions. - Added a newline at the end of the file for proper formatting. * Update .changeset/crazy-meals-hope.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: improve changeset * chore: improve changeset * fix referenced bug in docs and remove docs * chore: fix format --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
1621 lines
46 KiB
JavaScript
1621 lines
46 KiB
JavaScript
/**
|
|
* utils.js
|
|
* Utility functions for the Task Master CLI
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import chalk from 'chalk';
|
|
import dotenv from 'dotenv';
|
|
// Import specific config getters needed here
|
|
import { getLogLevel, getDebugFlag } from './config-manager.js';
|
|
import * as gitUtils from './utils/git-utils.js';
|
|
import {
|
|
COMPLEXITY_REPORT_FILE,
|
|
LEGACY_COMPLEXITY_REPORT_FILE,
|
|
LEGACY_CONFIG_FILE
|
|
} from '../../src/constants/paths.js';
|
|
|
|
// Global silent mode flag
|
|
let silentMode = false;
|
|
|
|
// --- Environment Variable Resolution Utility ---
|
|
/**
|
|
* Resolves an environment variable's value.
|
|
* Precedence:
|
|
* 1. session.env (if session provided)
|
|
* 2. process.env
|
|
* 3. .env file at projectRoot (if projectRoot provided)
|
|
* @param {string} key - The environment variable key.
|
|
* @param {object|null} [session=null] - The MCP session object.
|
|
* @param {string|null} [projectRoot=null] - The project root directory (for .env fallback).
|
|
* @returns {string|undefined} The value of the environment variable or undefined if not found.
|
|
*/
|
|
function resolveEnvVariable(key, session = null, projectRoot = null) {
|
|
// 1. Check session.env
|
|
if (session?.env?.[key]) {
|
|
return session.env[key];
|
|
}
|
|
|
|
// 2. Read .env file at projectRoot
|
|
if (projectRoot) {
|
|
const envPath = path.join(projectRoot, '.env');
|
|
if (fs.existsSync(envPath)) {
|
|
try {
|
|
const envFileContent = fs.readFileSync(envPath, 'utf-8');
|
|
const parsedEnv = dotenv.parse(envFileContent); // Use dotenv to parse
|
|
if (parsedEnv && parsedEnv[key]) {
|
|
// console.log(`DEBUG: Found key ${key} in ${envPath}`); // Optional debug log
|
|
return parsedEnv[key];
|
|
}
|
|
} catch (error) {
|
|
// Log error but don't crash, just proceed as if key wasn't found in file
|
|
log('warn', `Could not read or parse ${envPath}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Fallback: Check process.env
|
|
if (process.env[key]) {
|
|
return process.env[key];
|
|
}
|
|
|
|
// Not found anywhere
|
|
return undefined;
|
|
}
|
|
|
|
// --- Tag-Aware Path Resolution Utility ---
|
|
|
|
/**
|
|
* Slugifies a tag name to be filesystem-safe
|
|
* @param {string} tagName - The tag name to slugify
|
|
* @returns {string} Slugified tag name safe for filesystem use
|
|
*/
|
|
function slugifyTagForFilePath(tagName) {
|
|
if (!tagName || typeof tagName !== 'string') {
|
|
return 'unknown-tag';
|
|
}
|
|
|
|
// Replace invalid filesystem characters with hyphens and clean up
|
|
return tagName
|
|
.replace(/[^a-zA-Z0-9_-]/g, '-') // Replace invalid chars with hyphens
|
|
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
|
.replace(/-+/g, '-') // Collapse multiple hyphens
|
|
.toLowerCase() // Convert to lowercase
|
|
.substring(0, 50); // Limit length to prevent overly long filenames
|
|
}
|
|
|
|
/**
|
|
* Resolves a file path to be tag-aware, following the pattern used by other commands.
|
|
* For non-master tags, appends _slugified-tagname before the file extension.
|
|
* @param {string} basePath - The base file path (e.g., '.taskmaster/reports/task-complexity-report.json')
|
|
* @param {string|null} tag - The tag name (null, undefined, or 'master' uses base path)
|
|
* @param {string} [projectRoot='.'] - The project root directory
|
|
* @returns {string} The resolved file path
|
|
*/
|
|
function getTagAwareFilePath(basePath, tag, projectRoot = '.') {
|
|
// Use path.parse and format for clean tag insertion
|
|
const parsedPath = path.parse(basePath);
|
|
if (!tag || tag === 'master') {
|
|
return path.join(projectRoot, basePath);
|
|
}
|
|
|
|
// Slugify the tag for filesystem safety
|
|
const slugifiedTag = slugifyTagForFilePath(tag);
|
|
|
|
// Append slugified tag before file extension
|
|
parsedPath.base = `${parsedPath.name}_${slugifiedTag}${parsedPath.ext}`;
|
|
const relativePath = path.format(parsedPath);
|
|
return path.join(projectRoot, relativePath);
|
|
}
|
|
|
|
// --- Project Root Finding Utility ---
|
|
/**
|
|
* Recursively searches upwards for project root starting from a given directory.
|
|
* @param {string} [startDir=process.cwd()] - The directory to start searching from.
|
|
* @param {string[]} [markers=['package.json', '.git', LEGACY_CONFIG_FILE]] - Marker files/dirs to look for.
|
|
* @returns {string|null} The path to the project root, or null if not found.
|
|
*/
|
|
function findProjectRoot(
|
|
startDir = process.cwd(),
|
|
markers = ['package.json', 'pyproject.toml', '.git', LEGACY_CONFIG_FILE]
|
|
) {
|
|
let currentPath = path.resolve(startDir);
|
|
const rootPath = path.parse(currentPath).root;
|
|
|
|
while (currentPath !== rootPath) {
|
|
// Check if any marker exists in the current directory
|
|
const hasMarker = markers.some((marker) => {
|
|
const markerPath = path.join(currentPath, marker);
|
|
return fs.existsSync(markerPath);
|
|
});
|
|
|
|
if (hasMarker) {
|
|
return currentPath;
|
|
}
|
|
|
|
// Move up one directory
|
|
currentPath = path.dirname(currentPath);
|
|
}
|
|
|
|
// Check the root directory as well
|
|
const hasMarkerInRoot = markers.some((marker) => {
|
|
const markerPath = path.join(rootPath, marker);
|
|
return fs.existsSync(markerPath);
|
|
});
|
|
|
|
return hasMarkerInRoot ? rootPath : null;
|
|
}
|
|
|
|
// --- Dynamic Configuration Function --- (REMOVED)
|
|
|
|
// --- Logging and Utility Functions ---
|
|
|
|
// Set up logging based on log level
|
|
const LOG_LEVELS = {
|
|
debug: 0,
|
|
info: 1,
|
|
warn: 2,
|
|
error: 3,
|
|
success: 1 // Treat success like info level
|
|
};
|
|
|
|
/**
|
|
* Returns the task manager module
|
|
* @returns {Promise<Object>} The task manager module object
|
|
*/
|
|
async function getTaskManager() {
|
|
return import('./task-manager.js');
|
|
}
|
|
|
|
/**
|
|
* Enable silent logging mode
|
|
*/
|
|
function enableSilentMode() {
|
|
silentMode = true;
|
|
}
|
|
|
|
/**
|
|
* Disable silent logging mode
|
|
*/
|
|
function disableSilentMode() {
|
|
silentMode = false;
|
|
}
|
|
|
|
/**
|
|
* Check if silent mode is enabled
|
|
* @returns {boolean} True if silent mode is enabled
|
|
*/
|
|
function isSilentMode() {
|
|
return silentMode;
|
|
}
|
|
|
|
/**
|
|
* Logs a message at the specified level
|
|
* @param {string} level - The log level (debug, info, warn, error)
|
|
* @param {...any} args - Arguments to log
|
|
*/
|
|
function log(level, ...args) {
|
|
// Immediately return if silentMode is enabled
|
|
if (isSilentMode()) {
|
|
return;
|
|
}
|
|
|
|
// GUARD: Prevent circular dependency during config loading
|
|
// Use a simple fallback log level instead of calling getLogLevel()
|
|
let configLevel = 'info'; // Default fallback
|
|
try {
|
|
// Only try to get config level if we're not in the middle of config loading
|
|
configLevel = getLogLevel() || 'info';
|
|
} catch (error) {
|
|
// If getLogLevel() fails (likely due to circular dependency),
|
|
// use default 'info' level and continue
|
|
configLevel = 'info';
|
|
}
|
|
|
|
// Use text prefixes instead of emojis
|
|
const prefixes = {
|
|
debug: chalk.gray('[DEBUG]'),
|
|
info: chalk.blue('[INFO]'),
|
|
warn: chalk.yellow('[WARN]'),
|
|
error: chalk.red('[ERROR]'),
|
|
success: chalk.green('[SUCCESS]')
|
|
};
|
|
|
|
// Ensure level exists, default to info if not
|
|
const currentLevel = LOG_LEVELS.hasOwnProperty(level) ? level : 'info';
|
|
|
|
// Check log level configuration
|
|
if (
|
|
LOG_LEVELS[currentLevel] >= (LOG_LEVELS[configLevel] ?? LOG_LEVELS.info)
|
|
) {
|
|
const prefix = prefixes[currentLevel] || '';
|
|
// Use console.log for all levels, let chalk handle coloring
|
|
// Construct the message properly
|
|
const message = args
|
|
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : arg))
|
|
.join(' ');
|
|
console.log(`${prefix} ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the data object has a tagged structure (contains tag objects with tasks arrays)
|
|
* @param {Object} data - The data object to check
|
|
* @returns {boolean} True if the data has a tagged structure
|
|
*/
|
|
function hasTaggedStructure(data) {
|
|
if (!data || typeof data !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
// Check if any top-level properties are objects with tasks arrays
|
|
for (const key in data) {
|
|
if (
|
|
data.hasOwnProperty(key) &&
|
|
typeof data[key] === 'object' &&
|
|
Array.isArray(data[key].tasks)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Normalizes task IDs to ensure they are numbers instead of strings
|
|
* @param {Array} tasks - Array of tasks to normalize
|
|
*/
|
|
function normalizeTaskIds(tasks) {
|
|
if (!Array.isArray(tasks)) return;
|
|
|
|
tasks.forEach((task) => {
|
|
// Convert task ID to number with validation
|
|
if (task.id !== undefined) {
|
|
const parsedId = parseInt(task.id, 10);
|
|
if (!isNaN(parsedId) && parsedId > 0) {
|
|
task.id = parsedId;
|
|
}
|
|
}
|
|
|
|
// Convert subtask IDs to numbers with validation
|
|
if (Array.isArray(task.subtasks)) {
|
|
task.subtasks.forEach((subtask) => {
|
|
if (subtask.id !== undefined) {
|
|
// Check for dot notation (which shouldn't exist in storage)
|
|
if (typeof subtask.id === 'string' && subtask.id.includes('.')) {
|
|
// Extract the subtask part after the dot
|
|
const parts = subtask.id.split('.');
|
|
subtask.id = parseInt(parts[parts.length - 1], 10);
|
|
} else {
|
|
const parsedSubtaskId = parseInt(subtask.id, 10);
|
|
if (!isNaN(parsedSubtaskId) && parsedSubtaskId > 0) {
|
|
subtask.id = parsedSubtaskId;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reads and parses a JSON file
|
|
* @param {string} filepath - Path to the JSON file
|
|
* @param {string} [projectRoot] - Optional project root for tag resolution (used by MCP)
|
|
* @param {string} [tag] - Optional tag to use instead of current tag resolution
|
|
* @returns {Object|null} The parsed JSON data or null if error
|
|
*/
|
|
function readJSON(filepath, projectRoot = null, tag = null) {
|
|
// GUARD: Prevent circular dependency during config loading
|
|
let isDebug = false; // Default fallback
|
|
try {
|
|
// Only try to get debug flag if we're not in the middle of config loading
|
|
isDebug = getDebugFlag();
|
|
} catch (error) {
|
|
// If getDebugFlag() fails (likely due to circular dependency),
|
|
// use default false and continue
|
|
}
|
|
|
|
if (isDebug) {
|
|
console.log(
|
|
`readJSON called with: ${filepath}, projectRoot: ${projectRoot}, tag: ${tag}`
|
|
);
|
|
}
|
|
|
|
if (!filepath) {
|
|
return null;
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(fs.readFileSync(filepath, 'utf8'));
|
|
if (isDebug) {
|
|
console.log(`Successfully read JSON from ${filepath}`);
|
|
}
|
|
} catch (err) {
|
|
if (isDebug) {
|
|
console.log(`Failed to read JSON from ${filepath}: ${err.message}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// If it's not a tasks.json file, return as-is
|
|
if (!filepath.includes('tasks.json') || !data) {
|
|
if (isDebug) {
|
|
console.log(`File is not tasks.json or data is null, returning as-is`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// Check if this is legacy format that needs migration
|
|
// Only migrate if we have tasks at the ROOT level AND no tag-like structure
|
|
if (
|
|
Array.isArray(data.tasks) &&
|
|
!data._rawTaggedData &&
|
|
!hasTaggedStructure(data)
|
|
) {
|
|
if (isDebug) {
|
|
console.log(`File is in legacy format, performing migration...`);
|
|
}
|
|
|
|
normalizeTaskIds(data.tasks);
|
|
|
|
// This is legacy format - migrate it to tagged format
|
|
const migratedData = {
|
|
master: {
|
|
tasks: data.tasks,
|
|
metadata: data.metadata || {
|
|
created: new Date().toISOString(),
|
|
updated: new Date().toISOString(),
|
|
description: 'Tasks for master context'
|
|
}
|
|
}
|
|
};
|
|
|
|
// Write the migrated data back to the file
|
|
try {
|
|
writeJSON(filepath, migratedData);
|
|
if (isDebug) {
|
|
console.log(`Successfully migrated legacy format to tagged format`);
|
|
}
|
|
|
|
// Perform complete migration (config.json, state.json)
|
|
performCompleteTagMigration(filepath);
|
|
|
|
// Check and auto-switch git tags if enabled (after migration)
|
|
// This needs to run synchronously BEFORE tag resolution
|
|
if (projectRoot) {
|
|
try {
|
|
// Run git integration synchronously
|
|
gitUtils.checkAndAutoSwitchGitTagSync(projectRoot, filepath);
|
|
} catch (error) {
|
|
// Silent fail - don't break normal operations
|
|
}
|
|
}
|
|
|
|
// Mark for migration notice
|
|
markMigrationForNotice(filepath);
|
|
} catch (writeError) {
|
|
if (isDebug) {
|
|
console.log(`Error writing migrated data: ${writeError.message}`);
|
|
}
|
|
// If write fails, continue with the original data
|
|
}
|
|
|
|
// Continue processing with the migrated data structure
|
|
data = migratedData;
|
|
}
|
|
|
|
// If we have tagged data, we need to resolve which tag to use
|
|
if (typeof data === 'object' && !data.tasks) {
|
|
// This is tagged format
|
|
if (isDebug) {
|
|
console.log(`File is in tagged format, resolving tag...`);
|
|
}
|
|
|
|
// Ensure all tags have proper metadata before proceeding
|
|
for (const tagName in data) {
|
|
if (
|
|
data.hasOwnProperty(tagName) &&
|
|
typeof data[tagName] === 'object' &&
|
|
data[tagName].tasks
|
|
) {
|
|
try {
|
|
ensureTagMetadata(data[tagName], {
|
|
description: `Tasks for ${tagName} context`,
|
|
skipUpdate: true // Don't update timestamp during read operations
|
|
});
|
|
} catch (error) {
|
|
// If ensureTagMetadata fails, continue without metadata
|
|
if (isDebug) {
|
|
console.log(
|
|
`Failed to ensure metadata for tag ${tagName}: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store reference to the raw tagged data for functions that need it
|
|
const originalTaggedData = JSON.parse(JSON.stringify(data));
|
|
|
|
// Normalize IDs in all tags before storing as originalTaggedData
|
|
for (const tagName in originalTaggedData) {
|
|
if (
|
|
originalTaggedData[tagName] &&
|
|
Array.isArray(originalTaggedData[tagName].tasks)
|
|
) {
|
|
normalizeTaskIds(originalTaggedData[tagName].tasks);
|
|
}
|
|
}
|
|
|
|
// Check and auto-switch git tags if enabled (for existing tagged format)
|
|
// This needs to run synchronously BEFORE tag resolution
|
|
if (projectRoot) {
|
|
try {
|
|
// Run git integration synchronously
|
|
gitUtils.checkAndAutoSwitchGitTagSync(projectRoot, filepath);
|
|
} catch (error) {
|
|
// Silent fail - don't break normal operations
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Default to master tag if anything goes wrong
|
|
let resolvedTag = 'master';
|
|
|
|
// Try to resolve the correct tag, but don't fail if it doesn't work
|
|
try {
|
|
// If tag is provided, use it directly
|
|
if (tag) {
|
|
resolvedTag = tag;
|
|
} else if (projectRoot) {
|
|
// Use provided projectRoot
|
|
resolvedTag = resolveTag({ projectRoot });
|
|
} else {
|
|
// Try to derive projectRoot from filepath
|
|
const derivedProjectRoot = findProjectRoot(path.dirname(filepath));
|
|
if (derivedProjectRoot) {
|
|
resolvedTag = resolveTag({ projectRoot: derivedProjectRoot });
|
|
}
|
|
// If derivedProjectRoot is null, stick with 'master'
|
|
}
|
|
} catch (tagResolveError) {
|
|
if (isDebug) {
|
|
console.log(
|
|
`Tag resolution failed, using master: ${tagResolveError.message}`
|
|
);
|
|
}
|
|
// resolvedTag stays as 'master'
|
|
}
|
|
|
|
if (isDebug) {
|
|
console.log(`Resolved tag: ${resolvedTag}`);
|
|
}
|
|
|
|
// Get the data for the resolved tag
|
|
const tagData = data[resolvedTag];
|
|
if (tagData && tagData.tasks) {
|
|
normalizeTaskIds(tagData.tasks);
|
|
|
|
// Add the _rawTaggedData property and the resolved tag to the returned data
|
|
const result = {
|
|
...tagData,
|
|
tag: resolvedTag,
|
|
_rawTaggedData: originalTaggedData
|
|
};
|
|
if (isDebug) {
|
|
console.log(
|
|
`Returning data for tag '${resolvedTag}' with ${tagData.tasks.length} tasks`
|
|
);
|
|
}
|
|
return result;
|
|
} else {
|
|
// If the resolved tag doesn't exist, fall back to master
|
|
const masterData = data.master;
|
|
if (masterData && masterData.tasks) {
|
|
normalizeTaskIds(masterData.tasks);
|
|
|
|
if (isDebug) {
|
|
console.log(
|
|
`Tag '${resolvedTag}' not found, falling back to master with ${masterData.tasks.length} tasks`
|
|
);
|
|
}
|
|
return {
|
|
...masterData,
|
|
tag: 'master',
|
|
_rawTaggedData: originalTaggedData
|
|
};
|
|
} else {
|
|
if (isDebug) {
|
|
console.log(`No valid tag data found, returning empty structure`);
|
|
}
|
|
// Return empty structure if no valid data
|
|
return {
|
|
tasks: [],
|
|
tag: 'master',
|
|
_rawTaggedData: originalTaggedData
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (isDebug) {
|
|
console.log(`Error during tag resolution: ${error.message}`);
|
|
}
|
|
// If anything goes wrong, try to return master or empty
|
|
const masterData = data.master;
|
|
if (masterData && masterData.tasks) {
|
|
normalizeTaskIds(masterData.tasks);
|
|
return {
|
|
...masterData,
|
|
_rawTaggedData: originalTaggedData
|
|
};
|
|
}
|
|
return {
|
|
tasks: [],
|
|
_rawTaggedData: originalTaggedData
|
|
};
|
|
}
|
|
}
|
|
|
|
// If we reach here, it's some other format
|
|
if (isDebug) {
|
|
console.log(`File format not recognized, returning as-is`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Performs complete tag migration including config.json and state.json updates
|
|
* @param {string} tasksJsonPath - Path to the tasks.json file that was migrated
|
|
*/
|
|
function performCompleteTagMigration(tasksJsonPath) {
|
|
try {
|
|
// Derive project root from tasks.json path
|
|
const projectRoot =
|
|
findProjectRoot(path.dirname(tasksJsonPath)) ||
|
|
path.dirname(tasksJsonPath);
|
|
|
|
// 1. Migrate config.json - add defaultTag and tags section
|
|
const configPath = path.join(projectRoot, '.taskmaster', 'config.json');
|
|
if (fs.existsSync(configPath)) {
|
|
migrateConfigJson(configPath);
|
|
}
|
|
|
|
// 2. Create state.json if it doesn't exist
|
|
const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
|
|
if (!fs.existsSync(statePath)) {
|
|
createStateJson(statePath);
|
|
}
|
|
|
|
if (getDebugFlag()) {
|
|
log(
|
|
'debug',
|
|
`Complete tag migration performed for project: ${projectRoot}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (getDebugFlag()) {
|
|
log('warn', `Error during complete tag migration: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrates config.json to add tagged task system configuration
|
|
* @param {string} configPath - Path to the config.json file
|
|
*/
|
|
function migrateConfigJson(configPath) {
|
|
try {
|
|
const rawConfig = fs.readFileSync(configPath, 'utf8');
|
|
const config = JSON.parse(rawConfig);
|
|
if (!config) return;
|
|
|
|
let modified = false;
|
|
|
|
// Add global.defaultTag if missing
|
|
if (!config.global) {
|
|
config.global = {};
|
|
}
|
|
if (!config.global.defaultTag) {
|
|
config.global.defaultTag = 'master';
|
|
modified = true;
|
|
}
|
|
|
|
if (modified) {
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
if (process.env.TASKMASTER_DEBUG === 'true') {
|
|
console.log(
|
|
'[DEBUG] Updated config.json with tagged task system settings'
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (process.env.TASKMASTER_DEBUG === 'true') {
|
|
console.warn(`[WARN] Error migrating config.json: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates initial state.json file for tagged task system
|
|
* @param {string} statePath - Path where state.json should be created
|
|
*/
|
|
function createStateJson(statePath) {
|
|
try {
|
|
const initialState = {
|
|
currentTag: 'master',
|
|
lastSwitched: new Date().toISOString(),
|
|
branchTagMapping: {},
|
|
migrationNoticeShown: false
|
|
};
|
|
|
|
fs.writeFileSync(statePath, JSON.stringify(initialState, null, 2), 'utf8');
|
|
if (process.env.TASKMASTER_DEBUG === 'true') {
|
|
console.log('[DEBUG] Created initial state.json for tagged task system');
|
|
}
|
|
} catch (error) {
|
|
if (process.env.TASKMASTER_DEBUG === 'true') {
|
|
console.warn(`[WARN] Error creating state.json: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Marks in state.json that migration occurred and notice should be shown
|
|
* @param {string} tasksJsonPath - Path to the tasks.json file
|
|
*/
|
|
function markMigrationForNotice(tasksJsonPath) {
|
|
try {
|
|
const projectRoot = path.dirname(path.dirname(tasksJsonPath));
|
|
const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
|
|
|
|
// Ensure state.json exists
|
|
if (!fs.existsSync(statePath)) {
|
|
createStateJson(statePath);
|
|
}
|
|
|
|
// Read and update state to mark migration occurred using fs directly
|
|
try {
|
|
const rawState = fs.readFileSync(statePath, 'utf8');
|
|
const stateData = JSON.parse(rawState) || {};
|
|
// Only set to false if it's not already set (i.e., first time migration)
|
|
if (stateData.migrationNoticeShown === undefined) {
|
|
stateData.migrationNoticeShown = false;
|
|
fs.writeFileSync(statePath, JSON.stringify(stateData, null, 2), 'utf8');
|
|
}
|
|
} catch (stateError) {
|
|
if (process.env.TASKMASTER_DEBUG === 'true') {
|
|
console.warn(
|
|
`[WARN] Error updating state for migration notice: ${stateError.message}`
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (process.env.TASKMASTER_DEBUG === 'true') {
|
|
console.warn(
|
|
`[WARN] Error marking migration for notice: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes and saves a JSON file. Handles tagged task lists properly.
|
|
* @param {string} filepath - Path to the JSON file
|
|
* @param {Object} data - Data to write (can be resolved tag data or raw tagged data)
|
|
* @param {string} projectRoot - Optional project root for tag context
|
|
* @param {string} tag - Optional tag for tag context
|
|
*/
|
|
function writeJSON(filepath, data, projectRoot = null, tag = null) {
|
|
const isDebug = process.env.TASKMASTER_DEBUG === 'true';
|
|
|
|
try {
|
|
let finalData = data;
|
|
|
|
// If data represents resolved tag data but lost _rawTaggedData (edge-case observed in MCP path)
|
|
if (
|
|
!data._rawTaggedData &&
|
|
projectRoot &&
|
|
Array.isArray(data.tasks) &&
|
|
!hasTaggedStructure(data)
|
|
) {
|
|
const resolvedTag = tag || getCurrentTag(projectRoot);
|
|
|
|
if (isDebug) {
|
|
console.log(
|
|
`writeJSON: Detected resolved tag data missing _rawTaggedData. Re-reading raw data to prevent data loss for tag '${resolvedTag}'.`
|
|
);
|
|
}
|
|
|
|
// Re-read the full file to get the complete tagged structure
|
|
const rawFullData = JSON.parse(fs.readFileSync(filepath, 'utf8'));
|
|
|
|
// Merge the updated data into the full structure
|
|
finalData = {
|
|
...rawFullData,
|
|
[resolvedTag]: {
|
|
// Preserve existing tag metadata if it exists, otherwise use what's passed
|
|
...(rawFullData[resolvedTag]?.metadata || {}),
|
|
...(data.metadata ? { metadata: data.metadata } : {}),
|
|
tasks: data.tasks // The updated tasks array is the source of truth here
|
|
}
|
|
};
|
|
}
|
|
// If we have _rawTaggedData, this means we're working with resolved tag data
|
|
// and need to merge it back into the full tagged structure
|
|
else if (data && data._rawTaggedData && projectRoot) {
|
|
const resolvedTag = tag || getCurrentTag(projectRoot);
|
|
|
|
// Get the original tagged data
|
|
const originalTaggedData = data._rawTaggedData;
|
|
|
|
// Create a clean copy of the current resolved data (without internal properties)
|
|
const { _rawTaggedData, tag: _, ...cleanResolvedData } = data;
|
|
|
|
// Update the specific tag with the resolved data
|
|
finalData = {
|
|
...originalTaggedData,
|
|
[resolvedTag]: cleanResolvedData
|
|
};
|
|
|
|
if (isDebug) {
|
|
console.log(
|
|
`writeJSON: Merging resolved data back into tag '${resolvedTag}'`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Clean up any internal properties that shouldn't be persisted
|
|
let cleanData = finalData;
|
|
if (cleanData && typeof cleanData === 'object') {
|
|
// Remove any _rawTaggedData or tag properties from root level
|
|
const { _rawTaggedData, tag: tagProp, ...rootCleanData } = cleanData;
|
|
cleanData = rootCleanData;
|
|
|
|
// Additional cleanup for tag objects
|
|
if (typeof cleanData === 'object' && !Array.isArray(cleanData)) {
|
|
const finalCleanData = {};
|
|
for (const [key, value] of Object.entries(cleanData)) {
|
|
if (
|
|
value &&
|
|
typeof value === 'object' &&
|
|
Array.isArray(value.tasks)
|
|
) {
|
|
// This is a tag object - clean up any rogue root-level properties
|
|
const { created, description, ...cleanTagData } = value;
|
|
|
|
// Only keep the description if there's no metadata.description
|
|
if (
|
|
description &&
|
|
(!cleanTagData.metadata || !cleanTagData.metadata.description)
|
|
) {
|
|
cleanTagData.description = description;
|
|
}
|
|
|
|
finalCleanData[key] = cleanTagData;
|
|
} else {
|
|
finalCleanData[key] = value;
|
|
}
|
|
}
|
|
cleanData = finalCleanData;
|
|
}
|
|
}
|
|
|
|
fs.writeFileSync(filepath, JSON.stringify(cleanData, null, 2), 'utf8');
|
|
|
|
if (isDebug) {
|
|
console.log(`writeJSON: Successfully wrote to ${filepath}`);
|
|
}
|
|
} catch (error) {
|
|
log('error', `Error writing JSON file ${filepath}:`, error.message);
|
|
if (isDebug) {
|
|
log('error', 'Full error details:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a prompt string for use in a shell command
|
|
* @param {string} prompt The prompt to sanitize
|
|
* @returns {string} Sanitized prompt
|
|
*/
|
|
function sanitizePrompt(prompt) {
|
|
// Replace double quotes with escaped double quotes
|
|
return prompt.replace(/"/g, '\\"');
|
|
}
|
|
|
|
/**
|
|
* Reads the complexity report from file
|
|
* @param {string} customPath - Optional custom path to the report
|
|
* @returns {Object|null} The parsed complexity report or null if not found
|
|
*/
|
|
function readComplexityReport(customPath = null) {
|
|
// GUARD: Prevent circular dependency during config loading
|
|
let isDebug = false; // Default fallback
|
|
try {
|
|
// Only try to get debug flag if we're not in the middle of config loading
|
|
isDebug = getDebugFlag();
|
|
} catch (error) {
|
|
// If getDebugFlag() fails (likely due to circular dependency),
|
|
// use default false and continue
|
|
isDebug = false;
|
|
}
|
|
|
|
try {
|
|
let reportPath;
|
|
if (customPath) {
|
|
reportPath = customPath;
|
|
} else {
|
|
// Try new location first, then fall back to legacy
|
|
const newPath = path.join(process.cwd(), COMPLEXITY_REPORT_FILE);
|
|
const legacyPath = path.join(
|
|
process.cwd(),
|
|
LEGACY_COMPLEXITY_REPORT_FILE
|
|
);
|
|
|
|
reportPath = fs.existsSync(newPath) ? newPath : legacyPath;
|
|
}
|
|
|
|
if (!fs.existsSync(reportPath)) {
|
|
if (isDebug) {
|
|
log('debug', `Complexity report not found at ${reportPath}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const reportData = readJSON(reportPath);
|
|
if (isDebug) {
|
|
log('debug', `Successfully read complexity report from ${reportPath}`);
|
|
}
|
|
return reportData;
|
|
} catch (error) {
|
|
if (isDebug) {
|
|
log('error', `Error reading complexity report: ${error.message}`);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds a task analysis in the complexity report
|
|
* @param {Object} report - The complexity report
|
|
* @param {number} taskId - The task ID to find
|
|
* @returns {Object|null} The task analysis or null if not found
|
|
*/
|
|
function findTaskInComplexityReport(report, taskId) {
|
|
if (
|
|
!report ||
|
|
!report.complexityAnalysis ||
|
|
!Array.isArray(report.complexityAnalysis)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return report.complexityAnalysis.find((task) => task.taskId === taskId);
|
|
}
|
|
|
|
function addComplexityToTask(task, complexityReport) {
|
|
let taskId;
|
|
if (task.isSubtask) {
|
|
taskId = task.parentTask.id;
|
|
} else if (task.parentId) {
|
|
taskId = task.parentId;
|
|
} else {
|
|
taskId = task.id;
|
|
}
|
|
|
|
const taskAnalysis = findTaskInComplexityReport(complexityReport, taskId);
|
|
if (taskAnalysis) {
|
|
task.complexityScore = taskAnalysis.complexityScore;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a task exists in the tasks array
|
|
* @param {Array} tasks - The tasks array
|
|
* @param {string|number} taskId - The task ID to check
|
|
* @returns {boolean} True if the task exists, false otherwise
|
|
*/
|
|
function taskExists(tasks, taskId) {
|
|
if (!taskId || !tasks || !Array.isArray(tasks)) {
|
|
return false;
|
|
}
|
|
|
|
// Handle both regular task IDs and subtask IDs (e.g., "1.2")
|
|
if (typeof taskId === 'string' && taskId.includes('.')) {
|
|
const [parentId, subtaskId] = taskId
|
|
.split('.')
|
|
.map((id) => parseInt(id, 10));
|
|
const parentTask = tasks.find((t) => t.id === parentId);
|
|
|
|
if (!parentTask || !parentTask.subtasks) {
|
|
return false;
|
|
}
|
|
|
|
return parentTask.subtasks.some((st) => st.id === subtaskId);
|
|
}
|
|
|
|
const id = parseInt(taskId, 10);
|
|
return tasks.some((t) => t.id === id);
|
|
}
|
|
|
|
/**
|
|
* Formats a task ID as a string
|
|
* @param {string|number} id - The task ID to format
|
|
* @returns {string} The formatted task ID
|
|
*/
|
|
function formatTaskId(id) {
|
|
if (typeof id === 'string' && id.includes('.')) {
|
|
return id; // Already formatted as a string with a dot (e.g., "1.2")
|
|
}
|
|
|
|
if (typeof id === 'number') {
|
|
return id.toString();
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Finds a task by ID in the tasks array. Optionally filters subtasks by status.
|
|
* @param {Array} tasks - The tasks array
|
|
* @param {string|number} taskId - The task ID to find
|
|
* @param {Object|null} complexityReport - Optional pre-loaded complexity report
|
|
* @param {string} [statusFilter] - Optional status to filter subtasks by
|
|
* @returns {{task: Object|null, originalSubtaskCount: number|null, originalSubtasks: Array|null}} The task object (potentially with filtered subtasks), the original subtask count, and original subtasks array if filtered, or nulls if not found.
|
|
*/
|
|
function findTaskById(
|
|
tasks,
|
|
taskId,
|
|
complexityReport = null,
|
|
statusFilter = null
|
|
) {
|
|
if (!taskId || !tasks || !Array.isArray(tasks)) {
|
|
return { task: null, originalSubtaskCount: null };
|
|
}
|
|
|
|
// Check if it's a subtask ID (e.g., "1.2")
|
|
if (typeof taskId === 'string' && taskId.includes('.')) {
|
|
// If looking for a subtask, statusFilter doesn't apply directly here.
|
|
const [parentId, subtaskId] = taskId
|
|
.split('.')
|
|
.map((id) => parseInt(id, 10));
|
|
const parentTask = tasks.find((t) => t.id === parentId);
|
|
|
|
if (!parentTask || !parentTask.subtasks) {
|
|
return { task: null, originalSubtaskCount: null, originalSubtasks: null };
|
|
}
|
|
|
|
const subtask = parentTask.subtasks.find((st) => st.id === subtaskId);
|
|
if (subtask) {
|
|
// Add reference to parent task for context
|
|
subtask.parentTask = {
|
|
id: parentTask.id,
|
|
title: parentTask.title,
|
|
status: parentTask.status
|
|
};
|
|
subtask.isSubtask = true;
|
|
}
|
|
|
|
// If we found a task, check for complexity data
|
|
if (subtask && complexityReport) {
|
|
addComplexityToTask(subtask, complexityReport);
|
|
}
|
|
|
|
return {
|
|
task: subtask || null,
|
|
originalSubtaskCount: null,
|
|
originalSubtasks: null
|
|
};
|
|
}
|
|
|
|
let taskResult = null;
|
|
let originalSubtaskCount = null;
|
|
let originalSubtasks = null;
|
|
|
|
// Find the main task
|
|
const id = parseInt(taskId, 10);
|
|
const task = tasks.find((t) => t.id === id) || null;
|
|
|
|
// If task not found, return nulls
|
|
if (!task) {
|
|
return { task: null, originalSubtaskCount: null, originalSubtasks: null };
|
|
}
|
|
|
|
taskResult = task;
|
|
|
|
// If task found and statusFilter provided, filter its subtasks
|
|
if (statusFilter && task.subtasks && Array.isArray(task.subtasks)) {
|
|
// Store original subtasks and count before filtering
|
|
originalSubtasks = [...task.subtasks]; // Clone the original subtasks array
|
|
originalSubtaskCount = task.subtasks.length;
|
|
|
|
// Clone the task to avoid modifying the original array
|
|
const filteredTask = { ...task };
|
|
filteredTask.subtasks = task.subtasks.filter(
|
|
(subtask) =>
|
|
subtask.status &&
|
|
subtask.status.toLowerCase() === statusFilter.toLowerCase()
|
|
);
|
|
|
|
taskResult = filteredTask;
|
|
}
|
|
|
|
// If task found and complexityReport provided, add complexity data
|
|
if (taskResult && complexityReport) {
|
|
addComplexityToTask(taskResult, complexityReport);
|
|
}
|
|
|
|
// Return the found task, original subtask count, and original subtasks
|
|
return { task: taskResult, originalSubtaskCount, originalSubtasks };
|
|
}
|
|
|
|
/**
|
|
* Truncates text to a specified length
|
|
* @param {string} text - The text to truncate
|
|
* @param {number} maxLength - The maximum length
|
|
* @returns {string} The truncated text
|
|
*/
|
|
function truncate(text, maxLength) {
|
|
if (!text || text.length <= maxLength) {
|
|
return text;
|
|
}
|
|
|
|
return `${text.slice(0, maxLength - 3)}...`;
|
|
}
|
|
|
|
/**
|
|
* Checks if array or object are empty
|
|
* @param {*} value - The value to check
|
|
* @returns {boolean} True if empty, false otherwise
|
|
*/
|
|
function isEmpty(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.length === 0;
|
|
} else if (typeof value === 'object' && value !== null) {
|
|
return Object.keys(value).length === 0;
|
|
}
|
|
|
|
return false; // Not an array or object, or is null
|
|
}
|
|
|
|
/**
|
|
* Find cycles in a dependency graph using DFS
|
|
* @param {string} subtaskId - Current subtask ID
|
|
* @param {Map} dependencyMap - Map of subtask IDs to their dependencies
|
|
* @param {Set} visited - Set of visited nodes
|
|
* @param {Set} recursionStack - Set of nodes in current recursion stack
|
|
* @returns {Array} - List of dependency edges that need to be removed to break cycles
|
|
*/
|
|
function findCycles(
|
|
subtaskId,
|
|
dependencyMap,
|
|
visited = new Set(),
|
|
recursionStack = new Set(),
|
|
path = []
|
|
) {
|
|
// Mark the current node as visited and part of recursion stack
|
|
visited.add(subtaskId);
|
|
recursionStack.add(subtaskId);
|
|
path.push(subtaskId);
|
|
|
|
const cyclesToBreak = [];
|
|
|
|
// Get all dependencies of the current subtask
|
|
const dependencies = dependencyMap.get(subtaskId) || [];
|
|
|
|
// For each dependency
|
|
for (const depId of dependencies) {
|
|
// If not visited, recursively check for cycles
|
|
if (!visited.has(depId)) {
|
|
const cycles = findCycles(depId, dependencyMap, visited, recursionStack, [
|
|
...path
|
|
]);
|
|
cyclesToBreak.push(...cycles);
|
|
}
|
|
// If the dependency is in the recursion stack, we found a cycle
|
|
else if (recursionStack.has(depId)) {
|
|
// Find the position of the dependency in the path
|
|
const cycleStartIndex = path.indexOf(depId);
|
|
// The last edge in the cycle is what we want to remove
|
|
const cycleEdges = path.slice(cycleStartIndex);
|
|
// We'll remove the last edge in the cycle (the one that points back)
|
|
cyclesToBreak.push(depId);
|
|
}
|
|
}
|
|
|
|
// Remove the node from recursion stack before returning
|
|
recursionStack.delete(subtaskId);
|
|
|
|
return cyclesToBreak;
|
|
}
|
|
|
|
/**
|
|
* Unified dependency traversal utility that supports both forward and reverse dependency traversal
|
|
* @param {Array} sourceTasks - Array of source tasks to start traversal from
|
|
* @param {Array} allTasks - Array of all tasks to search within
|
|
* @param {Object} options - Configuration options
|
|
* @param {number} options.maxDepth - Maximum recursion depth (default: 50)
|
|
* @param {boolean} options.includeSelf - Whether to include self-references (default: false)
|
|
* @param {'forward'|'reverse'} options.direction - Direction of traversal (default: 'forward')
|
|
* @param {Function} options.logger - Optional logger function for warnings
|
|
* @returns {Array} Array of all dependency task IDs found through traversal
|
|
*/
|
|
function traverseDependencies(sourceTasks, allTasks, options = {}) {
|
|
const {
|
|
maxDepth = 50,
|
|
includeSelf = false,
|
|
direction = 'forward',
|
|
logger = null
|
|
} = options;
|
|
|
|
const dependentTaskIds = new Set();
|
|
const processedIds = new Set();
|
|
|
|
// Helper function to normalize dependency IDs while preserving subtask format
|
|
function normalizeDependencyId(depId) {
|
|
if (typeof depId === 'string') {
|
|
// Preserve string format for subtask IDs like "1.2"
|
|
if (depId.includes('.')) {
|
|
return depId;
|
|
}
|
|
// Convert simple string numbers to numbers for consistency
|
|
const parsed = parseInt(depId, 10);
|
|
return isNaN(parsed) ? depId : parsed;
|
|
}
|
|
return depId;
|
|
}
|
|
|
|
// Helper function for forward dependency traversal
|
|
function findForwardDependencies(taskId, currentDepth = 0) {
|
|
// Check depth limit
|
|
if (currentDepth >= maxDepth) {
|
|
const warnMsg = `Maximum recursion depth (${maxDepth}) reached for task ${taskId}`;
|
|
if (logger && typeof logger.warn === 'function') {
|
|
logger.warn(warnMsg);
|
|
} else if (typeof log !== 'undefined' && log.warn) {
|
|
log.warn(warnMsg);
|
|
} else {
|
|
console.warn(warnMsg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (processedIds.has(taskId)) {
|
|
return; // Avoid infinite loops
|
|
}
|
|
processedIds.add(taskId);
|
|
|
|
const task = allTasks.find((t) => t.id === taskId);
|
|
if (!task || !Array.isArray(task.dependencies)) {
|
|
return;
|
|
}
|
|
|
|
task.dependencies.forEach((depId) => {
|
|
const normalizedDepId = normalizeDependencyId(depId);
|
|
|
|
// Skip invalid dependencies and optionally skip self-references
|
|
if (
|
|
normalizedDepId == null ||
|
|
(!includeSelf && normalizedDepId === taskId)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
dependentTaskIds.add(normalizedDepId);
|
|
// Recursively find dependencies of this dependency
|
|
findForwardDependencies(normalizedDepId, currentDepth + 1);
|
|
});
|
|
}
|
|
|
|
// Helper function for reverse dependency traversal
|
|
function findReverseDependencies(taskId, currentDepth = 0) {
|
|
// Check depth limit
|
|
if (currentDepth >= maxDepth) {
|
|
const warnMsg = `Maximum recursion depth (${maxDepth}) reached for task ${taskId}`;
|
|
if (logger && typeof logger.warn === 'function') {
|
|
logger.warn(warnMsg);
|
|
} else if (typeof log !== 'undefined' && log.warn) {
|
|
log.warn(warnMsg);
|
|
} else {
|
|
console.warn(warnMsg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (processedIds.has(taskId)) {
|
|
return; // Avoid infinite loops
|
|
}
|
|
processedIds.add(taskId);
|
|
|
|
allTasks.forEach((task) => {
|
|
if (task.dependencies && Array.isArray(task.dependencies)) {
|
|
const dependsOnTaskId = task.dependencies.some((depId) => {
|
|
const normalizedDepId = normalizeDependencyId(depId);
|
|
return normalizedDepId === taskId;
|
|
});
|
|
|
|
if (dependsOnTaskId) {
|
|
// Skip invalid dependencies and optionally skip self-references
|
|
if (task.id == null || (!includeSelf && task.id === taskId)) {
|
|
return;
|
|
}
|
|
|
|
dependentTaskIds.add(task.id);
|
|
// Recursively find tasks that depend on this task
|
|
findReverseDependencies(task.id, currentDepth + 1);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Choose traversal function based on direction
|
|
const traversalFunc =
|
|
direction === 'reverse' ? findReverseDependencies : findForwardDependencies;
|
|
|
|
// Start traversal from each source task
|
|
sourceTasks.forEach((sourceTask) => {
|
|
if (sourceTask && sourceTask.id) {
|
|
traversalFunc(sourceTask.id);
|
|
}
|
|
});
|
|
|
|
return Array.from(dependentTaskIds);
|
|
}
|
|
|
|
/**
|
|
* Convert a string from camelCase to kebab-case
|
|
* @param {string} str - The string to convert
|
|
* @returns {string} The kebab-case version of the string
|
|
*/
|
|
const toKebabCase = (str) => {
|
|
// Special handling for common acronyms
|
|
const withReplacedAcronyms = str
|
|
.replace(/ID/g, 'Id')
|
|
.replace(/API/g, 'Api')
|
|
.replace(/UI/g, 'Ui')
|
|
.replace(/URL/g, 'Url')
|
|
.replace(/URI/g, 'Uri')
|
|
.replace(/JSON/g, 'Json')
|
|
.replace(/XML/g, 'Xml')
|
|
.replace(/HTML/g, 'Html')
|
|
.replace(/CSS/g, 'Css');
|
|
|
|
// Insert hyphens before capital letters and convert to lowercase
|
|
return withReplacedAcronyms
|
|
.replace(/([A-Z])/g, '-$1')
|
|
.toLowerCase()
|
|
.replace(/^-/, ''); // Remove leading hyphen if present
|
|
};
|
|
|
|
/**
|
|
* Detect camelCase flags in command arguments
|
|
* @param {string[]} args - Command line arguments to check
|
|
* @returns {Array<{original: string, kebabCase: string}>} - List of flags that should be converted
|
|
*/
|
|
function detectCamelCaseFlags(args) {
|
|
const camelCaseFlags = [];
|
|
for (const arg of args) {
|
|
if (arg.startsWith('--')) {
|
|
const flagName = arg.split('=')[0].slice(2); // Remove -- and anything after =
|
|
|
|
// Skip single-word flags - they can't be camelCase
|
|
if (!flagName.includes('-') && !/[A-Z]/.test(flagName)) {
|
|
continue;
|
|
}
|
|
|
|
// Check for camelCase pattern (lowercase followed by uppercase)
|
|
if (/[a-z][A-Z]/.test(flagName)) {
|
|
const kebabVersion = toKebabCase(flagName);
|
|
if (kebabVersion !== flagName) {
|
|
camelCaseFlags.push({
|
|
original: flagName,
|
|
kebabCase: kebabVersion
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return camelCaseFlags;
|
|
}
|
|
|
|
/**
|
|
* Aggregates an array of telemetry objects into a single summary object.
|
|
* @param {Array<Object>} telemetryArray - Array of telemetryData objects.
|
|
* @param {string} overallCommandName - The name for the aggregated command.
|
|
* @returns {Object|null} Aggregated telemetry object or null if input is empty.
|
|
*/
|
|
function aggregateTelemetry(telemetryArray, overallCommandName) {
|
|
if (!telemetryArray || telemetryArray.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const aggregated = {
|
|
timestamp: new Date().toISOString(), // Use current time for aggregation time
|
|
userId: telemetryArray[0].userId, // Assume userId is consistent
|
|
commandName: overallCommandName,
|
|
modelUsed: 'Multiple', // Default if models vary
|
|
providerName: 'Multiple', // Default if providers vary
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
totalTokens: 0,
|
|
totalCost: 0,
|
|
currency: telemetryArray[0].currency || 'USD' // Assume consistent currency or default
|
|
};
|
|
|
|
const uniqueModels = new Set();
|
|
const uniqueProviders = new Set();
|
|
const uniqueCurrencies = new Set();
|
|
|
|
telemetryArray.forEach((item) => {
|
|
aggregated.inputTokens += item.inputTokens || 0;
|
|
aggregated.outputTokens += item.outputTokens || 0;
|
|
aggregated.totalCost += item.totalCost || 0;
|
|
uniqueModels.add(item.modelUsed);
|
|
uniqueProviders.add(item.providerName);
|
|
uniqueCurrencies.add(item.currency || 'USD');
|
|
});
|
|
|
|
aggregated.totalTokens = aggregated.inputTokens + aggregated.outputTokens;
|
|
aggregated.totalCost = parseFloat(aggregated.totalCost.toFixed(6)); // Fix precision
|
|
|
|
if (uniqueModels.size === 1) {
|
|
aggregated.modelUsed = [...uniqueModels][0];
|
|
}
|
|
if (uniqueProviders.size === 1) {
|
|
aggregated.providerName = [...uniqueProviders][0];
|
|
}
|
|
if (uniqueCurrencies.size > 1) {
|
|
aggregated.currency = 'Multiple'; // Mark if currencies actually differ
|
|
} else if (uniqueCurrencies.size === 1) {
|
|
aggregated.currency = [...uniqueCurrencies][0];
|
|
}
|
|
|
|
return aggregated;
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
*/
|
|
function getCurrentTag(projectRoot) {
|
|
if (!projectRoot) {
|
|
throw new Error('projectRoot is required for getCurrentTag');
|
|
}
|
|
|
|
try {
|
|
// Try to read current tag from state.json using fs directly
|
|
const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
|
|
if (fs.existsSync(statePath)) {
|
|
const rawState = fs.readFileSync(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 {
|
|
const configPath = path.join(projectRoot, '.taskmaster', 'config.json');
|
|
if (fs.existsSync(configPath)) {
|
|
const rawConfig = fs.readFileSync(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';
|
|
}
|
|
|
|
/**
|
|
* Resolves the tag to use based on options
|
|
* @param {Object} options - Options object
|
|
* @param {string} options.projectRoot - The project root directory (required)
|
|
* @param {string} [options.tag] - Explicit tag to use
|
|
* @returns {string} The resolved tag name
|
|
*/
|
|
function resolveTag(options = {}) {
|
|
const { projectRoot, tag } = options;
|
|
|
|
if (!projectRoot) {
|
|
throw new Error('projectRoot is required for resolveTag');
|
|
}
|
|
|
|
// If explicit tag provided, use it
|
|
if (tag) {
|
|
return tag;
|
|
}
|
|
|
|
// Otherwise get current tag from state/config
|
|
return getCurrentTag(projectRoot);
|
|
}
|
|
|
|
/**
|
|
* Gets the tasks array for a specific tag from tagged tasks.json data
|
|
* @param {Object} data - The parsed tasks.json data (after migration)
|
|
* @param {string} tagName - The tag name to get tasks for
|
|
* @returns {Array} The tasks array for the specified tag, or empty array if not found
|
|
*/
|
|
function getTasksForTag(data, tagName) {
|
|
if (!data || !tagName) {
|
|
return [];
|
|
}
|
|
|
|
// Handle migrated format: { "master": { "tasks": [...] }, "otherTag": { "tasks": [...] } }
|
|
if (
|
|
data[tagName] &&
|
|
data[tagName].tasks &&
|
|
Array.isArray(data[tagName].tasks)
|
|
) {
|
|
return data[tagName].tasks;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Sets the tasks array for a specific tag in the data structure
|
|
* @param {Object} data - The tasks.json data object
|
|
* @param {string} tagName - The tag name to set tasks for
|
|
* @param {Array} tasks - The tasks array to set
|
|
* @returns {Object} The updated data object
|
|
*/
|
|
function setTasksForTag(data, tagName, tasks) {
|
|
if (!data) {
|
|
data = {};
|
|
}
|
|
|
|
if (!data[tagName]) {
|
|
data[tagName] = {};
|
|
}
|
|
|
|
data[tagName].tasks = tasks || [];
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Flatten tasks array to include subtasks as individual searchable items
|
|
* @param {Array} tasks - Array of task objects
|
|
* @returns {Array} Flattened array including both tasks and subtasks
|
|
*/
|
|
function flattenTasksWithSubtasks(tasks) {
|
|
const flattened = [];
|
|
|
|
for (const task of tasks) {
|
|
// Add the main task
|
|
flattened.push({
|
|
...task,
|
|
searchableId: task.id.toString(), // For consistent ID handling
|
|
isSubtask: false
|
|
});
|
|
|
|
// Add subtasks if they exist
|
|
if (task.subtasks && task.subtasks.length > 0) {
|
|
for (const subtask of task.subtasks) {
|
|
flattened.push({
|
|
...subtask,
|
|
searchableId: `${task.id}.${subtask.id}`, // Format: "15.2"
|
|
isSubtask: true,
|
|
parentId: task.id,
|
|
parentTitle: task.title,
|
|
// Enhance subtask context with parent information
|
|
title: `${subtask.title} (subtask of: ${task.title})`,
|
|
description: `${subtask.description} [Parent: ${task.description}]`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return flattened;
|
|
}
|
|
|
|
/**
|
|
* Ensures the tag object has a metadata object with created/updated timestamps.
|
|
* @param {Object} tagObj - The tag object (e.g., data['master'])
|
|
* @param {Object} [opts] - Optional fields (e.g., description, skipUpdate)
|
|
* @param {string} [opts.description] - Description for the tag
|
|
* @param {boolean} [opts.skipUpdate] - If true, don't update the 'updated' timestamp
|
|
* @returns {Object} The updated tag object (for chaining)
|
|
*/
|
|
function ensureTagMetadata(tagObj, opts = {}) {
|
|
if (!tagObj || typeof tagObj !== 'object') {
|
|
throw new Error('tagObj must be a valid object');
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
if (!tagObj.metadata) {
|
|
// Create new metadata object
|
|
tagObj.metadata = {
|
|
created: now,
|
|
updated: now,
|
|
...(opts.description ? { description: opts.description } : {})
|
|
};
|
|
} else {
|
|
// Ensure existing metadata has required fields
|
|
if (!tagObj.metadata.created) {
|
|
tagObj.metadata.created = now;
|
|
}
|
|
|
|
// Update timestamp unless explicitly skipped
|
|
if (!opts.skipUpdate) {
|
|
tagObj.metadata.updated = now;
|
|
}
|
|
|
|
// Add description if provided and not already present
|
|
if (opts.description && !tagObj.metadata.description) {
|
|
tagObj.metadata.description = opts.description;
|
|
}
|
|
}
|
|
|
|
return tagObj;
|
|
}
|
|
|
|
/**
|
|
* Strip ANSI color codes from a string
|
|
* Useful for testing, logging to files, or when clean text output is needed
|
|
* @param {string} text - The text that may contain ANSI color codes
|
|
* @returns {string} - The text with ANSI color codes removed
|
|
*/
|
|
function stripAnsiCodes(text) {
|
|
if (typeof text !== 'string') {
|
|
return text;
|
|
}
|
|
// Remove ANSI escape sequences (color codes, cursor movements, etc.)
|
|
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
}
|
|
|
|
// Export all utility functions and configuration
|
|
export {
|
|
LOG_LEVELS,
|
|
log,
|
|
readJSON,
|
|
writeJSON,
|
|
sanitizePrompt,
|
|
readComplexityReport,
|
|
findTaskInComplexityReport,
|
|
taskExists,
|
|
formatTaskId,
|
|
findTaskById,
|
|
truncate,
|
|
isEmpty,
|
|
findCycles,
|
|
traverseDependencies,
|
|
toKebabCase,
|
|
detectCamelCaseFlags,
|
|
disableSilentMode,
|
|
enableSilentMode,
|
|
getTaskManager,
|
|
isSilentMode,
|
|
addComplexityToTask,
|
|
resolveEnvVariable,
|
|
findProjectRoot,
|
|
getTagAwareFilePath,
|
|
slugifyTagForFilePath,
|
|
aggregateTelemetry,
|
|
getCurrentTag,
|
|
resolveTag,
|
|
getTasksForTag,
|
|
setTasksForTag,
|
|
performCompleteTagMigration,
|
|
migrateConfigJson,
|
|
createStateJson,
|
|
markMigrationForNotice,
|
|
flattenTasksWithSubtasks,
|
|
ensureTagMetadata,
|
|
stripAnsiCodes,
|
|
normalizeTaskIds
|
|
};
|