fix(core): Fixed move-task.js writing _rawTaggedData directly, updated writeJSON to filter tag fields, fixed CLI move command missing projectRoot, added ensureTagMetadata utility

This commit is contained in:
Eyal Toledano
2025-06-12 18:12:53 -04:00
parent 75e017e371
commit 9efbd38f10
15 changed files with 589 additions and 40487 deletions

View File

@@ -1,17 +0,0 @@
---
'task-master-ai': minor
---
Add comprehensive `research` MCP tool for AI-powered research queries
- **New MCP Tool**: `research` tool enables AI-powered research with project context
- **Context Integration**: Supports task IDs, file paths, custom context, and project tree
- **Fuzzy Task Discovery**: Automatically finds relevant tasks using semantic search
- **Token Management**: Detailed token counting and breakdown by context type
- **Multiple Detail Levels**: Support for low, medium, and high detail research responses
- **Telemetry Integration**: Full cost tracking and usage analytics
- **Direct Function**: `researchDirect` with comprehensive parameter validation
- **Silent Mode**: Prevents console output interference with MCP JSON responses
- **Error Handling**: Robust error handling with proper MCP response formatting
This completes subtasks 94.5 (Direct Function) and 94.6 (MCP Tool) for the research command implementation, providing a powerful research interface for integrated development environments like Cursor.

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": patch
---
Improves dependency management when moving tasks by updating subtask dependencies that reference sibling subtasks by their old parent-based ID

View File

@@ -0,0 +1,62 @@
# Task ID: 106
# Title: Implement Fun Easter Egg Commands for Developer Delight
# Status: pending
# Dependencies: 2, 4
# Priority: medium
# Description: Add playful easter egg commands to the CLI that provide entertainment and stress relief for developers while maintaining the professional nature of the tool.
# Details:
## Core Problem Statement
Developers often work long hours and need moments of levity to maintain productivity and morale. Adding fun, non-intrusive easter egg commands can:
1. **Boost Developer Morale**: Provide moments of humor and surprise during intense work sessions
2. **Showcase Tool Personality**: Give Task Master a friendly, approachable character
3. **Create Community Engagement**: Fun features often become talking points and increase tool adoption
4. **Stress Relief**: Offer quick mental breaks without leaving the development environment
## Implementation Approach
1. **Add Easter Egg Commands**: Implement hidden/fun commands in commands.js:
- `fortune` - Display random programming wisdom or motivational quotes
- `joke` - Show developer-friendly programming jokes
- `zen` - Display programming zen principles (like Python's zen)
- `coffee` - ASCII art coffee cup with brewing animation
- `rubber-duck` - Rubber duck debugging assistant with encouraging messages
- `praise` - Random praise messages for completed tasks
2. **Command Structure**: Follow existing CLI patterns but make commands discoverable through:
- Hidden help section (accessible via `--fun` flag on help command)
- Occasional hints when users complete milestones or long work sessions
3. **Content Management**: Create a separate `easter-eggs.js` module containing:
- Arrays of quotes, jokes, zen principles
- ASCII art templates
- Motivational messages
- Randomization logic for content selection
4. **Integration Points**:
- Add subtle hints after task completions ("Try 'task coffee' for a break!")
- Include fun stats in status displays (e.g., "You've completed X tasks - that deserves a joke!")
- Optional daily/weekly fun fact notifications
5. **Configuration**: Add optional config settings:
- `enableEasterEggs` (default: true)
- `funNotificationFrequency` (never, rare, occasional, frequent)
- `favoriteEasterEgg` for personalized defaults
6. **ASCII Art and Animations**: Implement simple text-based animations:
- Coffee brewing progress bars
- Rubber duck "thinking" animations
- Celebration ASCII art for major milestones
# Test Strategy:
- Test each easter egg command individually to ensure proper content display and formatting
- Verify ASCII art renders correctly across different terminal sizes and configurations
- Test configuration options to ensure easter eggs can be disabled/customized
- Validate that fun commands don't interfere with core Task Master functionality
- Test hint integration points to ensure they appear at appropriate times without being intrusive
- Verify content randomization works properly and doesn't repeat too frequently
- Test command discovery through hidden help sections
- Ensure all content is appropriate and maintains professional standards while being entertaining
- Test performance impact to ensure easter eggs don't slow down core operations
- Validate that easter egg commands gracefully handle edge cases (empty content arrays, display errors)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3011,6 +3011,13 @@ Examples:
process.exit(1);
}
// Find project root for tag resolution
const projectRoot = findProjectRoot();
if (!projectRoot) {
console.error(chalk.red('Error: Could not find project root.'));
process.exit(1);
}
// Check if we're moving multiple tasks (comma-separated IDs)
const sourceIds = sourceId.split(',').map((id) => id.trim());
const destinationIds = destinationId.split(',').map((id) => id.trim());
@@ -3067,7 +3074,8 @@ Examples:
tasksPath,
fromId,
toId,
i === sourceIds.length - 1
i === sourceIds.length - 1,
{ projectRoot }
);
console.log(
chalk.green(
@@ -3096,7 +3104,8 @@ Examples:
tasksPath,
sourceId,
destinationId,
true
true,
{ projectRoot }
);
console.log(
chalk.green(

View File

@@ -15,7 +15,15 @@ import {
displayAiUsageSummary,
displayContextAnalysis
} from '../ui.js';
import { readJSON, writeJSON, log as consoleLog, truncate } from '../utils.js';
import {
readJSON,
writeJSON,
log as consoleLog,
truncate,
ensureTagMetadata,
performCompleteTagMigration,
markMigrationForNotice
} from '../utils.js';
import { generateObjectService } from '../ai-services-unified.js';
import { getDefaultPriority } from '../config-manager.js';
import generateTaskFiles from './generate-task-files.js';
@@ -41,6 +49,25 @@ const AiTaskDataSchema = z.object({
)
});
/**
* Get all tasks from all tags
* @param {Object} rawData - The raw tagged data object
* @returns {Array} A flat array of all task objects
*/
function getAllTasks(rawData) {
let allTasks = [];
for (const tagName in rawData) {
if (
Object.prototype.hasOwnProperty.call(rawData, tagName) &&
rawData[tagName] &&
Array.isArray(rawData[tagName].tasks)
) {
allTasks = allTasks.concat(rawData[tagName].tasks);
}
}
return allTasks;
}
/**
* Add a new task using AI
* @param {string} tasksPath - Path to the tasks.json file
@@ -58,6 +85,7 @@ const AiTaskDataSchema = z.object({
* @param {string} [context.projectRoot] - Project root path (for MCP/env fallback)
* @param {string} [context.commandName] - The name of the command being executed (for telemetry)
* @param {string} [context.outputType] - The output type ('cli' or 'mcp', for telemetry)
* @param {string} [tag] - Tag for the task (optional)
* @returns {Promise<object>} An object containing newTaskId and telemetryData
*/
async function addTask(
@@ -68,7 +96,8 @@ async function addTask(
context = {},
outputFormat = 'text', // Default to text for CLI
manualTaskData = null,
useResearch = false
useResearch = false,
tag = null
) {
const { session, mcpLog, projectRoot, commandName, outputType } = context;
const isMCP = !!mcpLog;
@@ -90,6 +119,9 @@ async function addTask(
logFn.info(
`Adding new task with prompt: "${prompt}", Priority: ${effectivePriority}, Dependencies: ${dependencies.join(', ') || 'None'}, Research: ${useResearch}, ProjectRoot: ${projectRoot}`
);
if (tag) {
logFn.info(`Using tag context: ${tag}`);
}
let loadingIndicator = null;
let aiServiceResponse = null; // To store the full response from AI service
@@ -165,24 +197,82 @@ async function addTask(
}
try {
// Read the existing tasks
let data = readJSON(tasksPath);
// Read the existing tasks - IMPORTANT: Read the raw data without tag resolution
let rawData = readJSON(tasksPath, projectRoot); // No tag parameter
// If tasks.json doesn't exist or is invalid, create a new one
if (!data || !data.tasks) {
report('tasks.json not found or invalid. Creating a new one.', 'info');
// Create default tasks data structure
data = {
tasks: []
};
// Ensure the directory exists and write the new file
writeJSON(tasksPath, data);
report('Created new tasks.json file with empty tasks array.', 'info');
// Handle the case where readJSON returns resolved data with _rawTaggedData
if (rawData && rawData._rawTaggedData) {
// Use the raw tagged data and discard the resolved view
rawData = rawData._rawTaggedData;
}
// Find the highest task ID to determine the next ID
// If file doesn't exist or is invalid, create a new structure
if (!rawData) {
report('tasks.json not found or invalid. Creating a new one.', 'info');
rawData = {
master: {
tasks: [],
metadata: {
created: new Date().toISOString(),
description: 'Default tasks context'
}
}
};
writeJSON(tasksPath, rawData);
report(
'Created new tasks.json file with a default "master" tag.',
'info'
);
}
// Handle legacy format migration using utilities
if (rawData && Array.isArray(rawData.tasks)) {
report('Migrating legacy tasks.json format to tagged format...', 'info');
const legacyTasks = rawData.tasks;
rawData = {
master: {
tasks: legacyTasks
}
};
// Ensure proper metadata using utility
ensureTagMetadata(rawData.master, {
description: 'Tasks for master context'
});
writeJSON(tasksPath, rawData);
// Perform complete migration (config.json, state.json)
performCompleteTagMigration(tasksPath);
markMigrationForNotice(tasksPath);
report('Successfully migrated to tagged format.', 'success');
}
// Use the provided tag, or the current tag, or default to 'master'
const targetTag = tag || context.tag || 'master';
// Ensure the target tag exists
if (!rawData[targetTag]) {
report(
`Tag "${targetTag}" does not exist. Please create it first using the 'add-tag' command.`,
'error'
);
throw new Error(`Tag "${targetTag}" not found.`);
}
// Ensure the target tag has a metadata object
if (!rawData[targetTag].metadata) {
rawData[targetTag].metadata = {
created: new Date().toISOString(),
description: ``
};
}
// Get a flat list of ALL tasks across ALL tags to calculate new ID and validate dependencies
const allTasks = getAllTasks(rawData);
// Find the highest task ID across all tags to determine the next ID
const highestId =
data.tasks.length > 0 ? Math.max(...data.tasks.map((t) => t.id)) : 0;
allTasks.length > 0 ? Math.max(...allTasks.map((t) => t.id)) : 0;
const newTaskId = highestId + 1;
// Only show UI box for CLI mode
@@ -201,9 +291,7 @@ async function addTask(
const invalidDeps = dependencies.filter((depId) => {
// Ensure depId is parsed as a number for comparison
const numDepId = parseInt(depId, 10);
return (
Number.isNaN(numDepId) || !data.tasks.some((t) => t.id === numDepId)
);
return Number.isNaN(numDepId) || !allTasks.some((t) => t.id === numDepId);
});
if (invalidDeps.length > 0) {
@@ -226,12 +314,7 @@ async function addTask(
// First pass: build a complete dependency graph for each specified dependency
for (const depId of numericDependencies) {
const graph = buildDependencyGraph(
data.tasks,
depId,
new Set(),
depthMap
);
const graph = buildDependencyGraph(allTasks, depId, new Set(), depthMap);
if (graph) {
dependencyGraphs.push(graph);
}
@@ -444,7 +527,7 @@ async function addTask(
const allValidDeps = taskData.dependencies.every((depId) => {
const numDepId = parseInt(depId, 10);
return (
!Number.isNaN(numDepId) && data.tasks.some((t) => t.id === numDepId)
!Number.isNaN(numDepId) && allTasks.some((t) => t.id === numDepId)
);
});
@@ -456,18 +539,23 @@ async function addTask(
newTask.dependencies = taskData.dependencies.filter((depId) => {
const numDepId = parseInt(depId, 10);
return (
!Number.isNaN(numDepId) && data.tasks.some((t) => t.id === numDepId)
!Number.isNaN(numDepId) && allTasks.some((t) => t.id === numDepId)
);
});
}
}
// Add the task to the tasks array
data.tasks.push(newTask);
// Add the task to the tasks array OF THE CORRECT TAG
rawData[targetTag].tasks.push(newTask);
// Update the tag's metadata
ensureTagMetadata(rawData[targetTag], {
description: `Tasks for ${targetTag} context`
});
report('DEBUG: Writing tasks.json...', 'debug');
// Write the updated tasks to the file
writeJSON(tasksPath, data);
// Write the updated raw data back to the file
// The writeJSON function will automatically filter out _rawTaggedData
writeJSON(tasksPath, rawData);
report('DEBUG: tasks.json written.', 'debug');
// Generate markdown task files
@@ -522,7 +610,7 @@ async function addTask(
// Get task titles for dependencies to display
const depTitles = {};
newTask.dependencies.forEach((dep) => {
const depTask = data.tasks.find((t) => t.id === dep);
const depTask = allTasks.find((t) => t.id === dep);
if (depTask) {
depTitles[dep] = truncate(depTask.title, 30);
}
@@ -550,7 +638,7 @@ async function addTask(
chalk.gray('\nUser-specified dependencies that were not used:') +
'\n';
aiRemovedDeps.forEach((dep) => {
const depTask = data.tasks.find((t) => t.id === dep);
const depTask = allTasks.find((t) => t.id === dep);
const title = depTask ? truncate(depTask.title, 30) : 'Unknown task';
dependencyDisplay += chalk.gray(` - ${dep}: ${title}`) + '\n';
});

View File

@@ -26,6 +26,7 @@ import {
* @param {string} reportPath - Path to the complexity report
* @param {boolean} withSubtasks - Whether to show subtasks
* @param {string} outputFormat - Output format (text or json)
* @param {string} tag - Optional tag to override current tag resolution
* @returns {Object} - Task list result for json format
*/
function listTasks(
@@ -33,10 +34,11 @@ function listTasks(
statusFilter,
reportPath = null,
withSubtasks = false,
outputFormat = 'text'
outputFormat = 'text',
tag = null
) {
try {
const data = readJSON(tasksPath); // Reads the whole tasks.json
const data = readJSON(tasksPath, null, tag); // Pass tag to readJSON
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
}

View File

@@ -63,15 +63,32 @@ async function moveTask(
}
// Single move logic
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
// Read the raw data without tag resolution to preserve tagged structure
let rawData = readJSON(tasksPath, options.projectRoot); // No tag parameter
// Handle the case where readJSON returns resolved data with _rawTaggedData
if (rawData && rawData._rawTaggedData) {
// Use the raw tagged data and discard the resolved view
rawData = rawData._rawTaggedData;
}
// Note: readJSON() already handles tag resolution transparently
// data.tasks contains the tasks for the current tag
const tasks = data.tasks;
const currentTag = data.tag || 'master';
// Determine the current tag
const currentTag =
options.tag || getCurrentTag(options.projectRoot) || 'master';
// Ensure the tag exists in the raw data
if (
!rawData ||
!rawData[currentTag] ||
!Array.isArray(rawData[currentTag].tasks)
) {
throw new Error(
`Invalid tasks file or tag "${currentTag}" not found at ${tasksPath}`
);
}
// Get the tasks for the current tag
const tasks = rawData[currentTag].tasks;
log(
'info',
@@ -99,15 +116,11 @@ async function moveTask(
}
// Update the data structure with the modified tasks
data.tasks = tasks;
rawData[currentTag].tasks = tasks;
// If we have raw tagged data, also update that to maintain consistency
if (data._rawTaggedData && data._rawTaggedData[currentTag]) {
data._rawTaggedData[currentTag].tasks = tasks;
}
// Write the updated data back
writeJSON(tasksPath, data._rawTaggedData || data);
// Always write the data object, never the _rawTaggedData directly
// The writeJSON function will filter out _rawTaggedData automatically
writeJSON(tasksPath, rawData);
if (generateFiles) {
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
@@ -426,6 +439,25 @@ function moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId) {
}
});
// Update dependencies within movedTask's subtasks that reference sibling subtasks
if (Array.isArray(movedTask.subtasks)) {
movedTask.subtasks.forEach((subtask) => {
if (Array.isArray(subtask.dependencies)) {
subtask.dependencies = subtask.dependencies.map((dep) => {
// If dependency is a string like "oldParent.subId", update to "newParent.subId"
if (typeof dep === 'string' && dep.includes('.')) {
const [depParent, depSub] = dep.split('.');
if (parseInt(depParent, 10) === sourceTask.id) {
return `${destTaskId}.${depSub}`;
}
}
// If dependency is a number, and matches a subtask ID in the moved task, leave as is (context is implied)
return dep;
});
}
});
}
// Strategy based on commit fixes: remove source first, then replace destination
// This avoids index shifting problems

View File

@@ -11,7 +11,8 @@ import {
disableSilentMode,
isSilentMode,
readJSON,
findTaskById
findTaskById,
ensureTagMetadata
} from '../utils.js';
import { generateObjectService } from '../ai-services-unified.js';
@@ -312,7 +313,23 @@ Guidelines:
const finalTasks = append
? [...existingTasks, ...processedNewTasks]
: processedNewTasks;
const outputData = { tasks: finalTasks };
// Create proper tagged structure with metadata
const outputData = {
master: {
tasks: finalTasks,
metadata: {
created: new Date().toISOString(),
updated: new Date().toISOString(),
description: 'Tasks for master context'
}
}
};
// Ensure the master tag has proper metadata
ensureTagMetadata(outputData.master, {
description: 'Tasks for master context'
});
// Write the final tasks to the file
writeJSON(tasksPath, outputData);

View File

@@ -19,9 +19,16 @@ import {
* @param {string} taskIdInput - Task ID(s) to update
* @param {string} newStatus - New status
* @param {Object} options - Additional options (mcpLog for MCP mode)
* @param {string} tag - Optional tag to override current tag resolution
* @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode
*/
async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) {
async function setTaskStatus(
tasksPath,
taskIdInput,
newStatus,
options = {},
tag = null
) {
try {
if (!isValidTaskStatus(newStatus)) {
throw new Error(
@@ -43,7 +50,7 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) {
}
log('info', `Reading tasks from ${tasksPath}...`);
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, null, tag);
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
}

View File

@@ -895,10 +895,16 @@ function truncateString(str, maxLength) {
/**
* Display the next task to work on
* @param {string} tasksPath - Path to the tasks.json file
* @param {string} complexityReportPath - Path to the complexity report file
* @param {string} tag - Optional tag to override current tag resolution
*/
async function displayNextTask(tasksPath, complexityReportPath = null) {
async function displayNextTask(
tasksPath,
complexityReportPath = null,
tag = null
) {
// Read the tasks file
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, tag);
if (!data || !data.tasks) {
log('error', 'No valid tasks found.');
process.exit(1);
@@ -1162,16 +1168,19 @@ async function displayNextTask(tasksPath, complexityReportPath = null) {
* Display a specific task by ID
* @param {string} tasksPath - Path to the tasks.json file
* @param {string|number} taskId - The ID of the task to display
* @param {string} complexityReportPath - Path to the complexity report file
* @param {string} [statusFilter] - Optional status to filter subtasks by
* @param {string} tag - Optional tag to override current tag resolution
*/
async function displayTaskById(
tasksPath,
taskId,
complexityReportPath = null,
statusFilter = null
statusFilter = null,
tag = null
) {
// Read the tasks file
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, tag);
if (!data || !data.tasks) {
log('error', 'No valid tasks found.');
process.exit(1);

View File

@@ -197,9 +197,10 @@ function log(level, ...args) {
* 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) {
function readJSON(filepath, projectRoot = null, tag = null) {
// GUARD: Prevent circular dependency during config loading
let isDebug = false; // Default fallback
try {
@@ -208,158 +209,170 @@ function readJSON(filepath, projectRoot = null) {
} catch (error) {
// If getDebugFlag() fails (likely due to circular dependency),
// use default false and continue
isDebug = false;
}
if (isDebug) {
console.log(
`readJSON called with: ${filepath}, projectRoot: ${projectRoot}, tag: ${tag}`
);
}
if (!filepath) {
return null;
}
let data;
try {
if (!fs.existsSync(filepath)) {
if (isDebug) {
log('debug', `File not found: ${filepath}`);
}
return null;
}
const rawData = fs.readFileSync(filepath, 'utf8');
let data = JSON.parse(rawData);
// Check if this is legacy tasks.json format that needs migration
if (
data &&
data.tasks &&
Array.isArray(data.tasks) &&
!data.master &&
filepath.includes('tasks.json')
) {
// This is legacy format - migrate to tagged format
// Get file creation/modification date for the master tag
let createdDate;
try {
const stats = fs.statSync(filepath);
// Use the earlier of creation time or modification time
createdDate =
stats.birthtime < stats.mtime ? stats.birthtime : stats.mtime;
} catch (error) {
// Fallback to current date if we can't get file stats
createdDate = new Date();
}
const migratedData = {
master: {
tasks: data.tasks,
description: 'Tasks live here by default',
created: createdDate.toISOString(),
taskCount: data.tasks ? data.tasks.length : 0
}
};
// Copy any other top-level properties except 'tasks'
for (const [key, value] of Object.entries(data)) {
if (key !== 'tasks') {
migratedData[key] = value;
}
}
// Write the migrated format back using writeJSON for consistency
try {
writeJSON(filepath, migratedData);
if (isDebug) {
log(
'debug',
`Silently migrated tasks.json to tagged format: ${filepath}`
);
}
// Set global flag for CLI notice
global.taskMasterMigrationOccurred = true;
// Also perform complete project migration (config.json and state.json)
performCompleteTagMigration(filepath);
// Return the migrated data
data = migratedData;
} catch (writeError) {
// If we can't write back, log the error but continue with migrated data in memory
if (isDebug) {
log(
'warn',
`Could not write migrated tasks.json to ${filepath}: ${writeError.message}`
);
}
// Set global flag even on write failure
global.taskMasterMigrationOccurred = true;
// Still attempt other migrations
performCompleteTagMigration(filepath);
data = migratedData;
}
}
// Tag resolution: If data has tagged format, resolve the current tag and return old format
// This makes tag support completely transparent to existing code
if (data && !data.tasks && typeof data === 'object') {
// Check if this looks like tagged format (has tag-like keys)
const hasTaggedFormat = Object.keys(data).some(
(key) => data[key] && data[key].tasks && Array.isArray(data[key].tasks)
);
if (hasTaggedFormat) {
// 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 (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' default
}
} catch (error) {
// If anything fails, just use master
resolvedTag = 'master';
}
// Store the raw tagged data BEFORE modifying data
const rawTaggedData = { ...data };
// Return the tasks for the resolved tag, or master as fallback, or empty array
if (data[resolvedTag] && data[resolvedTag].tasks) {
data = {
tag: resolvedTag,
tasks: data[resolvedTag].tasks,
_rawTaggedData: rawTaggedData
};
} else if (data.master && data.master.tasks) {
data = {
tag: 'master',
tasks: data.master.tasks,
_rawTaggedData: rawTaggedData
};
} else {
// No valid tags found, return empty
data = { tasks: [], tag: 'master', _rawTaggedData: rawTaggedData };
}
}
}
return data;
} catch (error) {
log('error', `Error reading JSON file ${filepath}:`, error.message);
data = JSON.parse(fs.readFileSync(filepath, 'utf8'));
if (isDebug) {
// Use dynamic debug flag
// Use log utility for debug output too
log('error', 'Full error details:', error);
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 or already in legacy format, return as-is
if (!filepath.includes('tasks.json') || !data || Array.isArray(data.tasks)) {
if (isDebug) {
console.log(`File is not tagged format or is legacy, returning as-is`);
}
return data;
}
// 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));
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;
}
/**
@@ -533,9 +546,13 @@ function writeJSON(filepath, data) {
// Clean the data before writing - remove internal properties that should not be persisted
let cleanData = data;
if (data && typeof data === 'object' && data._rawTaggedData !== undefined) {
if (
data &&
typeof data === 'object' &&
(data._rawTaggedData !== undefined || data.tag !== undefined)
) {
// Create a clean copy without internal properties using destructuring
const { _rawTaggedData, ...cleanedData } = data;
const { _rawTaggedData, tag, ...cleanedData } = data;
cleanData = cleanedData;
}
@@ -1098,6 +1115,48 @@ function flattenTasksWithSubtasks(tasks) {
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,
@@ -1130,5 +1189,6 @@ export {
migrateConfigJson,
createStateJson,
markMigrationForNotice,
flattenTasksWithSubtasks
flattenTasksWithSubtasks,
ensureTagMetadata
};

70
test-clean-tags.js Normal file
View File

@@ -0,0 +1,70 @@
import fs from 'fs';
import {
createTag,
listTags
} from './scripts/modules/task-manager/tag-management.js';
console.log('=== Testing Tag Management with Clean File ===');
// Create a clean test tasks.json file
const testTasksPath = './test-tasks.json';
const cleanData = {
master: {
tasks: [
{ id: 1, title: 'Test Task 1', status: 'pending' },
{ id: 2, title: 'Test Task 2', status: 'done' }
],
metadata: {
created: new Date().toISOString(),
description: 'Master tag'
}
}
};
// Write clean test file
fs.writeFileSync(testTasksPath, JSON.stringify(cleanData, null, 2));
console.log('Created clean test file');
try {
// Test creating a new tag
console.log('\n--- Testing createTag ---');
await createTag(
testTasksPath,
'test-branch',
{ copyFromCurrent: true, description: 'Test branch' },
{ projectRoot: process.cwd() },
'json'
);
// Read the file and check for corruption
const resultData = JSON.parse(fs.readFileSync(testTasksPath, 'utf8'));
console.log('Keys in result file:', Object.keys(resultData));
console.log('Has _rawTaggedData in file:', !!resultData._rawTaggedData);
if (resultData._rawTaggedData) {
console.log('❌ CORRUPTION DETECTED: _rawTaggedData found in file!');
} else {
console.log('✅ SUCCESS: No _rawTaggedData corruption in file');
}
// Test listing tags
console.log('\n--- Testing listTags ---');
const tagList = await listTags(
testTasksPath,
{},
{ projectRoot: process.cwd() },
'json'
);
console.log(
'Found tags:',
tagList.tags.map((t) => t.name)
);
} catch (error) {
console.error('Error during test:', error.message);
} finally {
// Clean up test file
if (fs.existsSync(testTasksPath)) {
fs.unlinkSync(testTasksPath);
console.log('\nCleaned up test file');
}
}

1
test-tag-functions.js Normal file
View File

@@ -0,0 +1 @@