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

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