mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-07-04 07:26:38 +00:00

* Feat: Implemented advanced settings for Claude Code AI provider - Added new 'claudeCode' property to default config - Added getters and validation functions to 'config-manager.js' - Added new 'isEmpty' utility to 'utils.js' - Added new constants file 'commands.js' for AI_COMMAND_NAMES - Updated Claude Code AI provider to use new config functions - Updated 'claude-code-usage.md' documentation - Added 'config-manager.test.js' tests to cover new settings * chore: run format --------- Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
1416 lines
40 KiB
JavaScript
1416 lines
40 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;
|
|
}
|
|
|
|
/**
|
|
* 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...`);
|
|
}
|
|
|
|
// 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));
|
|
|
|
// 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) {
|
|
// 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) {
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Export all utility functions and configuration
|
|
export {
|
|
LOG_LEVELS,
|
|
log,
|
|
readJSON,
|
|
writeJSON,
|
|
sanitizePrompt,
|
|
readComplexityReport,
|
|
findTaskInComplexityReport,
|
|
taskExists,
|
|
formatTaskId,
|
|
findTaskById,
|
|
truncate,
|
|
isEmpty,
|
|
findCycles,
|
|
toKebabCase,
|
|
detectCamelCaseFlags,
|
|
disableSilentMode,
|
|
enableSilentMode,
|
|
getTaskManager,
|
|
isSilentMode,
|
|
addComplexityToTask,
|
|
resolveEnvVariable,
|
|
findProjectRoot,
|
|
getTagAwareFilePath,
|
|
slugifyTagForFilePath,
|
|
aggregateTelemetry,
|
|
getCurrentTag,
|
|
resolveTag,
|
|
getTasksForTag,
|
|
setTasksForTag,
|
|
performCompleteTagMigration,
|
|
migrateConfigJson,
|
|
createStateJson,
|
|
markMigrationForNotice,
|
|
flattenTasksWithSubtasks,
|
|
ensureTagMetadata
|
|
};
|