chore: v0.17 features and improvements (#771)

* chore: task management and small bug fix.

* chore: task management

* feat: implement research command with enhanced context gathering - Add comprehensive research command with AI-powered queries - Implement ContextGatherer utility for reusable context extraction - Support multiple context types: tasks, files, custom text, project tree - Add fuzzy search integration for automatic task discovery - Implement detailed token breakdown display with syntax highlighting - Add enhanced UI with boxed output and code block formatting - Support different detail levels (low, medium, high) for responses - Include project-specific context for more relevant AI responses - Add token counting with gpt-tokens library integration - Create reusable patterns for future context-aware commands - Task 94.4 completed

* docs: add context gathering rule and update existing rules

- Create comprehensive context_gathering.mdc rule documenting ContextGatherer utility patterns, FuzzyTaskSearch integration, token breakdown display, code block syntax highlighting, and enhanced result display patterns
- Update new_features.mdc to include context gathering step
- Update commands.mdc with context-aware command pattern
- Update ui.mdc with enhanced display patterns and syntax highlighting
- Update utilities.mdc to document new context gathering utilities
- Update glossary.mdc to include new context_gathering rule
- Establishes standardized patterns for building intelligent, context-aware commands that can leverage project knowledge for better AI assistance

* feat(fuzzy): improves fuzzy search to introspect into subtasks as well. might still need improvement.

* fix(move): adjusts logic to prevent an issue when moving from parent to subtask if the target parent has no subtasks.

* fix(move-task): Fix critical bugs in task move functionality

- Fixed parent-to-parent task moves where original task would remain as duplicate
- Fixed moving tasks to become subtasks of empty parents (validation errors)
- Fixed moving subtasks between different parent tasks
- Improved comma-separated batch moves with proper error handling
- Updated MCP tool to use core logic instead of custom implementation
- Resolves task duplication issues and enables proper task hierarchy reorganization

* feat(research): Add subtasks to fuzzy search and follow-up questions

- Enhanced fuzzy search to include subtasks in discovery - Added interactive follow-up question functionality using inquirer
- Improved context discovery by including both tasks and subtasks
- Follow-up option for research with default to 'n' for quick workflow

* chore: removes task004 chat that had like 11k lines lol.

* chore: formatting

* feat(show): add comma-separated ID support for multi-task viewing

- Enhanced get-task/show command to support comma-separated task IDs for efficient batch operations.
- New features include multiple task retrieval, smart display logic, interactive action menu with batch operations, MCP array response for AI agent efficiency, and support for mixed parent tasks and subtasks.
- Implementation includes updated CLI show command, enhanced MCP get_task tool, modified showTaskDirect function, and maintained full backward compatibility.
- Documentation updated across all relevant files.

Benefits include faster context gathering for AI agents, improved workflow with interactive batch operations, better UX with responsive layout, and enhanced API efficiency.

* feat(research): Adds MCP tool for  command

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

Updated documentation across taskmaster.mdc, README.md, command-reference.md, examples.md, tutorial.md, and docs/README.md to highlight research capabilities and usage patterns.

* chore: task management

* chore: task management and removes mistakenly staged changes

* fix(move): Fix move command bug that left duplicate tasks

- Fixed logic in moveTaskToNewId function that was incorrectly treating task-to-task moves as subtask creation instead of task replacement
- Updated moveTaskToNewId to properly handle replacing existing destination tasks instead of just placeholders
- The move command now correctly replaces destination tasks and cleans up properly without leaving duplicates

- Task Management: Moved task 93 (Google Vertex AI Provider) to position 88, Moved task 94 (Azure OpenAI Provider) to position 89, Updated task dependencies and regenerated task files, Cleaned up orphaned task files automatically
- All important validations remain in place: Prevents moving tasks to themselves, Prevents moving parent tasks to their own subtasks, Prevents circular dependencies
- Resolves the issue where moving tasks would leave both source and destination tasks in tasks.json and file system

* chore: formatting

* feat: Add .taskmaster directory (#619)

* chore: apply requested changes from next branch (#629)

* chore: rc version bump

* chore: cleanup migration-guide

* fix: bedrock set model and other fixes (#641)

* Fix: MCP log errors (#648)

* fix: projectRoot duplicate .taskmaster directory (#655)

* Version Packages

* chore: add package-lock.json

* Version Packages

* Version Packages

* fix: markdown format (#622)

* Version Packages

* Version Packages

* Fixed the Typo in cursor rules Issue:#675 (#677)

Fixed the typo in the Api keys

* Add one-click MCP server installation for Cursor (#671)

* Update README.md - Remove trailing commas (#673)

JSON doesn't allow for trailing commas, so these need to be removed in order for this to work

* chore: rc version bump

* fix: findTasksPath function

* fix: update MCP tool

* feat(ui): replace emoji complexity indicators with clean filled circle characters

Replace 🟢, 🟡, 🔴 emojis with ● character in getComplexityWithColor function

Update corresponding unit tests to expect ● instead of emojis

Improves UI continuity

* fix(ai-providers): change generateObject mode from 'tool' to 'auto' for better provider compatibility

Fixes Perplexity research role failing with 'tool-mode object generation' error

The hardcoded 'tool' mode was incompatible with providers like Perplexity that support structured JSON output but not function calling/tool use

Using 'auto' mode allows the AI SDK to choose the best approach for each provider

* Adds qwen3-235n-a22b:free to supported models. Closes #687)

* chore: adds a warning when custom openrouter model is a free model which suffers from lower rate limits, restricted context, and, worst of all, no access to tool_use.

* refactor: enhance add-task fuzzy search and fix duplicate banner display

- **Remove hardcoded category system** in add-task that always matched 'Task management'
- **Eliminate arbitrary limits** in fuzzy search results (5→25 high relevance, 3→10 medium relevance, 8→20 detailed tasks)
- **Improve semantic weighting** in Fuse.js search (details=3, description=2, title=1.5) for better relevance
- **Fix duplicate banner issue** by removing console.clear() and redundant displayBanner() calls from UI functions
- **Enhance context generation** to rely on semantic similarity rather than rigid pattern matching
- **Preserve terminal history** to address GitHub issue #553 about eating terminal lines
- **Remove displayBanner() calls** from: displayHelp, displayNextTask, displayTaskById, displayComplexityReport, set-task-status, clear-subtasks, dependency-manager functions

The add-task system now provides truly relevant task context based on semantic similarity rather than arbitrary categories and limits, while maintaining a cleaner terminal experience.

Changes span: add-task.js, ui.js, set-task-status.js, clear-subtasks.js, list-tasks.js, dependency-manager.js

Closes #553

* chore: changeset

* chore: passes tests and linting

* chore: more linting

* ninja(sync): add sync-readme command for GitHub README export with UTM tracking and professional markdown formatting. Experimental

* chore: changeset adjustment

* docs: Auto-update and format models.md

* chore: updates readme with npm download badges and mentions AI Jason who is joining the taskmaster core team.

* chore: fixes urls in readme npm packages

* chore: fixes urls in readme npm packages again

* fix: readme typo

* readme: fix twitter urls.

* readme: removes the taskmaster list output which is too overwhelming given its size with subtasks. may re-add later. fixes likely issues in the json for manual config in cursor and windsurf in the readme.

* chore: small readme nitpicks

* chore: adjusts changeset from minor to patch to avoid version bump to 0.17

* readme: moves up the documentation links higher up in the readme. same with the cursor one-click install.

* Fix Cursor deeplink installation with copy-paste instructions (#723)

* solve merge conflics with next. not gonna deal with these much longer.

* chore: update task files during rebase

* chore: task management

* feat: implement research command with enhanced context gathering - Add comprehensive research command with AI-powered queries - Implement ContextGatherer utility for reusable context extraction - Support multiple context types: tasks, files, custom text, project tree - Add fuzzy search integration for automatic task discovery - Implement detailed token breakdown display with syntax highlighting - Add enhanced UI with boxed output and code block formatting - Support different detail levels (low, medium, high) for responses - Include project-specific context for more relevant AI responses - Add token counting with gpt-tokens library integration - Create reusable patterns for future context-aware commands - Task 94.4 completed

* fix(move): adjusts logic to prevent an issue when moving from parent to subtask if the target parent has no subtasks.

* fix(move-task): Fix critical bugs in task move functionality

- Fixed parent-to-parent task moves where original task would remain as duplicate
- Fixed moving tasks to become subtasks of empty parents (validation errors)
- Fixed moving subtasks between different parent tasks
- Improved comma-separated batch moves with proper error handling
- Updated MCP tool to use core logic instead of custom implementation
- Resolves task duplication issues and enables proper task hierarchy reorganization

* chore: removes task004 chat that had like 11k lines lol.

* feat(show): add comma-separated ID support for multi-task viewing

- Enhanced get-task/show command to support comma-separated task IDs for efficient batch operations.
- New features include multiple task retrieval, smart display logic, interactive action menu with batch operations, MCP array response for AI agent efficiency, and support for mixed parent tasks and subtasks.
- Implementation includes updated CLI show command, enhanced MCP get_task tool, modified showTaskDirect function, and maintained full backward compatibility.
- Documentation updated across all relevant files.

Benefits include faster context gathering for AI agents, improved workflow with interactive batch operations, better UX with responsive layout, and enhanced API efficiency.

* feat(research): Adds MCP tool for  command

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

Updated documentation across taskmaster.mdc, README.md, command-reference.md, examples.md, tutorial.md, and docs/README.md to highlight research capabilities and usage patterns.

* chore: task management

* fix(move): Fix move command bug that left duplicate tasks

- Fixed logic in moveTaskToNewId function that was incorrectly treating task-to-task moves as subtask creation instead of task replacement
- Updated moveTaskToNewId to properly handle replacing existing destination tasks instead of just placeholders
- The move command now correctly replaces destination tasks and cleans up properly without leaving duplicates

- Task Management: Moved task 93 (Google Vertex AI Provider) to position 88, Moved task 94 (Azure OpenAI Provider) to position 89, Updated task dependencies and regenerated task files, Cleaned up orphaned task files automatically
- All important validations remain in place: Prevents moving tasks to themselves, Prevents moving parent tasks to their own subtasks, Prevents circular dependencies
- Resolves the issue where moving tasks would leave both source and destination tasks in tasks.json and file system

* chore: moves to new task master config setup

* feat: add comma-separated status filtering to list-tasks

- supports multiple statuses like 'blocked,deferred' with comprehensive test coverage and backward compatibility

- also adjusts biome.json to stop bitching about templating.

* chore: linting ffs

* fix(generate): Fix generate command creating tasks in legacy location

- Update generate command default output directory from 'tasks' to '.taskmaster/tasks'
- Fix path.dirname() usage to properly derive output directory from tasks file location
- Update MCP tool description and documentation to reflect new structure
- Disable Biome linting rules for noUnusedTemplateLiteral and useArrowFunction
- Fixes issue where generate command was creating task files in the old 'tasks/' directory instead of the new '.taskmaster/tasks/' structure after the refactor

* chore: task management

* chore: task management some more

* fix(get-task): makes the projectRoot argument required to prevent errors when getting tasks.

* feat(tags): Implement tagged task lists migration system (Part 1/2)

This commit introduces the foundational infrastructure for tagged task lists,
enabling multi-context task management without remote storage to prevent merge conflicts.

CORE ARCHITECTURE:
• Silent migration system transforms tasks.json from old format { "tasks": [...] }
  to new tagged format { "master": { "tasks": [...] } }
• Tag resolution layer provides complete backward compatibility - existing code continues to work
• Automatic configuration and state management for seamless user experience

SILENT MIGRATION SYSTEM:
• Automatic detection and migration of legacy tasks.json format
• Complete project migration: tasks.json + config.json + state.json
• Transparent tag resolution returns old format to maintain compatibility
• Zero breaking changes - all existing functionality preserved

CONFIGURATION MANAGEMENT:
• Added global.defaultTag setting (defaults to 'master')
• New tags section with gitIntegration placeholders for future features
• Automatic config.json migration during first run
• Proper state.json creation with migration tracking

USER EXPERIENCE:
• Clean, one-time FYI notice after migration (no emojis, professional styling)
• Notice appears after 'Suggested Next Steps' and is tracked in state.json
• Silent operation - users unaware migration occurred unless explicitly shown

TECHNICAL IMPLEMENTATION:
• Enhanced readJSON() with automatic migration detection and processing
• New utility functions: getCurrentTag(), resolveTag(), getTasksForTag(), setTasksForTag()
• Complete migration orchestration via performCompleteTagMigration()
• Robust error handling and fallback mechanisms

BACKWARD COMPATIBILITY:
• 100% backward compatibility maintained
• Existing CLI commands and MCP tools continue to work unchanged
• Legacy tasks.json format automatically upgraded on first read
• All existing workflows preserved

TESTING VERIFIED:
• Complete migration from legacy state works correctly
• Config.json properly updated with tagged system settings
• State.json created with correct initial values
• Migration notice system functions as designed
• All existing functionality continues to work normally

Part 2 will implement tag management commands (add-tag, use-tag, list-tags)
and MCP tool updates for full tagged task system functionality.

Related: Task 103 - Implement Tagged Task Lists System for Multi-Context Task Management

* docs: Update documentation and rules for tagged task lists system

- Updated task-structure.md with comprehensive tagged format explanation
- Updated all .cursor/rules/*.mdc files to reflect tagged system
- Completed subtask 103.16: Update Documentation for Tagged Task Lists System

* feat(mcp): Add tagInfo to responses and integrate ContextGatherer

Enhances the MCP server to include 'tagInfo' (currentTag, availableTags) in all tool responses, providing better client-side context.

- Introduces a new 'ContextGatherer' utility to standardize the collection of file, task, and project context for AI-powered commands. This refactors several task-manager modules ('expand-task', 'research', 'update-task', etc.) to use the new utility.

- Fixes an issue in 'get-task' and 'get-tasks' MCP tools where the 'projectRoot' was not being passed correctly, preventing tag information from being included in their responses.

- Adds subtask '103.17' to track the implementation of the task template importing feature.

- Updates documentation ('.cursor/rules', 'docs/') to align with the new tagged task system and context gatherer logic.

* fix: include tagInfo in AI service responses for MCP tools

- Update all core functions that call AI services to extract and return tagInfo
- Update all direct functions to include tagInfo in MCP response data
- Fixes issue where add_task, expand_task, and other AI commands were not including current tag and available tags information
- tagInfo includes currentTag from state.json and availableTags list
- Ensures tagged task lists system information is properly propagated through the full chain: AI service -> core function -> direct function -> MCP client

* fix(move-task): Update move functionality for tagged task system compatibility

- incorporate GitHub commit fixes and resolve readJSON data handling

* feat(tagged-tasks): Complete core tag management system implementation

- Implements comprehensive tagged task lists system for multi-context task management including core tag management functions (Task 103.11), MCP integration updates, and foundational infrastructure for tagged task operations. Features tag CRUD operations, validation, metadata tracking, deep task copying, and full backward compatibility.

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

* fix(tasks): ensure list tasks triggers silent migration if necessary.

* feat(tags): Complete show and add-task command tag support
- show command: Added --tag flag, fixed projectRoot passing to UI functions
- add-task command: Already had proper tag support and projectRoot handling
- Both commands now work correctly with tagged task lists system
- Migration logic works properly when viewing and adding tasks
- Updated subtask 103.5 with progress on high-priority command fixes

* fix(tags): Clean up rogue created properties and fix taskCount calculation
- Enhanced writeJSON to automatically filter rogue created/description properties from tag objects
- Fixed tags command error by making taskCount calculation dynamic instead of hardcoded
- Cleaned up existing rogue created property in master tag through forced write operation
- All created properties now properly located in metadata objects only
- Tags command working perfectly with proper task count display
- Data integrity maintained with automatic cleanup during write operations

* fix(tags): Resolve critical tag deletion and migration notice bugs

Major Issues Fixed:

1. Tag Deletion Bug: Fixed critical issue where creating subtasks would delete other tags

   - Root cause: writeJSON function wasn't accepting projectRoot/tag parameters

   - Fixed writeJSON signature and logic to handle tagged data structure

   - Added proper merging of resolved tag data back into full tagged structure

2. Persistent Migration Notice: Fixed FYI notice showing after every command

   - Root cause: markMigrationForNotice was resetting migrationNoticeShown to false

   - Fixed migration logic to only trigger on actual legacy->tagged migrations

   - Added proper _rawTaggedData checks to prevent false migration detection

3. Data Corruption Prevention: Enhanced data integrity safeguards

   - Fixed writeJSON to filter out internal properties

   - Added automatic cleanup of rogue properties

   - Improved hasTaggedStructure detection logic

Commands Fixed: add-subtask, remove-subtask, and all commands now preserve tags correctly

* fix(tags): Resolve tag deletion bug in remove-task command

Refactored the core 'removeTask' function to be fully tag-aware, preventing data corruption.

- The function now correctly reads the full tagged data structure by prioritizing '_rawTaggedData' instead of operating on a resolved single-tag view.

- All subsequent operations (task removal, dependency cleanup, file writing) now correctly reference the full multi-tag data object, preserving the integrity of 'tasks.json'.

- This resolves the critical bug where removing a task would delete all other tags.

* fix(tasks): Ensure new task IDs are sequential within the target tag

Modified the ID generation logic in 'add-task.js' to calculate the next task ID based on the highest ID within the specified tag, rather than globally across all tags.

This fixes a critical bug where creating a task in a new tag would result in a high, non-sequential ID, such as ID 105 for the first task in a tag.

* fix(commands): Add missing context parameters to dependency and remove-subtask commands

- Add projectRoot and tag context to all dependency commands
- Add projectRoot and tag context to remove-subtask command
- Add --tag option to remove-subtask command
- Fixes critical bug where remove-subtask was deleting other tags due to missing context
- All dependency and subtask commands now properly handle tagged task lists

* feat(tags): Add --tag flag support to core commands for multi-context task management
- parse-prd now supports creating tasks in specific contexts
- Fixed tag preservation logic to prevent data loss
- analyze-complexity generates tag-specific reports
- Non-existent tags created automatically
- Enables rapid prototyping and parallel development workflows

* feat(tags): Complete tagged task lists system with enhanced use-tag command

- Multi-context task management with full CLI support
- Enhanced use-tag command shows next available task after switching
- Universal --tag flag support across all commands
- Seamless migration with zero disruption
- Complete tag management suite (add, delete, rename, copy, list)
- Smart confirmation logic and data integrity protection
- State management and configuration integration
- Real-world use cases for teams, features, and releases

* feat(tags): Complete tag support for remaining CLI commands

- Add --tag flag to update, move, and set-status commands
- Ensure all task operation commands now support tag context
- Fix missing tag context passing to core functions
- Complete comprehensive tag-aware command coverage

* feat(ui): add tag indicator to all CLI commands
- shows 🏷️ tag: tagname for complete context visibility across 15+ commands

* fix(ui): resolve dependency 'Not found' issue when filtering

- now correctly displays dependencies that exist but are filtered out of view

* feat(research): Add comprehensive AI-powered research command with interactive follow-ups, save functionality, intelligent context gathering, fuzzy task discovery, multi-source context support, enhanced display with syntax highlighting, clean inquirer menus, comprehensive help, and MCP integration with saveTo parameter

* feat(tags): Implement full MCP support for Tagged Task Lists and update-task append mode

* chore: task management

* feat(research): Enhance research command with follow-up menu, save functionality, and fix ContextGatherer token counting

* feat(git-workflow): Add automatic git branch-tag integration

- Implement automatic tag creation when switching to new git branches

- Add branch-tag mapping system for seamless context switching

- Enable auto-switch of task contexts based on current git branch

- Provide isolated task contexts per branch to prevent merge conflicts

- Add configuration support for enabling/disabling git workflow features

- Fix ES module compatibility issues in git-utils module

- Maintain zero migration impact with automatic 'master' tag creation

- Support parallel development with branch-specific task contexts

The git workflow system automatically detects branch changes and creates corresponding empty task tags, enabling developers to maintain separate task contexts for different features/branches while preventing task-related merge conflicts during collaborative development.

Resolves git workflow integration requirements for multi-context development.

* feat(git-workflow): Simplify git integration with --from-branch option

- Remove automatic git workflow and branch-tag switching - we are not ready for it yet

- Add --from-branch option to add-tag command for manual tag creation from git branch

- Remove git workflow configuration from config.json and assets

- Disable automatic tag switching functions in git-utils.js

- Add createTagFromBranch function for branch-based tag creation

- Support both CLI and MCP interfaces for --from-branch functionality

- Fix ES module imports in git-utils.js and utils.js

- Maintain user control over tag contexts without forced automation

The simplified approach allows users to create tags from their current git branch when desired, without the complexity and rigidity of automatic branch-tag synchronization. Users maintain full control over their tag contexts while having convenient tools for git-based workflows when needed.

* docs: Update rule files to reflect simplified git integration approach

- Remove automatic git workflow features, update to manual --from-branch option, change Part 2 references to completed status

* fix(commands): Fix add-tag --from-branch requiring tagName argument
- Made tagName optional when using --from-branch - Added validation for either tagName or --from-branch
- Fixes 'missing required argument' error with --from-branch option

* fix(mcp): Prevent tag deletion on subtask update

Adds a safety net to the writeJSON utility to prevent data loss when updating subtasks via the MCP server.

The MCP process was inadvertently causing the _rawTaggedData property, which holds the complete multi-tag structure, to be lost. When writeJSON received the data for only a single tag, it would overwrite the entire tasks.json file, deleting all other tags.

This fix makes writeJSON more robust. If it receives data that looks like a single, resolved tag without the complete structure, it re-reads the full tasks.json file from disk. It then carefully merges the updated data back into the correct tag within the full structure, preserving all other tags.

* fix: resolve all remaining test failures and improve test reliability

- Fix clear-subtasks test by implementing deep copy of mock data to prevent mutation issues between tests
- Fix add-task test by uncommenting and properly configuring generateTaskFiles call with correct parameters
- Fix analyze-task-complexity tests by properly mocking fs.writeFileSync with shared mock function
- Update test expectations to match actual function signatures and data structures
- Improve mock setup consistency across all test suites
- Ensure all tests now pass (329 total: 318 passed, 11 skipped, 0 failed)

* chore: task management

---------

Co-authored-by: Eyal Toledano <eyal@microangel.so>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ibrahim H. <bitsnaps@yahoo.fr>
Co-authored-by: Saksham Goel <sakshamgoel1107@gmail.com>
Co-authored-by: Joe Danziger <joe@ticc.net>
Co-authored-by: Aaron Gabriel Neyer <ag@unforced.org>
This commit is contained in:
Ralph Khreish
2025-06-14 17:04:26 +02:00
parent 12bed2b307
commit c0b3f432a6
242 changed files with 23137 additions and 15494 deletions

View File

@@ -11,6 +11,7 @@ import generateTaskFiles from './generate-task-files.js';
* @param {number|string|null} existingTaskId - ID of an existing task to convert to subtask (optional)
* @param {Object} newSubtaskData - Data for creating a new subtask (used if existingTaskId is null)
* @param {boolean} generateFiles - Whether to regenerate task files after adding the subtask
* @param {Object} context - Context object containing projectRoot and tag information
* @returns {Object} The newly created or converted subtask
*/
async function addSubtask(
@@ -18,13 +19,14 @@ async function addSubtask(
parentId,
existingTaskId = null,
newSubtaskData = null,
generateFiles = true
generateFiles = true,
context = {}
) {
try {
log('info', `Adding subtask to parent task ${parentId}...`);
// Read the existing tasks
const data = readJSON(tasksPath);
// Read the existing tasks with proper context
const data = readJSON(tasksPath, context.projectRoot, context.tag);
if (!data || !data.tasks) {
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
}
@@ -134,13 +136,13 @@ async function addSubtask(
);
}
// Write the updated tasks back to the file
writeJSON(tasksPath, data);
// Write the updated tasks back to the file with proper context
writeJSON(tasksPath, data, context.projectRoot, context.tag);
// Generate task files if requested
if (generateFiles) {
log('info', 'Regenerating task files...');
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
// await generateTaskFiles(tasksPath, path.dirname(tasksPath), context);
}
return newSubtask;

View File

@@ -12,12 +12,23 @@ import {
stopLoadingIndicator,
succeedLoadingIndicator,
failLoadingIndicator,
displayAiUsageSummary
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,
getCurrentTag
} from '../utils.js';
import { generateObjectService } from '../ai-services-unified.js';
import { getDefaultPriority } from '../config-manager.js';
import generateTaskFiles from './generate-task-files.js';
import ContextGatherer from '../utils/contextGatherer.js';
// Define Zod schema for the expected AI output object
const AiTaskDataSchema = z.object({
@@ -39,6 +50,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
@@ -56,6 +86,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(
@@ -66,7 +97,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;
@@ -88,6 +120,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
@@ -163,24 +198,95 @@ 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 in memory
if (!rawData) {
report(
'tasks.json not found or invalid. Initializing new structure.',
'info'
);
rawData = {
master: {
tasks: [],
metadata: {
created: new Date().toISOString(),
description: 'Default tasks context'
}
}
};
// Do not write the file here; it will be written later with the new task.
}
// Handle legacy format migration using utilities
if (rawData && Array.isArray(rawData.tasks) && !rawData._rawTaggedData) {
report('Legacy format detected. Migrating to tagged format...', 'info');
// This is legacy format - migrate it to tagged format
rawData = {
master: {
tasks: rawData.tasks,
metadata: rawData.metadata || {
created: new Date().toISOString(),
updated: new Date().toISOString(),
description: 'Tasks for master context'
}
}
};
// Ensure proper metadata using utility
ensureTagMetadata(rawData.master, {
description: 'Tasks for master context'
});
// Do not write the file here; it will be written later with the new task.
// 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 active tag, or default to 'master'
const targetTag =
tag || context.tag || getCurrentTag(projectRoot) || '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 tasks array and metadata object
if (!rawData[targetTag].tasks) {
rawData[targetTag].tasks = [];
}
if (!rawData[targetTag].metadata) {
rawData[targetTag].metadata = {
created: new Date().toISOString(),
updated: new Date().toISOString(),
description: ``
};
}
// Get a flat list of ALL tasks across ALL tags to validate dependencies
const allTasks = getAllTasks(rawData);
// Find the highest task ID *within the target tag* to determine the next ID
const tasksInTargetTag = rawData[targetTag].tasks;
const highestId =
data.tasks.length > 0 ? Math.max(...data.tasks.map((t) => t.id)) : 0;
tasksInTargetTag.length > 0
? Math.max(...tasksInTargetTag.map((t) => t.id))
: 0;
const newTaskId = highestId + 1;
// Only show UI box for CLI mode
@@ -199,7 +305,7 @@ async function addTask(
const invalidDeps = dependencies.filter((depId) => {
// Ensure depId is parsed as a number for comparison
const numDepId = parseInt(depId, 10);
return isNaN(numDepId) || !data.tasks.some((t) => t.id === numDepId);
return Number.isNaN(numDepId) || !allTasks.some((t) => t.id === numDepId);
});
if (invalidDeps.length > 0) {
@@ -222,12 +328,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);
}
@@ -262,570 +363,20 @@ async function addTask(
// --- Refactored AI Interaction ---
report(`Generating task data with AI with prompt:\n${prompt}`, 'info');
// Create context string for task creation prompt
let contextTasks = '';
// Create a dependency map for better understanding of the task relationships
const taskMap = {};
data.tasks.forEach((t) => {
// For each task, only include id, title, description, and dependencies
taskMap[t.id] = {
id: t.id,
title: t.title,
description: t.description,
dependencies: t.dependencies || [],
status: t.status
};
// --- Use the new ContextGatherer ---
const contextGatherer = new ContextGatherer(projectRoot);
const gatherResult = await contextGatherer.gather({
semanticQuery: prompt,
dependencyTasks: numericDependencies,
format: 'research'
});
// CLI-only feedback for the dependency analysis
if (outputFormat === 'text') {
console.log(
boxen(chalk.cyan.bold('Task Context Analysis'), {
padding: { top: 0, bottom: 0, left: 1, right: 1 },
margin: { top: 0, bottom: 0 },
borderColor: 'cyan',
borderStyle: 'round'
})
);
}
const gatheredContext = gatherResult.context;
const analysisData = gatherResult.analysisData;
// Initialize variables that will be used in either branch
let uniqueDetailedTasks = [];
let dependentTasks = [];
let promptCategory = null;
if (numericDependencies.length > 0) {
// If specific dependencies were provided, focus on them
// Get all tasks that were found in the dependency graph
dependentTasks = Array.from(allRelatedTaskIds)
.map((id) => data.tasks.find((t) => t.id === id))
.filter(Boolean);
// Sort by depth in the dependency chain
dependentTasks.sort((a, b) => {
const depthA = depthMap.get(a.id) || 0;
const depthB = depthMap.get(b.id) || 0;
return depthA - depthB; // Lowest depth (root dependencies) first
});
// Limit the number of detailed tasks to avoid context explosion
uniqueDetailedTasks = dependentTasks.slice(0, 8);
contextTasks = `\nThis task relates to a dependency structure with ${dependentTasks.length} related tasks in the chain.\n\nDirect dependencies:`;
const directDeps = data.tasks.filter((t) =>
numericDependencies.includes(t.id)
);
contextTasks += `\n${directDeps.map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`).join('\n')}`;
// Add an overview of indirect dependencies if present
const indirectDeps = dependentTasks.filter(
(t) => !numericDependencies.includes(t.id)
);
if (indirectDeps.length > 0) {
contextTasks += `\n\nIndirect dependencies (dependencies of dependencies):`;
contextTasks += `\n${indirectDeps
.slice(0, 5)
.map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`)
.join('\n')}`;
if (indirectDeps.length > 5) {
contextTasks += `\n- ... and ${indirectDeps.length - 5} more indirect dependencies`;
}
}
// Add more details about each dependency, prioritizing direct dependencies
contextTasks += `\n\nDetailed information about dependencies:`;
for (const depTask of uniqueDetailedTasks) {
const depthInfo = depthMap.get(depTask.id)
? ` (depth: ${depthMap.get(depTask.id)})`
: '';
const isDirect = numericDependencies.includes(depTask.id)
? ' [DIRECT DEPENDENCY]'
: '';
contextTasks += `\n\n------ Task ${depTask.id}${isDirect}${depthInfo}: ${depTask.title} ------\n`;
contextTasks += `Description: ${depTask.description}\n`;
contextTasks += `Status: ${depTask.status || 'pending'}\n`;
contextTasks += `Priority: ${depTask.priority || 'medium'}\n`;
// List its dependencies
if (depTask.dependencies && depTask.dependencies.length > 0) {
const depDeps = depTask.dependencies.map((dId) => {
const depDepTask = data.tasks.find((t) => t.id === dId);
return depDepTask
? `Task ${dId}: ${depDepTask.title}`
: `Task ${dId}`;
});
contextTasks += `Dependencies: ${depDeps.join(', ')}\n`;
} else {
contextTasks += `Dependencies: None\n`;
}
// Add implementation details but truncate if too long
if (depTask.details) {
const truncatedDetails =
depTask.details.length > 400
? depTask.details.substring(0, 400) + '... (truncated)'
: depTask.details;
contextTasks += `Implementation Details: ${truncatedDetails}\n`;
}
}
// Add dependency chain visualization
if (dependencyGraphs.length > 0) {
contextTasks += '\n\nDependency Chain Visualization:';
// Helper function to format dependency chain as text
function formatDependencyChain(
node,
prefix = '',
isLast = true,
depth = 0
) {
if (depth > 3) return ''; // Limit depth to avoid excessive nesting
const connector = isLast ? '└── ' : '├── ';
const childPrefix = isLast ? ' ' : '│ ';
let result = `\n${prefix}${connector}Task ${node.id}: ${node.title}`;
if (node.dependencies && node.dependencies.length > 0) {
for (let i = 0; i < node.dependencies.length; i++) {
const isLastChild = i === node.dependencies.length - 1;
result += formatDependencyChain(
node.dependencies[i],
prefix + childPrefix,
isLastChild,
depth + 1
);
}
}
return result;
}
// Format each dependency graph
for (const graph of dependencyGraphs) {
contextTasks += formatDependencyChain(graph);
}
}
// Show dependency analysis in CLI mode
if (outputFormat === 'text') {
if (directDeps.length > 0) {
console.log(chalk.gray(` Explicitly specified dependencies:`));
directDeps.forEach((t) => {
console.log(
chalk.yellow(` • Task ${t.id}: ${truncate(t.title, 50)}`)
);
});
}
if (indirectDeps.length > 0) {
console.log(
chalk.gray(
`\n Indirect dependencies (${indirectDeps.length} total):`
)
);
indirectDeps.slice(0, 3).forEach((t) => {
const depth = depthMap.get(t.id) || 0;
console.log(
chalk.cyan(
` • Task ${t.id} [depth ${depth}]: ${truncate(t.title, 45)}`
)
);
});
if (indirectDeps.length > 3) {
console.log(
chalk.cyan(
` • ... and ${indirectDeps.length - 3} more indirect dependencies`
)
);
}
}
// Visualize the dependency chain
if (dependencyGraphs.length > 0) {
console.log(chalk.gray(`\n Dependency chain visualization:`));
// Convert dependency graph to ASCII art for terminal
function visualizeDependencyGraph(
node,
prefix = '',
isLast = true,
depth = 0
) {
if (depth > 2) return; // Limit depth for display
const connector = isLast ? '└── ' : '├── ';
const childPrefix = isLast ? ' ' : '│ ';
console.log(
chalk.blue(
` ${prefix}${connector}Task ${node.id}: ${truncate(node.title, 40)}`
)
);
if (node.dependencies && node.dependencies.length > 0) {
for (let i = 0; i < node.dependencies.length; i++) {
const isLastChild = i === node.dependencies.length - 1;
visualizeDependencyGraph(
node.dependencies[i],
prefix + childPrefix,
isLastChild,
depth + 1
);
}
}
}
// Visualize each dependency graph
for (const graph of dependencyGraphs) {
visualizeDependencyGraph(graph);
}
}
console.log(); // Add spacing
}
} else {
// If no dependencies provided, use Fuse.js to find semantically related tasks
// Create fuzzy search index for all tasks
const searchOptions = {
includeScore: true, // Return match scores
threshold: 0.4, // Lower threshold = stricter matching (range 0-1)
keys: [
{ name: 'title', weight: 1.5 }, // Title is most important
{ name: 'description', weight: 2 }, // Description is very important
{ name: 'details', weight: 3 }, // Details is most important
// Search dependencies to find tasks that depend on similar things
{ name: 'dependencyTitles', weight: 0.5 }
],
// Sort matches by score (lower is better)
shouldSort: true,
// Allow searching in nested properties
useExtendedSearch: true,
// Return up to 50 matches
limit: 50
};
// Prepare task data with dependencies expanded as titles for better semantic search
const searchableTasks = data.tasks.map((task) => {
// Get titles of this task's dependencies if they exist
const dependencyTitles =
task.dependencies?.length > 0
? task.dependencies
.map((depId) => {
const depTask = data.tasks.find((t) => t.id === depId);
return depTask ? depTask.title : '';
})
.filter((title) => title)
.join(' ')
: '';
return {
...task,
dependencyTitles
};
});
// Create search index using Fuse.js
const fuse = new Fuse(searchableTasks, searchOptions);
// Extract significant words and phrases from the prompt
const promptWords = prompt
.toLowerCase()
.replace(/[^\w\s-]/g, ' ') // Replace non-alphanumeric chars with spaces
.split(/\s+/)
.filter((word) => word.length > 3); // Words at least 4 chars
// Use the user's prompt for fuzzy search
const fuzzyResults = fuse.search(prompt);
// Also search for each significant word to catch different aspects
let wordResults = [];
for (const word of promptWords) {
if (word.length > 5) {
// Only use significant words
const results = fuse.search(word);
if (results.length > 0) {
wordResults.push(...results);
}
}
}
// Merge and deduplicate results
const mergedResults = [...fuzzyResults];
// Add word results that aren't already in fuzzyResults
for (const wordResult of wordResults) {
if (!mergedResults.some((r) => r.item.id === wordResult.item.id)) {
mergedResults.push(wordResult);
}
}
// Group search results by relevance
const highRelevance = mergedResults
.filter((result) => result.score < 0.25)
.map((result) => result.item);
const mediumRelevance = mergedResults
.filter((result) => result.score >= 0.25 && result.score < 0.4)
.map((result) => result.item);
// Get recent tasks (newest first)
const recentTasks = [...data.tasks]
.sort((a, b) => b.id - a.id)
.slice(0, 5);
// Combine high relevance, medium relevance, and recent tasks
// Prioritize high relevance first
const allRelevantTasks = [...highRelevance];
// Add medium relevance if not already included
for (const task of mediumRelevance) {
if (!allRelevantTasks.some((t) => t.id === task.id)) {
allRelevantTasks.push(task);
}
}
// Add recent tasks if not already included
for (const task of recentTasks) {
if (!allRelevantTasks.some((t) => t.id === task.id)) {
allRelevantTasks.push(task);
}
}
// Get top N results for context
const relatedTasks = allRelevantTasks.slice(0, 8);
// Format basic task overviews
if (relatedTasks.length > 0) {
contextTasks = `\nRelevant tasks identified by semantic similarity:\n${relatedTasks
.map((t, i) => {
const relevanceMarker = i < highRelevance.length ? '⭐ ' : '';
return `- ${relevanceMarker}Task ${t.id}: ${t.title} - ${t.description}`;
})
.join('\n')}`;
}
if (
recentTasks.length > 0 &&
!contextTasks.includes('Recently created tasks')
) {
contextTasks += `\n\nRecently created tasks:\n${recentTasks
.filter((t) => !relatedTasks.some((rt) => rt.id === t.id))
.slice(0, 3)
.map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`)
.join('\n')}`;
}
// Add detailed information about the most relevant tasks
const allDetailedTasks = [...relatedTasks.slice(0, 25)];
uniqueDetailedTasks = Array.from(
new Map(allDetailedTasks.map((t) => [t.id, t])).values()
).slice(0, 20);
if (uniqueDetailedTasks.length > 0) {
contextTasks += `\n\nDetailed information about relevant tasks:`;
for (const task of uniqueDetailedTasks) {
contextTasks += `\n\n------ Task ${task.id}: ${task.title} ------\n`;
contextTasks += `Description: ${task.description}\n`;
contextTasks += `Status: ${task.status || 'pending'}\n`;
contextTasks += `Priority: ${task.priority || 'medium'}\n`;
if (task.dependencies && task.dependencies.length > 0) {
// Format dependency list with titles
const depList = task.dependencies.map((depId) => {
const depTask = data.tasks.find((t) => t.id === depId);
return depTask
? `Task ${depId} (${depTask.title})`
: `Task ${depId}`;
});
contextTasks += `Dependencies: ${depList.join(', ')}\n`;
}
// Add implementation details but truncate if too long
if (task.details) {
const truncatedDetails =
task.details.length > 400
? task.details.substring(0, 400) + '... (truncated)'
: task.details;
contextTasks += `Implementation Details: ${truncatedDetails}\n`;
}
}
}
// Add a concise view of the task dependency structure
contextTasks += '\n\nSummary of task dependencies in the project:';
// Get pending/in-progress tasks that might be most relevant based on fuzzy search
// Prioritize tasks from our similarity search
const relevantTaskIds = new Set(uniqueDetailedTasks.map((t) => t.id));
const relevantPendingTasks = data.tasks
.filter(
(t) =>
(t.status === 'pending' || t.status === 'in-progress') &&
// Either in our relevant set OR has relevant words in title/description
(relevantTaskIds.has(t.id) ||
promptWords.some(
(word) =>
t.title.toLowerCase().includes(word) ||
t.description.toLowerCase().includes(word)
))
)
.slice(0, 10);
for (const task of relevantPendingTasks) {
const depsStr =
task.dependencies && task.dependencies.length > 0
? task.dependencies.join(', ')
: 'None';
contextTasks += `\n- Task ${task.id}: depends on [${depsStr}]`;
}
// Additional analysis of common patterns
const similarPurposeTasks = data.tasks.filter((t) =>
prompt.toLowerCase().includes(t.title.toLowerCase())
);
let commonDeps = []; // Initialize commonDeps
if (similarPurposeTasks.length > 0) {
contextTasks += `\n\nCommon patterns for similar tasks:`;
// Collect dependencies from similar purpose tasks
const similarDeps = similarPurposeTasks
.filter((t) => t.dependencies && t.dependencies.length > 0)
.map((t) => t.dependencies)
.flat();
// Count frequency of each dependency
const depCounts = {};
similarDeps.forEach((dep) => {
depCounts[dep] = (depCounts[dep] || 0) + 1;
});
// Get most common dependencies for similar tasks
commonDeps = Object.entries(depCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
if (commonDeps.length > 0) {
contextTasks += '\nMost common dependencies for similar tasks:';
commonDeps.forEach(([depId, count]) => {
const depTask = data.tasks.find((t) => t.id === parseInt(depId));
if (depTask) {
contextTasks += `\n- Task ${depId} (used by ${count} similar tasks): ${depTask.title}`;
}
});
}
}
// Show fuzzy search analysis in CLI mode
if (outputFormat === 'text') {
console.log(
chalk.gray(
` Context search across ${data.tasks.length} tasks using full prompt and ${promptWords.length} keywords`
)
);
if (highRelevance.length > 0) {
console.log(
chalk.gray(`\n High relevance matches (score < 0.25):`)
);
highRelevance.slice(0, 25).forEach((t) => {
console.log(
chalk.yellow(` • ⭐ Task ${t.id}: ${truncate(t.title, 50)}`)
);
});
}
if (mediumRelevance.length > 0) {
console.log(
chalk.gray(`\n Medium relevance matches (score < 0.4):`)
);
mediumRelevance.slice(0, 10).forEach((t) => {
console.log(
chalk.green(` • Task ${t.id}: ${truncate(t.title, 50)}`)
);
});
}
// Show dependency patterns
if (commonDeps && commonDeps.length > 0) {
console.log(
chalk.gray(`\n Common dependency patterns for similar tasks:`)
);
commonDeps.slice(0, 3).forEach(([depId, count]) => {
const depTask = data.tasks.find((t) => t.id === parseInt(depId));
if (depTask) {
console.log(
chalk.blue(
` • Task ${depId} (${count}x): ${truncate(depTask.title, 45)}`
)
);
}
});
}
// Add information about which tasks will be provided in detail
if (uniqueDetailedTasks.length > 0) {
console.log(
chalk.gray(
`\n Providing detailed context for ${uniqueDetailedTasks.length} most relevant tasks:`
)
);
uniqueDetailedTasks.forEach((t) => {
const isHighRelevance = highRelevance.some(
(ht) => ht.id === t.id
);
const relevanceIndicator = isHighRelevance ? '⭐ ' : '';
console.log(
chalk.cyan(
`${relevanceIndicator}Task ${t.id}: ${truncate(t.title, 40)}`
)
);
});
}
console.log(); // Add spacing
}
}
// DETERMINE THE ACTUAL COUNT OF DETAILED TASKS BEING USED FOR AI CONTEXT
let actualDetailedTasksCount = 0;
if (numericDependencies.length > 0) {
// In explicit dependency mode, we used 'uniqueDetailedTasks' derived from 'dependentTasks'
// Ensure 'uniqueDetailedTasks' from THAT scope is used or re-evaluate.
// For simplicity, let's assume 'dependentTasks' reflects the detailed tasks.
actualDetailedTasksCount = dependentTasks.length;
} else {
// In fuzzy search mode, 'uniqueDetailedTasks' from THIS scope is correct.
actualDetailedTasksCount = uniqueDetailedTasks
? uniqueDetailedTasks.length
: 0;
}
// Add a visual transition to show we're moving to AI generation - only for CLI
if (outputFormat === 'text') {
console.log(
boxen(
chalk.white.bold('AI Task Generation') +
`\n\n${chalk.gray('Analyzing context and generating task details using AI...')}` +
`\n${chalk.cyan('Context size: ')}${chalk.yellow(contextTasks.length.toLocaleString())} characters` +
`\n${chalk.cyan('Dependency detection: ')}${chalk.yellow(numericDependencies.length > 0 ? 'Explicit dependencies' : 'Auto-discovery mode')}` +
`\n${chalk.cyan('Detailed tasks: ')}${chalk.yellow(
numericDependencies.length > 0
? dependentTasks.length // Use length of tasks from explicit dependency path
: uniqueDetailedTasks.length // Use length of tasks from fuzzy search path
)}`,
{
padding: { top: 0, bottom: 1, left: 1, right: 1 },
margin: { top: 1, bottom: 0 },
borderColor: 'white',
borderStyle: 'round'
}
)
);
console.log(); // Add spacing
// Display context analysis if not in silent mode
if (outputFormat === 'text' && analysisData) {
displayContextAnalysis(analysisData, prompt, gatheredContext.length);
}
// System Prompt - Enhanced for dependency awareness
@@ -866,8 +417,7 @@ async function addTask(
// User Prompt
const userPrompt = `You are generating the details for Task #${newTaskId}. Based on the user's request: "${prompt}", create a comprehensive new task for a software development project.
${contextTasks}
${contextFromArgs ? `\nConsider these additional details provided by the user:${contextFromArgs}` : ''}
${gatheredContext}
Based on the information about existing tasks provided above, include appropriate dependencies in the "dependencies" array. Only include task IDs that this new task directly depends on.
@@ -975,7 +525,9 @@ async function addTask(
if (taskData.dependencies?.length) {
const allValidDeps = taskData.dependencies.every((depId) => {
const numDepId = parseInt(depId, 10);
return !isNaN(numDepId) && data.tasks.some((t) => t.id === numDepId);
return (
!Number.isNaN(numDepId) && allTasks.some((t) => t.id === numDepId)
);
});
if (!allValidDeps) {
@@ -985,24 +537,34 @@ async function addTask(
);
newTask.dependencies = taskData.dependencies.filter((depId) => {
const numDepId = parseInt(depId, 10);
return !isNaN(numDepId) && data.tasks.some((t) => t.id === numDepId);
return (
!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
report('Generating task files...', 'info');
report('DEBUG: Calling generateTaskFiles...', 'debug');
// Pass mcpLog if available to generateTaskFiles
await generateTaskFiles(tasksPath, path.dirname(tasksPath), { mcpLog });
await generateTaskFiles(tasksPath, path.dirname(tasksPath), {
projectRoot,
tag: targetTag
});
report('DEBUG: generateTaskFiles finished.', 'debug');
// Show success message - only for text output (CLI)
@@ -1032,7 +594,6 @@ async function addTask(
return 'red';
case 'low':
return 'gray';
case 'medium':
default:
return 'yellow';
}
@@ -1051,7 +612,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);
}
@@ -1079,7 +640,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';
});
@@ -1153,7 +714,8 @@ async function addTask(
);
return {
newTaskId: newTaskId,
telemetryData: aiServiceResponse ? aiServiceResponse.telemetryData : null
telemetryData: aiServiceResponse ? aiServiceResponse.telemetryData : null,
tagInfo: aiServiceResponse ? aiServiceResponse.tagInfo : null
};
} catch (error) {
// Stop any loading indicator on error

View File

@@ -18,19 +18,32 @@ import {
COMPLEXITY_REPORT_FILE,
LEGACY_TASKS_FILE
} from '../../../src/constants/paths.js';
import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
import { flattenTasksWithSubtasks } from '../utils.js';
/**
* Generates the prompt for complexity analysis.
* (Moved from ai-services.js and simplified)
* @param {Object} tasksData - The tasks data object.
* @param {string} [gatheredContext] - The gathered context for the analysis.
* @returns {string} The generated prompt.
*/
function generateInternalComplexityAnalysisPrompt(tasksData) {
function generateInternalComplexityAnalysisPrompt(
tasksData,
gatheredContext = ''
) {
const tasksString = JSON.stringify(tasksData.tasks, null, 2);
return `Analyze the following tasks to determine their complexity (1-10 scale) and recommend the number of subtasks for expansion. Provide a brief reasoning and an initial expansion prompt for each.
let prompt = `Analyze the following tasks to determine their complexity (1-10 scale) and recommend the number of subtasks for expansion. Provide a brief reasoning and an initial expansion prompt for each.
Tasks:
${tasksString}
${tasksString}`;
if (gatheredContext) {
prompt += `\n\n# Project Context\n\n${gatheredContext}`;
}
prompt += `
Respond ONLY with a valid JSON array matching the schema:
[
@@ -46,6 +59,7 @@ Respond ONLY with a valid JSON array matching the schema:
]
Do not include any explanatory text, markdown formatting, or code block markers before or after the JSON array.`;
return prompt;
}
/**
@@ -73,6 +87,7 @@ async function analyzeTaskComplexity(options, context = {}) {
const thresholdScore = parseFloat(options.threshold || '5');
const useResearch = options.research || false;
const projectRoot = options.projectRoot;
const tag = options.tag;
// New parameters for task ID filtering
const specificIds = options.id
? options.id
@@ -112,7 +127,7 @@ async function analyzeTaskComplexity(options, context = {}) {
originalTaskCount = options._originalTaskCount || tasksData.tasks.length;
if (!options._originalTaskCount) {
try {
originalData = readJSON(tasksPath);
originalData = readJSON(tasksPath, projectRoot, tag);
if (originalData && originalData.tasks) {
originalTaskCount = originalData.tasks.length;
}
@@ -121,7 +136,7 @@ async function analyzeTaskComplexity(options, context = {}) {
}
}
} else {
originalData = readJSON(tasksPath);
originalData = readJSON(tasksPath, projectRoot, tag);
if (
!originalData ||
!originalData.tasks ||
@@ -200,6 +215,41 @@ async function analyzeTaskComplexity(options, context = {}) {
};
}
// --- Context Gathering ---
let gatheredContext = '';
if (originalData && originalData.tasks.length > 0) {
try {
const contextGatherer = new ContextGatherer(projectRoot);
const allTasksFlat = flattenTasksWithSubtasks(originalData.tasks);
const fuzzySearch = new FuzzyTaskSearch(
allTasksFlat,
'analyze-complexity'
);
// Create a query from the tasks being analyzed
const searchQuery = tasksData.tasks
.map((t) => `${t.title} ${t.description}`)
.join(' ');
const searchResults = fuzzySearch.findRelevantTasks(searchQuery, {
maxResults: 10
});
const relevantTaskIds = fuzzySearch.getTaskIds(searchResults);
if (relevantTaskIds.length > 0) {
const contextResult = await contextGatherer.gather({
tasks: relevantTaskIds,
format: 'research'
});
gatheredContext = contextResult;
}
} catch (contextError) {
reportLog(
`Could not gather additional context: ${contextError.message}`,
'warn'
);
}
}
// --- End Context Gathering ---
const skippedCount = originalTaskCount - tasksData.tasks.length;
reportLog(
`Found ${originalTaskCount} total tasks in the task file.`,
@@ -226,10 +276,10 @@ async function analyzeTaskComplexity(options, context = {}) {
// Check for existing report before doing analysis
let existingReport = null;
let existingAnalysisMap = new Map(); // For quick lookups by task ID
const existingAnalysisMap = new Map(); // For quick lookups by task ID
try {
if (fs.existsSync(outputPath)) {
existingReport = readJSON(outputPath);
existingReport = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
reportLog(`Found existing complexity report at ${outputPath}`, 'info');
if (
@@ -260,13 +310,13 @@ async function analyzeTaskComplexity(options, context = {}) {
// If using ID filtering but no matching tasks, return existing report or empty
if (existingReport && (specificIds || fromId !== null || toId !== null)) {
reportLog(
`No matching tasks found for analysis. Keeping existing report.`,
'No matching tasks found for analysis. Keeping existing report.',
'info'
);
if (outputFormat === 'text') {
console.log(
chalk.yellow(
`No matching tasks found for analysis. Keeping existing report.`
'No matching tasks found for analysis. Keeping existing report.'
)
);
}
@@ -288,7 +338,11 @@ async function analyzeTaskComplexity(options, context = {}) {
complexityAnalysis: existingReport?.complexityAnalysis || []
};
reportLog(`Writing complexity report to ${outputPath}...`, 'info');
writeJSON(outputPath, emptyReport);
fs.writeFileSync(
outputPath,
JSON.stringify(emptyReport, null, '\t'),
'utf8'
);
reportLog(
`Task complexity analysis complete. Report written to ${outputPath}`,
'success'
@@ -342,7 +396,10 @@ async function analyzeTaskComplexity(options, context = {}) {
}
// Continue with regular analysis path
const prompt = generateInternalComplexityAnalysisPrompt(tasksData);
const prompt = generateInternalComplexityAnalysisPrompt(
tasksData,
gatheredContext
);
const systemPrompt =
'You are an expert software architect and project manager analyzing task complexity. Respond only with the requested valid JSON array.';
@@ -381,7 +438,7 @@ async function analyzeTaskComplexity(options, context = {}) {
);
}
reportLog(`Parsing complexity analysis from text response...`, 'info');
reportLog('Parsing complexity analysis from text response...', 'info');
try {
let cleanedResponse = aiServiceResponse.mainResult;
cleanedResponse = cleanedResponse.trim();
@@ -512,7 +569,7 @@ async function analyzeTaskComplexity(options, context = {}) {
complexityAnalysis: finalComplexityAnalysis
};
reportLog(`Writing complexity report to ${outputPath}...`, 'info');
writeJSON(outputPath, report);
fs.writeFileSync(outputPath, JSON.stringify(report, null, '\t'), 'utf8');
reportLog(
`Task complexity analysis complete. Report written to ${outputPath}`,
@@ -592,7 +649,8 @@ async function analyzeTaskComplexity(options, context = {}) {
return {
report: report,
telemetryData: aiServiceResponse?.telemetryData
telemetryData: aiServiceResponse?.telemetryData,
tagInfo: aiServiceResponse?.tagInfo
};
} catch (aiError) {
if (loadingIndicator) stopLoadingIndicator(loadingIndicator);

View File

@@ -11,10 +11,12 @@ import generateTaskFiles from './generate-task-files.js';
* Clear subtasks from specified tasks
* @param {string} tasksPath - Path to the tasks.json file
* @param {string} taskIds - Task IDs to clear subtasks from
* @param {Object} context - Context object containing projectRoot and tag
*/
function clearSubtasks(tasksPath, taskIds) {
function clearSubtasks(tasksPath, taskIds, context = {}) {
const { projectRoot, tag } = context;
log('info', `Reading tasks from ${tasksPath}...`);
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, projectRoot, tag);
if (!data || !data.tasks) {
log('error', 'No valid tasks found.');
process.exit(1);
@@ -48,7 +50,7 @@ function clearSubtasks(tasksPath, taskIds) {
taskIdArray.forEach((taskId) => {
const id = parseInt(taskId, 10);
if (isNaN(id)) {
if (Number.isNaN(id)) {
log('error', `Invalid task ID: ${taskId}`);
return;
}
@@ -82,7 +84,7 @@ function clearSubtasks(tasksPath, taskIds) {
});
if (clearedCount > 0) {
writeJSON(tasksPath, data);
writeJSON(tasksPath, data, projectRoot, tag);
// Show summary table
if (!isSilentMode()) {
@@ -99,7 +101,7 @@ function clearSubtasks(tasksPath, taskIds) {
// Regenerate task files to reflect changes
log('info', 'Regenerating task files...');
generateTaskFiles(tasksPath, path.dirname(tasksPath));
generateTaskFiles(tasksPath, path.dirname(tasksPath), { projectRoot, tag });
// Success message
if (!isSilentMode()) {

View File

@@ -1,4 +1,4 @@
import { log, readJSON, isSilentMode } from '../utils.js';
import { log, readJSON, isSilentMode, findProjectRoot } from '../utils.js';
import {
startLoadingIndicator,
stopLoadingIndicator,
@@ -32,9 +32,14 @@ async function expandAllTasks(
context = {},
outputFormat = 'text' // Assume text default for CLI
) {
const { session, mcpLog } = context;
const { session, mcpLog, projectRoot: providedProjectRoot } = context;
const isMCPCall = !!mcpLog; // Determine if called from MCP
const projectRoot = providedProjectRoot || findProjectRoot();
if (!projectRoot) {
throw new Error('Could not determine project root directory');
}
// Use mcpLog if available, otherwise use the default console log wrapper respecting silent mode
const logger =
mcpLog ||
@@ -69,7 +74,7 @@ async function expandAllTasks(
try {
logger.info(`Reading tasks from ${tasksPath}`);
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, projectRoot);
if (!data || !data.tasks) {
throw new Error(`Invalid tasks data in ${tasksPath}`);
}
@@ -119,7 +124,7 @@ async function expandAllTasks(
numSubtasks,
useResearch,
additionalContext,
context, // Pass the whole context object { session, mcpLog }
{ ...context, projectRoot }, // Pass the whole context object with projectRoot
force
);
expandedCount++;

View File

@@ -15,7 +15,9 @@ import { generateTextService } from '../ai-services-unified.js';
import { getDefaultSubtasks, getDebugFlag } from '../config-manager.js';
import generateTaskFiles from './generate-task-files.js';
import { COMPLEXITY_REPORT_FILE } from '../../../src/constants/paths.js';
import { findProjectRoot } from '../../../src/utils/path-utils.js';
import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
import { flattenTasksWithSubtasks, findProjectRoot } from '../utils.js';
// --- Zod Schemas (Keep from previous step) ---
const subtaskSchema = z
@@ -286,9 +288,9 @@ function parseSubtasksFromText(
const patternStartIndex = jsonToParse.indexOf(targetPattern);
if (patternStartIndex !== -1) {
let openBraces = 0;
let firstBraceFound = false;
let extractedJsonBlock = '';
const openBraces = 0;
const firstBraceFound = false;
const extractedJsonBlock = '';
// ... (loop for brace counting as before) ...
// ... (if successful, jsonToParse = extractedJsonBlock) ...
// ... (if that fails, fallbacks as before) ...
@@ -350,7 +352,8 @@ function parseSubtasksFromText(
? rawSubtask.dependencies
.map((dep) => (typeof dep === 'string' ? parseInt(dep, 10) : dep))
.filter(
(depId) => !isNaN(depId) && depId >= startId && depId < currentId
(depId) =>
!Number.isNaN(depId) && depId >= startId && depId < currentId
)
: [],
status: 'pending'
@@ -436,7 +439,7 @@ async function expandTask(
try {
// --- Task Loading/Filtering (Unchanged) ---
logger.info(`Reading tasks from ${tasksPath}`);
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, projectRoot);
if (!data || !data.tasks)
throw new Error(`Invalid tasks data in ${tasksPath}`);
const taskIndex = data.tasks.findIndex(
@@ -458,6 +461,35 @@ async function expandTask(
}
// --- End Force Flag Handling ---
// --- Context Gathering ---
let gatheredContext = '';
try {
const contextGatherer = new ContextGatherer(projectRoot);
const allTasksFlat = flattenTasksWithSubtasks(data.tasks);
const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'expand-task');
const searchQuery = `${task.title} ${task.description}`;
const searchResults = fuzzySearch.findRelevantTasks(searchQuery, {
maxResults: 5,
includeSelf: true
});
const relevantTaskIds = fuzzySearch.getTaskIds(searchResults);
const finalTaskIds = [
...new Set([taskId.toString(), ...relevantTaskIds])
];
if (finalTaskIds.length > 0) {
const contextResult = await contextGatherer.gather({
tasks: finalTaskIds,
format: 'research'
});
gatheredContext = contextResult;
}
} catch (contextError) {
logger.warn(`Could not gather context: ${contextError.message}`);
}
// --- End Context Gathering ---
// --- Complexity Report Integration ---
let finalSubtaskCount;
let promptContent = '';
@@ -498,7 +530,7 @@ async function expandTask(
// Determine final subtask count
const explicitNumSubtasks = parseInt(numSubtasks, 10);
if (!isNaN(explicitNumSubtasks) && explicitNumSubtasks > 0) {
if (!Number.isNaN(explicitNumSubtasks) && explicitNumSubtasks > 0) {
finalSubtaskCount = explicitNumSubtasks;
logger.info(
`Using explicitly provided subtask count: ${finalSubtaskCount}`
@@ -512,7 +544,7 @@ async function expandTask(
finalSubtaskCount = getDefaultSubtasks(session);
logger.info(`Using default number of subtasks: ${finalSubtaskCount}`);
}
if (isNaN(finalSubtaskCount) || finalSubtaskCount <= 0) {
if (Number.isNaN(finalSubtaskCount) || finalSubtaskCount <= 0) {
logger.warn(
`Invalid subtask count determined (${finalSubtaskCount}), defaulting to 3.`
);
@@ -528,6 +560,9 @@ async function expandTask(
// Append additional context and reasoning
promptContent += `\n\n${additionalContext}`.trim();
promptContent += `${complexityReasoningContext}`.trim();
if (gatheredContext) {
promptContent += `\n\n# Project Context\n\n${gatheredContext}`;
}
// --- Use Simplified System Prompt for Report Prompts ---
systemPrompt = `You are an AI assistant helping with task breakdown. Generate exactly ${finalSubtaskCount} subtasks based on the provided prompt and context. Respond ONLY with a valid JSON object containing a single key "subtasks" whose value is an array of the generated subtask objects. Each subtask object in the array must have keys: "id", "title", "description", "dependencies", "details", "status". Ensure the 'id' starts from ${nextSubtaskId} and is sequential. Ensure 'dependencies' only reference valid prior subtask IDs generated in this response (starting from ${nextSubtaskId}). Ensure 'status' is 'pending'. Do not include any other text or explanation.`;
@@ -537,8 +572,13 @@ async function expandTask(
// --- End Simplified System Prompt ---
} else {
// Use standard prompt generation
const combinedAdditionalContext =
let combinedAdditionalContext =
`${additionalContext}${complexityReasoningContext}`.trim();
if (gatheredContext) {
combinedAdditionalContext =
`${combinedAdditionalContext}\n\n# Project Context\n\n${gatheredContext}`.trim();
}
if (useResearch) {
promptContent = generateResearchUserPrompt(
task,
@@ -643,7 +683,8 @@ async function expandTask(
// Return the updated task object AND telemetry data
return {
task,
telemetryData: aiServiceResponse?.telemetryData
telemetryData: aiServiceResponse?.telemetryData,
tagInfo: aiServiceResponse?.tagInfo
};
} catch (error) {
// Catches errors from file reading, parsing, AI call etc.

View File

@@ -11,108 +11,131 @@ import { getDebugFlag } from '../config-manager.js';
* Generate individual task files from tasks.json
* @param {string} tasksPath - Path to the tasks.json file
* @param {string} outputDir - Output directory for task files
* @param {Object} options - Additional options (mcpLog for MCP mode)
* @param {Object} options - Additional options (mcpLog for MCP mode, projectRoot, tag)
* @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode
*/
function generateTaskFiles(tasksPath, outputDir, options = {}) {
try {
// Determine if we're in MCP mode by checking for mcpLog
const isMcpMode = !!options?.mcpLog;
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
// 1. Read the raw data structure, ensuring we have all tags.
// We call readJSON without a specific tag to get the resolved default view,
// which correctly contains the full structure in `_rawTaggedData`.
const resolvedData = readJSON(tasksPath, options.projectRoot);
if (!resolvedData) {
throw new Error(`Could not read or parse tasks file: ${tasksPath}`);
}
// Prioritize the _rawTaggedData if it exists, otherwise use the data as is.
const rawData = resolvedData._rawTaggedData || resolvedData;
// 2. Determine the target tag we need to generate files for.
const targetTag = options.tag || resolvedData.tag || 'master';
const tagData = rawData[targetTag];
if (!tagData || !tagData.tasks) {
throw new Error(
`Tag '${targetTag}' not found or has no tasks in the data.`
);
}
const tasksForGeneration = tagData.tasks;
// Create the output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
log('info', `Preparing to regenerate ${data.tasks.length} task files`);
log(
'info',
`Preparing to regenerate ${tasksForGeneration.length} task files for tag '${targetTag}'`
);
// Validate and fix dependencies before generating files
log('info', `Validating and fixing dependencies`);
validateAndFixDependencies(data, tasksPath);
// 3. Validate dependencies using the FULL, raw data structure to prevent data loss.
validateAndFixDependencies(
rawData, // Pass the entire object with all tags
tasksPath,
options.projectRoot,
targetTag // Provide the current tag context for the operation
);
// Get valid task IDs from tasks.json
const validTaskIds = data.tasks.map((task) => task.id);
const allTasksInTag = tagData.tasks;
const validTaskIds = allTasksInTag.map((task) => task.id);
// Cleanup orphaned task files
log('info', 'Checking for orphaned task files to clean up...');
try {
// Get all task files in the output directory
const files = fs.readdirSync(outputDir);
const taskFilePattern = /^task_(\d+)\.txt$/;
// Tag-aware file patterns: master -> task_001.txt, other tags -> task_001_tagname.txt
const masterFilePattern = /^task_(\d+)\.txt$/;
const taggedFilePattern = new RegExp(`^task_(\\d+)_${targetTag}\\.txt$`);
// Filter for task files and check if they match a valid task ID
const orphanedFiles = files.filter((file) => {
const match = file.match(taskFilePattern);
if (match) {
const fileTaskId = parseInt(match[1], 10);
return !validTaskIds.includes(fileTaskId);
let match = null;
let fileTaskId = null;
// Check if file belongs to current tag
if (targetTag === 'master') {
match = file.match(masterFilePattern);
if (match) {
fileTaskId = parseInt(match[1], 10);
// Only clean up master files when processing master tag
return !validTaskIds.includes(fileTaskId);
}
} else {
match = file.match(taggedFilePattern);
if (match) {
fileTaskId = parseInt(match[1], 10);
// Only clean up files for the current tag
return !validTaskIds.includes(fileTaskId);
}
}
return false;
});
// Delete orphaned files
if (orphanedFiles.length > 0) {
log(
'info',
`Found ${orphanedFiles.length} orphaned task files to remove`
`Found ${orphanedFiles.length} orphaned task files to remove for tag '${targetTag}'`
);
orphanedFiles.forEach((file) => {
const filePath = path.join(outputDir, file);
try {
fs.unlinkSync(filePath);
log('info', `Removed orphaned task file: ${file}`);
} catch (err) {
log(
'warn',
`Failed to remove orphaned task file ${file}: ${err.message}`
);
}
fs.unlinkSync(filePath);
});
} else {
log('info', 'No orphaned task files found');
log('info', 'No orphaned task files found.');
}
} catch (err) {
log('warn', `Error cleaning up orphaned task files: ${err.message}`);
// Continue with file generation even if cleanup fails
}
// Generate task files
log('info', 'Generating individual task files...');
data.tasks.forEach((task) => {
const taskPath = path.join(
outputDir,
`task_${task.id.toString().padStart(3, '0')}.txt`
);
// Generate task files for the target tag
log('info', `Generating individual task files for tag '${targetTag}'...`);
tasksForGeneration.forEach((task) => {
// Tag-aware file naming: master -> task_001.txt, other tags -> task_001_tagname.txt
const taskFileName =
targetTag === 'master'
? `task_${task.id.toString().padStart(3, '0')}.txt`
: `task_${task.id.toString().padStart(3, '0')}_${targetTag}.txt`;
const taskPath = path.join(outputDir, taskFileName);
// Format the content
let content = `# Task ID: ${task.id}\n`;
content += `# Title: ${task.title}\n`;
content += `# Status: ${task.status || 'pending'}\n`;
// Format dependencies with their status
if (task.dependencies && task.dependencies.length > 0) {
content += `# Dependencies: ${formatDependenciesWithStatus(task.dependencies, data.tasks, false)}\n`;
content += `# Dependencies: ${formatDependenciesWithStatus(task.dependencies, allTasksInTag, false)}\n`;
} else {
content += '# Dependencies: None\n';
}
content += `# Priority: ${task.priority || 'medium'}\n`;
content += `# Description: ${task.description || ''}\n`;
// Add more detailed sections
content += '# Details:\n';
content += (task.details || '')
.split('\n')
.map((line) => line)
.join('\n');
content += '\n\n';
content += '# Test Strategy:\n';
content += (task.testStrategy || '')
.split('\n')
@@ -120,36 +143,22 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) {
.join('\n');
content += '\n';
// Add subtasks if they exist
if (task.subtasks && task.subtasks.length > 0) {
content += '\n# Subtasks:\n';
task.subtasks.forEach((subtask) => {
content += `## ${subtask.id}. ${subtask.title} [${subtask.status || 'pending'}]\n`;
if (subtask.dependencies && subtask.dependencies.length > 0) {
// Format subtask dependencies
let subtaskDeps = subtask.dependencies
.map((depId) => {
if (typeof depId === 'number') {
// Handle numeric dependencies to other subtasks
const foundSubtask = task.subtasks.find(
(st) => st.id === depId
);
if (foundSubtask) {
// Just return the plain ID format without any color formatting
return `${task.id}.${depId}`;
}
}
return depId.toString();
})
const subtaskDeps = subtask.dependencies
.map((depId) =>
typeof depId === 'number'
? `${task.id}.${depId}`
: depId.toString()
)
.join(', ');
content += `### Dependencies: ${subtaskDeps}\n`;
} else {
content += '### Dependencies: None\n';
}
content += `### Description: ${subtask.description || ''}\n`;
content += '### Details:\n';
content += (subtask.details || '')
@@ -160,39 +169,30 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) {
});
}
// Write the file
fs.writeFileSync(taskPath, content);
// log('info', `Generated: task_${task.id.toString().padStart(3, '0')}.txt`); // Pollutes the CLI output
});
log(
'success',
`All ${data.tasks.length} tasks have been generated into '${outputDir}'.`
`All ${tasksForGeneration.length} tasks for tag '${targetTag}' have been generated into '${outputDir}'.`
);
// Return success data in MCP mode
if (isMcpMode) {
return {
success: true,
count: data.tasks.length,
count: tasksForGeneration.length,
directory: outputDir
};
}
} catch (error) {
log('error', `Error generating task files: ${error.message}`);
// Only show error UI in CLI mode
if (!options?.mcpLog) {
console.error(chalk.red(`Error generating task files: ${error.message}`));
if (getDebugFlag()) {
// Use getter
console.error(error);
}
process.exit(1);
} else {
// In MCP mode, throw the error for the caller to handle
throw error;
}
}

View File

@@ -22,10 +22,12 @@ import {
/**
* List all tasks
* @param {string} tasksPath - Path to the tasks.json file
* @param {string} statusFilter - Filter by status
* @param {string} statusFilter - Filter by status (single status or comma-separated list, e.g., 'pending' or 'blocked,deferred')
* @param {string} reportPath - Path to the complexity report
* @param {boolean} withSubtasks - Whether to show subtasks
* @param {string} outputFormat - Output format (text or json)
* @param {string} tag - Optional tag to override current tag resolution
* @param {Object} context - Optional context object containing projectRoot and other options
* @returns {Object} - Task list result for json format
*/
function listTasks(
@@ -33,10 +35,14 @@ function listTasks(
statusFilter,
reportPath = null,
withSubtasks = false,
outputFormat = 'text'
outputFormat = 'text',
tag = null,
context = {}
) {
try {
const data = readJSON(tasksPath); // Reads the whole tasks.json
// Extract projectRoot from context if provided
const projectRoot = context.projectRoot || null;
const data = readJSON(tasksPath, projectRoot, tag); // Pass projectRoot to readJSON
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
}
@@ -48,15 +54,23 @@ function listTasks(
data.tasks.forEach((task) => addComplexityToTask(task, complexityReport));
}
// Filter tasks by status if specified
const filteredTasks =
statusFilter && statusFilter.toLowerCase() !== 'all' // <-- Added check for 'all'
? data.tasks.filter(
(task) =>
task.status &&
task.status.toLowerCase() === statusFilter.toLowerCase()
)
: data.tasks; // Default to all tasks if no filter or filter is 'all'
// Filter tasks by status if specified - now supports comma-separated statuses
let filteredTasks;
if (statusFilter && statusFilter.toLowerCase() !== 'all') {
// Handle comma-separated statuses
const allowedStatuses = statusFilter
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0); // Remove empty strings
filteredTasks = data.tasks.filter(
(task) =>
task.status && allowedStatuses.includes(task.status.toLowerCase())
);
} else {
// Default to all tasks if no filter or filter is 'all'
filteredTasks = data.tasks;
}
// Calculate completion statistics
const totalTasks = data.tasks.length;
@@ -83,6 +97,9 @@ function listTasks(
const cancelledCount = data.tasks.filter(
(task) => task.status === 'cancelled'
).length;
const reviewCount = data.tasks.filter(
(task) => task.status === 'review'
).length;
// Count subtasks and their statuses
let totalSubtasks = 0;
@@ -92,6 +109,7 @@ function listTasks(
let blockedSubtasks = 0;
let deferredSubtasks = 0;
let cancelledSubtasks = 0;
let reviewSubtasks = 0;
data.tasks.forEach((task) => {
if (task.subtasks && task.subtasks.length > 0) {
@@ -114,6 +132,9 @@ function listTasks(
cancelledSubtasks += task.subtasks.filter(
(st) => st.status === 'cancelled'
).length;
reviewSubtasks += task.subtasks.filter(
(st) => st.status === 'review'
).length;
}
});
@@ -222,6 +243,7 @@ function listTasks(
blocked: blockedCount,
deferred: deferredCount,
cancelled: cancelledCount,
review: reviewCount,
completionPercentage,
subtasks: {
total: totalSubtasks,
@@ -257,6 +279,7 @@ function listTasks(
blockedSubtasks,
deferredSubtasks,
cancelledSubtasks,
reviewSubtasks,
tasksWithNoDeps,
tasksReadyToWork,
tasksWithUnsatisfiedDeps,
@@ -278,7 +301,8 @@ function listTasks(
pending: totalTasks > 0 ? (pendingCount / totalTasks) * 100 : 0,
blocked: totalTasks > 0 ? (blockedCount / totalTasks) * 100 : 0,
deferred: totalTasks > 0 ? (deferredCount / totalTasks) * 100 : 0,
cancelled: totalTasks > 0 ? (cancelledCount / totalTasks) * 100 : 0
cancelled: totalTasks > 0 ? (cancelledCount / totalTasks) * 100 : 0,
review: totalTasks > 0 ? (reviewCount / totalTasks) * 100 : 0
};
const subtaskStatusBreakdown = {
@@ -289,7 +313,8 @@ function listTasks(
deferred:
totalSubtasks > 0 ? (deferredSubtasks / totalSubtasks) * 100 : 0,
cancelled:
totalSubtasks > 0 ? (cancelledSubtasks / totalSubtasks) * 100 : 0
totalSubtasks > 0 ? (cancelledSubtasks / totalSubtasks) * 100 : 0,
review: totalSubtasks > 0 ? (reviewSubtasks / totalSubtasks) * 100 : 0
};
// Create progress bars with status breakdowns

View File

@@ -1,571 +1,488 @@
import path from 'path';
import { log, readJSON, writeJSON } from '../utils.js';
import {
log,
readJSON,
writeJSON,
getCurrentTag,
setTasksForTag
} from '../utils.js';
import { isTaskDependentOn } from '../task-manager.js';
import generateTaskFiles from './generate-task-files.js';
/**
* Move a task or subtask to a new position
* Move one or more tasks/subtasks to new positions
* @param {string} tasksPath - Path to tasks.json file
* @param {string} sourceId - ID of the task/subtask to move (e.g., '5' or '5.2')
* @param {string} destinationId - ID of the destination (e.g., '7' or '7.3')
* @param {string} sourceId - ID(s) of the task/subtask to move (e.g., '5' or '5.2' or '5,6,7')
* @param {string} destinationId - ID(s) of the destination (e.g., '7' or '7.3' or '7,8,9')
* @param {boolean} generateFiles - Whether to regenerate task files after moving
* @param {Object} options - Additional options
* @param {string} options.projectRoot - Project root directory for tag resolution
* @param {string} options.tag - Explicit tag to use (optional)
* @returns {Object} Result object with moved task details
*/
async function moveTask(
tasksPath,
sourceId,
destinationId,
generateFiles = true
generateFiles = false,
options = {}
) {
try {
log('info', `Moving task/subtask ${sourceId} to ${destinationId}...`);
// Check if we have comma-separated IDs (batch move)
const sourceIds = sourceId.split(',').map((id) => id.trim());
const destinationIds = destinationId.split(',').map((id) => id.trim());
// Read the existing tasks
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
}
if (sourceIds.length !== destinationIds.length) {
throw new Error(
`Number of source IDs (${sourceIds.length}) must match number of destination IDs (${destinationIds.length})`
);
}
// Parse source ID to determine if it's a task or subtask
const isSourceSubtask = sourceId.includes('.');
let sourceTask,
sourceParentTask,
sourceSubtask,
sourceTaskIndex,
sourceSubtaskIndex;
// Parse destination ID to determine the target
const isDestinationSubtask = destinationId.includes('.');
let destTask, destParentTask, destSubtask, destTaskIndex, destSubtaskIndex;
// Validate source exists
if (isSourceSubtask) {
// Source is a subtask
const [parentIdStr, subtaskIdStr] = sourceId.split('.');
const parentIdNum = parseInt(parentIdStr, 10);
const subtaskIdNum = parseInt(subtaskIdStr, 10);
sourceParentTask = data.tasks.find((t) => t.id === parentIdNum);
if (!sourceParentTask) {
throw new Error(`Source parent task with ID ${parentIdNum} not found`);
}
if (
!sourceParentTask.subtasks ||
sourceParentTask.subtasks.length === 0
) {
throw new Error(`Source parent task ${parentIdNum} has no subtasks`);
}
sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
(st) => st.id === subtaskIdNum
// For batch moves, process each pair sequentially
if (sourceIds.length > 1) {
const results = [];
for (let i = 0; i < sourceIds.length; i++) {
const result = await moveTask(
tasksPath,
sourceIds[i],
destinationIds[i],
false, // Don't generate files for each individual move
options
);
if (sourceSubtaskIndex === -1) {
throw new Error(`Source subtask ${sourceId} not found`);
}
sourceSubtask = { ...sourceParentTask.subtasks[sourceSubtaskIndex] };
} else {
// Source is a task
const sourceIdNum = parseInt(sourceId, 10);
sourceTaskIndex = data.tasks.findIndex((t) => t.id === sourceIdNum);
if (sourceTaskIndex === -1) {
throw new Error(`Source task with ID ${sourceIdNum} not found`);
}
sourceTask = { ...data.tasks[sourceTaskIndex] };
results.push(result);
}
// Validate destination exists
if (isDestinationSubtask) {
// Destination is a subtask (target will be the parent of this subtask)
const [parentIdStr, subtaskIdStr] = destinationId.split('.');
const parentIdNum = parseInt(parentIdStr, 10);
const subtaskIdNum = parseInt(subtaskIdStr, 10);
destParentTask = data.tasks.find((t) => t.id === parentIdNum);
if (!destParentTask) {
throw new Error(
`Destination parent task with ID ${parentIdNum} not found`
);
}
if (!destParentTask.subtasks || destParentTask.subtasks.length === 0) {
throw new Error(
`Destination parent task ${parentIdNum} has no subtasks`
);
}
destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === subtaskIdNum
);
if (destSubtaskIndex === -1) {
throw new Error(`Destination subtask ${destinationId} not found`);
}
destSubtask = destParentTask.subtasks[destSubtaskIndex];
} else {
// Destination is a task
const destIdNum = parseInt(destinationId, 10);
destTaskIndex = data.tasks.findIndex((t) => t.id === destIdNum);
if (destTaskIndex === -1) {
// Create placeholder for destination if it doesn't exist
log('info', `Creating placeholder for destination task ${destIdNum}`);
const newTask = {
id: destIdNum,
title: `Task ${destIdNum}`,
description: '',
status: 'pending',
priority: 'medium',
details: '',
testStrategy: ''
};
// Find correct position to insert the new task
let insertIndex = 0;
while (
insertIndex < data.tasks.length &&
data.tasks[insertIndex].id < destIdNum
) {
insertIndex++;
}
// Insert the new task at the appropriate position
data.tasks.splice(insertIndex, 0, newTask);
destTaskIndex = insertIndex;
destTask = data.tasks[destTaskIndex];
} else {
destTask = data.tasks[destTaskIndex];
// Check if destination task is already a "real" task with content
// Only allow moving to destination IDs that don't have meaningful content
if (
destTask.title !== `Task ${destTask.id}` ||
destTask.description !== '' ||
destTask.details !== ''
) {
throw new Error(
`Cannot move to task ID ${destIdNum} as it already contains content. Choose a different destination ID.`
);
}
}
}
// Validate that we aren't trying to move a task to itself
if (sourceId === destinationId) {
throw new Error('Cannot move a task/subtask to itself');
}
// Prevent moving a parent to its own subtask
if (!isSourceSubtask && isDestinationSubtask) {
const destParentId = parseInt(destinationId.split('.')[0], 10);
if (parseInt(sourceId, 10) === destParentId) {
throw new Error('Cannot move a parent task to one of its own subtasks');
}
}
// Check for circular dependency when moving tasks
if (!isSourceSubtask && !isDestinationSubtask) {
const sourceIdNum = parseInt(sourceId, 10);
const destIdNum = parseInt(destinationId, 10);
// Check if destination is dependent on source
if (isTaskDependentOn(data.tasks, destTask, sourceIdNum)) {
throw new Error(
`Cannot move task ${sourceId} to task ${destinationId} as it would create a circular dependency`
);
}
}
let movedTask;
// Handle different move scenarios
if (!isSourceSubtask && !isDestinationSubtask) {
// Check if destination is a placeholder we just created
if (
destTask.title === `Task ${destTask.id}` &&
destTask.description === '' &&
destTask.details === ''
) {
// Case 0: Move task to a new position/ID (destination is a placeholder)
movedTask = moveTaskToNewId(
data,
sourceTask,
sourceTaskIndex,
destTask,
destTaskIndex
);
} else {
// Case 1: Move standalone task to become a subtask of another task
movedTask = moveTaskToTask(data, sourceTask, sourceTaskIndex, destTask);
}
} else if (!isSourceSubtask && isDestinationSubtask) {
// Case 2: Move standalone task to become a subtask at a specific position
movedTask = moveTaskToSubtaskPosition(
data,
sourceTask,
sourceTaskIndex,
destParentTask,
destSubtaskIndex
);
} else if (isSourceSubtask && !isDestinationSubtask) {
// Case 3: Move subtask to become a standalone task
movedTask = moveSubtaskToTask(
data,
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destTask
);
} else if (isSourceSubtask && isDestinationSubtask) {
// Case 4: Move subtask to another parent or position
// First check if it's the same parent
const sourceParentId = parseInt(sourceId.split('.')[0], 10);
const destParentId = parseInt(destinationId.split('.')[0], 10);
if (sourceParentId === destParentId) {
// Case 4a: Move subtask within the same parent (reordering)
movedTask = reorderSubtask(
sourceParentTask,
sourceSubtaskIndex,
destSubtaskIndex
);
} else {
// Case 4b: Move subtask to a different parent
movedTask = moveSubtaskToAnotherParent(
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destParentTask,
destSubtaskIndex
);
}
}
// Write the updated tasks back to the file
writeJSON(tasksPath, data);
// Generate task files if requested
// Generate files once at the end if requested
if (generateFiles) {
log('info', 'Regenerating task files...');
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
}
return movedTask;
} catch (error) {
log('error', `Error moving task/subtask: ${error.message}`);
throw error;
}
}
/**
* Move a standalone task to become a subtask of another task
* @param {Object} data - Tasks data object
* @param {Object} sourceTask - Source task to move
* @param {number} sourceTaskIndex - Index of source task in data.tasks
* @param {Object} destTask - Destination task
* @returns {Object} Moved task object
*/
function moveTaskToTask(data, sourceTask, sourceTaskIndex, destTask) {
// Initialize subtasks array if it doesn't exist
if (!destTask.subtasks) {
destTask.subtasks = [];
return {
message: `Successfully moved ${sourceIds.length} tasks/subtasks`,
moves: results
};
}
// Find the highest subtask ID to determine the next ID
const highestSubtaskId =
destTask.subtasks.length > 0
? Math.max(...destTask.subtasks.map((st) => st.id))
: 0;
const newSubtaskId = highestSubtaskId + 1;
// Single move logic
// Read the raw data without tag resolution to preserve tagged structure
let rawData = readJSON(tasksPath, options.projectRoot); // No tag parameter
// Create the new subtask from the source task
const newSubtask = {
...sourceTask,
id: newSubtaskId,
parentTaskId: destTask.id
};
// 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;
}
// Add to destination's subtasks
destTask.subtasks.push(newSubtask);
// Determine the current tag
const currentTag =
options.tag || getCurrentTag(options.projectRoot) || 'master';
// Remove the original task from the tasks array
data.tasks.splice(sourceTaskIndex, 1);
// 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',
`Moved task ${sourceTask.id} to become subtask ${destTask.id}.${newSubtaskId}`
`Moving task/subtask ${sourceId} to ${destinationId} (tag: ${currentTag})`
);
return newSubtask;
// Parse source and destination IDs
const isSourceSubtask = sourceId.includes('.');
const isDestSubtask = destinationId.includes('.');
let result;
if (isSourceSubtask && isDestSubtask) {
// Subtask to subtask
result = moveSubtaskToSubtask(tasks, sourceId, destinationId);
} else if (isSourceSubtask && !isDestSubtask) {
// Subtask to task
result = moveSubtaskToTask(tasks, sourceId, destinationId);
} else if (!isSourceSubtask && isDestSubtask) {
// Task to subtask
result = moveTaskToSubtask(tasks, sourceId, destinationId);
} else {
// Task to task
result = moveTaskToTask(tasks, sourceId, destinationId);
}
// Update the data structure with the modified tasks
rawData[currentTag].tasks = tasks;
// 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));
}
return result;
}
/**
* Move a standalone task to become a subtask at a specific position
* @param {Object} data - Tasks data object
* @param {Object} sourceTask - Source task to move
* @param {number} sourceTaskIndex - Index of source task in data.tasks
* @param {Object} destParentTask - Destination parent task
* @param {number} destSubtaskIndex - Index of the subtask before which to insert
* @returns {Object} Moved task object
*/
function moveTaskToSubtaskPosition(
data,
sourceTask,
sourceTaskIndex,
destParentTask,
destSubtaskIndex
) {
// Initialize subtasks array if it doesn't exist
// Helper functions for different move scenarios
function moveSubtaskToSubtask(tasks, sourceId, destinationId) {
// Parse IDs
const [sourceParentId, sourceSubtaskId] = sourceId
.split('.')
.map((id) => parseInt(id, 10));
const [destParentId, destSubtaskId] = destinationId
.split('.')
.map((id) => parseInt(id, 10));
// Find source and destination parent tasks
const sourceParentTask = tasks.find((t) => t.id === sourceParentId);
const destParentTask = tasks.find((t) => t.id === destParentId);
if (!sourceParentTask) {
throw new Error(`Source parent task with ID ${sourceParentId} not found`);
}
if (!destParentTask) {
throw new Error(
`Destination parent task with ID ${destParentId} not found`
);
}
// Initialize subtasks arrays if they don't exist (based on commit fixes)
if (!sourceParentTask.subtasks) {
sourceParentTask.subtasks = [];
}
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Find the highest subtask ID to determine the next ID
const highestSubtaskId =
destParentTask.subtasks.length > 0
? Math.max(...destParentTask.subtasks.map((st) => st.id))
: 0;
const newSubtaskId = highestSubtaskId + 1;
// Create the new subtask from the source task
const newSubtask = {
...sourceTask,
id: newSubtaskId,
parentTaskId: destParentTask.id
};
// Insert at specific position
destParentTask.subtasks.splice(destSubtaskIndex + 1, 0, newSubtask);
// Remove the original task from the tasks array
data.tasks.splice(sourceTaskIndex, 1);
log(
'info',
`Moved task ${sourceTask.id} to become subtask ${destParentTask.id}.${newSubtaskId}`
// Find source subtask
const sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
(st) => st.id === sourceSubtaskId
);
if (sourceSubtaskIndex === -1) {
throw new Error(`Source subtask ${sourceId} not found`);
}
return newSubtask;
const sourceSubtask = sourceParentTask.subtasks[sourceSubtaskIndex];
if (sourceParentId === destParentId) {
// Moving within the same parent
if (destParentTask.subtasks.length > 0) {
const destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId
);
if (destSubtaskIndex !== -1) {
// Remove from old position
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
// Insert at new position (adjust index if moving within same array)
const adjustedIndex =
sourceSubtaskIndex < destSubtaskIndex
? destSubtaskIndex - 1
: destSubtaskIndex;
destParentTask.subtasks.splice(adjustedIndex + 1, 0, sourceSubtask);
} else {
// Destination subtask doesn't exist, insert at end
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
destParentTask.subtasks.push(sourceSubtask);
}
} else {
// No existing subtasks, this will be the first one
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
destParentTask.subtasks.push(sourceSubtask);
}
} else {
// Moving between different parents
moveSubtaskToAnotherParent(
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destParentTask,
destSubtaskId
);
}
return {
message: `Moved subtask ${sourceId} to ${destinationId}`,
movedItem: sourceSubtask
};
}
/**
* Move a subtask to become a standalone task
* @param {Object} data - Tasks data object
* @param {Object} sourceSubtask - Source subtask to move
* @param {Object} sourceParentTask - Parent task of the source subtask
* @param {number} sourceSubtaskIndex - Index of source subtask in parent's subtasks
* @param {Object} destTask - Destination task (for position reference)
* @returns {Object} Moved task object
*/
function moveSubtaskToTask(
data,
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destTask
) {
// Find the highest task ID to determine the next ID
const highestId = Math.max(...data.tasks.map((t) => t.id));
const newTaskId = highestId + 1;
function moveSubtaskToTask(tasks, sourceId, destinationId) {
// Parse source ID
const [sourceParentId, sourceSubtaskId] = sourceId
.split('.')
.map((id) => parseInt(id, 10));
const destTaskId = parseInt(destinationId, 10);
// Create the new task from the subtask
// Find source parent and destination task
const sourceParentTask = tasks.find((t) => t.id === sourceParentId);
if (!sourceParentTask) {
throw new Error(`Source parent task with ID ${sourceParentId} not found`);
}
if (!sourceParentTask.subtasks) {
throw new Error(`Source parent task ${sourceParentId} has no subtasks`);
}
// Find source subtask
const sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
(st) => st.id === sourceSubtaskId
);
if (sourceSubtaskIndex === -1) {
throw new Error(`Source subtask ${sourceId} not found`);
}
const sourceSubtask = sourceParentTask.subtasks[sourceSubtaskIndex];
// Check if destination task exists
const existingDestTask = tasks.find((t) => t.id === destTaskId);
if (existingDestTask) {
throw new Error(
`Cannot move to existing task ID ${destTaskId}. Choose a different ID or use subtask destination.`
);
}
// Create new task from subtask
const newTask = {
...sourceSubtask,
id: newTaskId,
priority: sourceParentTask.priority || 'medium' // Inherit priority from parent
id: destTaskId,
title: sourceSubtask.title,
description: sourceSubtask.description,
status: sourceSubtask.status || 'pending',
dependencies: sourceSubtask.dependencies || [],
priority: sourceSubtask.priority || 'medium',
details: sourceSubtask.details || '',
testStrategy: sourceSubtask.testStrategy || '',
subtasks: []
};
delete newTask.parentTaskId;
// Add the parent task as a dependency if not already present
if (!newTask.dependencies) {
newTask.dependencies = [];
}
if (!newTask.dependencies.includes(sourceParentTask.id)) {
newTask.dependencies.push(sourceParentTask.id);
}
// Find the destination index to insert the new task
const destTaskIndex = data.tasks.findIndex((t) => t.id === destTask.id);
// Insert the new task after the destination task
data.tasks.splice(destTaskIndex + 1, 0, newTask);
// Remove the subtask from the parent
// Remove subtask from source parent
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
// If parent has no more subtasks, remove the subtasks array
if (sourceParentTask.subtasks.length === 0) {
delete sourceParentTask.subtasks;
// Insert new task in correct position
const insertIndex = tasks.findIndex((t) => t.id > destTaskId);
if (insertIndex === -1) {
tasks.push(newTask);
} else {
tasks.splice(insertIndex, 0, newTask);
}
log(
'info',
`Moved subtask ${sourceParentTask.id}.${sourceSubtask.id} to become task ${newTaskId}`
);
return newTask;
return {
message: `Converted subtask ${sourceId} to task ${destinationId}`,
movedItem: newTask
};
}
/**
* Reorder a subtask within the same parent
* @param {Object} parentTask - Parent task containing the subtask
* @param {number} sourceIndex - Current index of the subtask
* @param {number} destIndex - Destination index for the subtask
* @returns {Object} Moved subtask object
*/
function reorderSubtask(parentTask, sourceIndex, destIndex) {
// Get the subtask to move
const subtask = parentTask.subtasks[sourceIndex];
function moveTaskToSubtask(tasks, sourceId, destinationId) {
// Parse IDs
const sourceTaskId = parseInt(sourceId, 10);
const [destParentId, destSubtaskId] = destinationId
.split('.')
.map((id) => parseInt(id, 10));
// Remove the subtask from its current position
parentTask.subtasks.splice(sourceIndex, 1);
// Find source task and destination parent
const sourceTaskIndex = tasks.findIndex((t) => t.id === sourceTaskId);
const destParentTask = tasks.find((t) => t.id === destParentId);
// Insert the subtask at the new position
// If destIndex was after sourceIndex, it's now one less because we removed an item
const adjustedDestIndex = sourceIndex < destIndex ? destIndex - 1 : destIndex;
parentTask.subtasks.splice(adjustedDestIndex, 0, subtask);
if (sourceTaskIndex === -1) {
throw new Error(`Source task with ID ${sourceTaskId} not found`);
}
if (!destParentTask) {
throw new Error(
`Destination parent task with ID ${destParentId} not found`
);
}
log(
'info',
`Reordered subtask ${parentTask.id}.${subtask.id} within parent task ${parentTask.id}`
);
const sourceTask = tasks[sourceTaskIndex];
return subtask;
// Initialize subtasks array if it doesn't exist (based on commit fixes)
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Create new subtask from task
const newSubtask = {
id: destSubtaskId,
title: sourceTask.title,
description: sourceTask.description,
status: sourceTask.status || 'pending',
dependencies: sourceTask.dependencies || [],
details: sourceTask.details || '',
testStrategy: sourceTask.testStrategy || ''
};
// Find insertion position (based on commit fixes)
let destSubtaskIndex = -1;
if (destParentTask.subtasks.length > 0) {
destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId
);
if (destSubtaskIndex === -1) {
// Subtask doesn't exist, we'll insert at the end
destSubtaskIndex = destParentTask.subtasks.length - 1;
}
}
// Insert at specific position (based on commit fixes)
const insertPosition = destSubtaskIndex === -1 ? 0 : destSubtaskIndex + 1;
destParentTask.subtasks.splice(insertPosition, 0, newSubtask);
// Remove the original task from the tasks array
tasks.splice(sourceTaskIndex, 1);
return {
message: `Converted task ${sourceId} to subtask ${destinationId}`,
movedItem: newSubtask
};
}
function moveTaskToTask(tasks, sourceId, destinationId) {
const sourceTaskId = parseInt(sourceId, 10);
const destTaskId = parseInt(destinationId, 10);
// Find source task
const sourceTaskIndex = tasks.findIndex((t) => t.id === sourceTaskId);
if (sourceTaskIndex === -1) {
throw new Error(`Source task with ID ${sourceTaskId} not found`);
}
const sourceTask = tasks[sourceTaskIndex];
// Check if destination exists
const destTaskIndex = tasks.findIndex((t) => t.id === destTaskId);
if (destTaskIndex !== -1) {
// Destination exists - this could be overwriting or swapping
const destTask = tasks[destTaskIndex];
// For now, throw an error to avoid accidental overwrites
throw new Error(
`Task with ID ${destTaskId} already exists. Use a different destination ID.`
);
} else {
// Destination doesn't exist - create new task ID
return moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId);
}
}
/**
* Move a subtask to a different parent
* @param {Object} sourceSubtask - Source subtask to move
* @param {Object} sourceParentTask - Parent task of the source subtask
* @param {number} sourceSubtaskIndex - Index of source subtask in parent's subtasks
* @param {Object} destParentTask - Destination parent task
* @param {number} destSubtaskIndex - Index of the subtask before which to insert
* @returns {Object} Moved subtask object
*/
function moveSubtaskToAnotherParent(
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destParentTask,
destSubtaskIndex
destSubtaskId
) {
// Find the highest subtask ID in the destination parent
const highestSubtaskId =
destParentTask.subtasks.length > 0
? Math.max(...destParentTask.subtasks.map((st) => st.id))
: 0;
const newSubtaskId = highestSubtaskId + 1;
const destSubtaskId_num = parseInt(destSubtaskId, 10);
// Create the new subtask with updated parent reference
// Create new subtask with destination ID
const newSubtask = {
...sourceSubtask,
id: newSubtaskId,
parentTaskId: destParentTask.id
id: destSubtaskId_num
};
// If the subtask depends on its original parent, keep that dependency
if (!newSubtask.dependencies) {
newSubtask.dependencies = [];
}
if (!newSubtask.dependencies.includes(sourceParentTask.id)) {
newSubtask.dependencies.push(sourceParentTask.id);
// Initialize subtasks array if it doesn't exist (based on commit fixes)
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Insert at the destination position
destParentTask.subtasks.splice(destSubtaskIndex + 1, 0, newSubtask);
// Find insertion position
let destSubtaskIndex = -1;
if (destParentTask.subtasks.length > 0) {
destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId_num
);
if (destSubtaskIndex === -1) {
// Subtask doesn't exist, we'll insert at the end
destSubtaskIndex = destParentTask.subtasks.length - 1;
}
}
// Insert at the destination position (based on commit fixes)
const insertPosition = destSubtaskIndex === -1 ? 0 : destSubtaskIndex + 1;
destParentTask.subtasks.splice(insertPosition, 0, newSubtask);
// Remove the subtask from the original parent
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
// If original parent has no more subtasks, remove the subtasks array
if (sourceParentTask.subtasks.length === 0) {
delete sourceParentTask.subtasks;
}
log(
'info',
`Moved subtask ${sourceParentTask.id}.${sourceSubtask.id} to become subtask ${destParentTask.id}.${newSubtaskId}`
);
return newSubtask;
}
/**
* Move a standalone task to a new ID position
* @param {Object} data - Tasks data object
* @param {Object} sourceTask - Source task to move
* @param {number} sourceTaskIndex - Index of source task in data.tasks
* @param {Object} destTask - Destination placeholder task
* @param {number} destTaskIndex - Index of destination task in data.tasks
* @returns {Object} Moved task object
*/
function moveTaskToNewId(
data,
sourceTask,
sourceTaskIndex,
destTask,
destTaskIndex
) {
// Create a copy of the source task with the new ID
function moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId) {
const destTaskIndex = tasks.findIndex((t) => t.id === destTaskId);
// Create moved task with new ID
const movedTask = {
...sourceTask,
id: destTask.id
id: destTaskId
};
// Get numeric IDs for comparison
const sourceIdNum = parseInt(sourceTask.id, 10);
const destIdNum = parseInt(destTask.id, 10);
// Handle subtasks if present
if (sourceTask.subtasks && sourceTask.subtasks.length > 0) {
// Update subtasks to reference the new parent ID if needed
movedTask.subtasks = sourceTask.subtasks.map((subtask) => ({
...subtask,
parentTaskId: destIdNum
}));
}
// Update any dependencies in other tasks that referenced the old ID
data.tasks.forEach((task) => {
if (task.dependencies && task.dependencies.includes(sourceIdNum)) {
// Replace the old ID with the new ID
const depIndex = task.dependencies.indexOf(sourceIdNum);
task.dependencies[depIndex] = destIdNum;
// Update any dependencies that reference the old task ID
tasks.forEach((task) => {
if (task.dependencies && task.dependencies.includes(sourceTask.id)) {
const depIndex = task.dependencies.indexOf(sourceTask.id);
task.dependencies[depIndex] = destTaskId;
}
// Also check for subtask dependencies that might reference this task
if (task.subtasks && task.subtasks.length > 0) {
if (task.subtasks) {
task.subtasks.forEach((subtask) => {
if (
subtask.dependencies &&
subtask.dependencies.includes(sourceIdNum)
subtask.dependencies.includes(sourceTask.id)
) {
const depIndex = subtask.dependencies.indexOf(sourceIdNum);
subtask.dependencies[depIndex] = destIdNum;
const depIndex = subtask.dependencies.indexOf(sourceTask.id);
subtask.dependencies[depIndex] = destTaskId;
}
});
}
});
// Remove the original task from its position
data.tasks.splice(sourceTaskIndex, 1);
// 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;
});
}
});
}
// If we're moving to a position after the original, adjust the destination index
// since removing the original shifts everything down by 1
// Strategy based on commit fixes: remove source first, then replace destination
// This avoids index shifting problems
// Remove the source task first
tasks.splice(sourceTaskIndex, 1);
// Adjust the destination index if the source was before the destination
// Since we removed the source, indices after it shift down by 1
const adjustedDestIndex =
sourceTaskIndex < destTaskIndex ? destTaskIndex - 1 : destTaskIndex;
// Remove the placeholder destination task
data.tasks.splice(adjustedDestIndex, 1);
// Replace the placeholder destination task with the moved task (based on commit fixes)
if (adjustedDestIndex >= 0 && adjustedDestIndex < tasks.length) {
tasks[adjustedDestIndex] = movedTask;
} else {
// Insert at the end if index is out of bounds
tasks.push(movedTask);
}
// Insert the moved task at the destination position
data.tasks.splice(adjustedDestIndex, 0, movedTask);
log('info', `Moved task ${sourceTask.id} to new ID ${destTaskId}`);
log('info', `Moved task ${sourceIdNum} to new ID ${destIdNum}`);
return movedTask;
return {
message: `Moved task ${sourceTask.id} to new ID ${destTaskId}`,
movedItem: movedTask
};
}
export default moveTask;

View File

@@ -11,7 +11,9 @@ import {
disableSilentMode,
isSilentMode,
readJSON,
findTaskById
findTaskById,
ensureTagMetadata,
getCurrentTag
} from '../utils.js';
import { generateObjectService } from '../ai-services-unified.js';
@@ -55,6 +57,7 @@ const prdResponseSchema = z.object({
* @param {Object} [options.mcpLog] - MCP logger object (optional).
* @param {Object} [options.session] - Session object from MCP server (optional).
* @param {string} [options.projectRoot] - Project root path (for MCP/env fallback).
* @param {string} [options.tag] - Target tag for task generation.
* @param {string} [outputFormat='text'] - Output format ('text' or 'json').
*/
async function parsePRD(prdPath, tasksPath, numTasks, options = {}) {
@@ -65,11 +68,15 @@ async function parsePRD(prdPath, tasksPath, numTasks, options = {}) {
projectRoot,
force = false,
append = false,
research = false
research = false,
tag
} = options;
const isMCP = !!mcpLog;
const outputFormat = isMCP ? 'json' : 'text';
// Use the provided tag, or the current active tag, or default to 'master'
const targetTag = tag || getCurrentTag(projectRoot) || 'master';
const logFn = mcpLog
? mcpLog
: {
@@ -101,34 +108,41 @@ async function parsePRD(prdPath, tasksPath, numTasks, options = {}) {
let aiServiceResponse = null;
try {
// Handle file existence and overwrite/append logic
// Check if there are existing tasks in the target tag
let hasExistingTasksInTag = false;
if (fs.existsSync(tasksPath)) {
try {
// Read the entire file to check if the tag exists
const existingFileContent = fs.readFileSync(tasksPath, 'utf8');
const allData = JSON.parse(existingFileContent);
// Check if the target tag exists and has tasks
if (
allData[targetTag] &&
Array.isArray(allData[targetTag].tasks) &&
allData[targetTag].tasks.length > 0
) {
hasExistingTasksInTag = true;
existingTasks = allData[targetTag].tasks;
nextId = Math.max(...existingTasks.map((t) => t.id || 0)) + 1;
}
} catch (error) {
// If we can't read the file or parse it, assume no existing tasks in this tag
hasExistingTasksInTag = false;
}
}
// Handle file existence and overwrite/append logic based on target tag
if (hasExistingTasksInTag) {
if (append) {
report(
`Append mode enabled. Reading existing tasks from ${tasksPath}`,
`Append mode enabled. Found ${existingTasks.length} existing tasks in tag '${targetTag}'. Next ID will be ${nextId}.`,
'info'
);
const existingData = readJSON(tasksPath); // Use readJSON utility
if (existingData && Array.isArray(existingData.tasks)) {
existingTasks = existingData.tasks;
if (existingTasks.length > 0) {
nextId = Math.max(...existingTasks.map((t) => t.id || 0)) + 1;
report(
`Found ${existingTasks.length} existing tasks. Next ID will be ${nextId}.`,
'info'
);
}
} else {
report(
`Could not read existing tasks from ${tasksPath} or format is invalid. Proceeding without appending.`,
'warn'
);
existingTasks = []; // Reset if read fails
}
} else if (!force) {
// Not appending and not forcing overwrite
// Not appending and not forcing overwrite, and there are existing tasks in the target tag
const overwriteError = new Error(
`Output file ${tasksPath} already exists. Use --force to overwrite or --append.`
`Tag '${targetTag}' already contains ${existingTasks.length} tasks. Use --force to overwrite or --append to add to existing tasks.`
);
report(overwriteError.message, 'error');
if (outputFormat === 'text') {
@@ -140,10 +154,16 @@ async function parsePRD(prdPath, tasksPath, numTasks, options = {}) {
} else {
// Force overwrite is true
report(
`Force flag enabled. Overwriting existing file: ${tasksPath}`,
`Force flag enabled. Overwriting existing tasks in tag '${targetTag}'.`,
'info'
);
}
} else {
// No existing tasks in target tag, proceed without confirmation
report(
`Tag '${targetTag}' is empty or doesn't exist. Creating/updating tag with new tasks.`,
'info'
);
}
report(`Reading PRD content from ${prdPath}`, 'info');
@@ -312,17 +332,44 @@ Guidelines:
const finalTasks = append
? [...existingTasks, ...processedNewTasks]
: processedNewTasks;
const outputData = { tasks: finalTasks };
// Write the final tasks to the file
writeJSON(tasksPath, outputData);
// Read the existing file to preserve other tags
let outputData = {};
if (fs.existsSync(tasksPath)) {
try {
const existingFileContent = fs.readFileSync(tasksPath, 'utf8');
outputData = JSON.parse(existingFileContent);
} catch (error) {
// If we can't read the existing file, start with empty object
outputData = {};
}
}
// Update only the target tag, preserving other tags
outputData[targetTag] = {
tasks: finalTasks,
metadata: {
created:
outputData[targetTag]?.metadata?.created || new Date().toISOString(),
updated: new Date().toISOString(),
description: `Tasks for ${targetTag} context`
}
};
// Ensure the target tag has proper metadata
ensureTagMetadata(outputData[targetTag], {
description: `Tasks for ${targetTag} context`
});
// Write the complete data structure back to the file
fs.writeFileSync(tasksPath, JSON.stringify(outputData, null, 2));
report(
`Successfully ${append ? 'appended' : 'generated'} ${processedNewTasks.length} tasks in ${tasksPath}${research ? ' with research-backed analysis' : ''}`,
'success'
);
// Generate markdown task files after writing tasks.json
await generateTaskFiles(tasksPath, path.dirname(tasksPath), { mcpLog });
// await generateTaskFiles(tasksPath, path.dirname(tasksPath), { mcpLog });
// Handle CLI output (e.g., success message)
if (outputFormat === 'text') {
@@ -359,7 +406,8 @@ Guidelines:
return {
success: true,
tasksPath,
telemetryData: aiServiceResponse?.telemetryData
telemetryData: aiServiceResponse?.telemetryData,
tagInfo: aiServiceResponse?.tagInfo
};
} catch (error) {
report(`Error parsing PRD: ${error.message}`, 'error');

View File

@@ -8,19 +8,21 @@ import generateTaskFiles from './generate-task-files.js';
* @param {string} subtaskId - ID of the subtask to remove in format "parentId.subtaskId"
* @param {boolean} convertToTask - Whether to convert the subtask to a standalone task
* @param {boolean} generateFiles - Whether to regenerate task files after removing the subtask
* @param {Object} context - Context object containing projectRoot and tag information
* @returns {Object|null} The removed subtask if convertToTask is true, otherwise null
*/
async function removeSubtask(
tasksPath,
subtaskId,
convertToTask = false,
generateFiles = true
generateFiles = true,
context = {}
) {
try {
log('info', `Removing subtask ${subtaskId}...`);
// Read the existing tasks
const data = readJSON(tasksPath);
// Read the existing tasks with proper context
const data = readJSON(tasksPath, context.projectRoot, context.tag);
if (!data || !data.tasks) {
throw new Error(`Invalid or missing tasks file at ${tasksPath}`);
}
@@ -63,7 +65,7 @@ async function removeSubtask(
// If parent has no more subtasks, remove the subtasks array
if (parentTask.subtasks.length === 0) {
delete parentTask.subtasks;
parentTask.subtasks = undefined;
}
let convertedTask = null;
@@ -100,13 +102,13 @@ async function removeSubtask(
log('info', `Subtask ${subtaskId} deleted`);
}
// Write the updated tasks back to the file
writeJSON(tasksPath, data);
// Write the updated tasks back to the file with proper context
writeJSON(tasksPath, data, context.projectRoot, context.tag);
// Generate task files if requested
if (generateFiles) {
log('info', 'Regenerating task files...');
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
// await generateTaskFiles(tasksPath, path.dirname(tasksPath), context);
}
return convertedTask;

View File

@@ -9,9 +9,11 @@ import taskExists from './task-exists.js';
* Removes one or more tasks or subtasks from the tasks file
* @param {string} tasksPath - Path to the tasks file
* @param {string} taskIds - Comma-separated string of task/subtask IDs to remove (e.g., '5,6.1,7')
* @param {Object} context - Context object containing projectRoot and tag information
* @returns {Object} Result object with success status, messages, and removed task info
*/
async function removeTask(tasksPath, taskIds) {
async function removeTask(tasksPath, taskIds, context = {}) {
const { projectRoot, tag } = context;
const results = {
success: true,
messages: [],
@@ -30,18 +32,28 @@ async function removeTask(tasksPath, taskIds) {
}
try {
// Read the tasks file ONCE before the loop
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
// Read the tasks file ONCE before the loop, preserving the full tagged structure
const rawData = readJSON(tasksPath, projectRoot); // Read raw data
if (!rawData) {
throw new Error(`Could not read tasks file at ${tasksPath}`);
}
// Use the full tagged data if available, otherwise use the data as is
const fullTaggedData = rawData._rawTaggedData || rawData;
const currentTag = tag || rawData.tag || 'master';
if (!fullTaggedData[currentTag] || !fullTaggedData[currentTag].tasks) {
throw new Error(`Tag '${currentTag}' not found or has no tasks.`);
}
const tasks = fullTaggedData[currentTag].tasks; // Work with tasks from the correct tag
const tasksToDeleteFiles = []; // Collect IDs of main tasks whose files should be deleted
for (const taskId of taskIdsToRemove) {
// Check if the task ID exists *before* attempting removal
if (!taskExists(data.tasks, taskId)) {
const errorMsg = `Task with ID ${taskId} not found or already removed.`;
if (!taskExists(tasks, taskId)) {
const errorMsg = `Task with ID ${taskId} in tag '${currentTag}' not found or already removed.`;
results.errors.push(errorMsg);
results.success = false; // Mark overall success as false if any error occurs
continue; // Skip to the next ID
@@ -55,7 +67,7 @@ async function removeTask(tasksPath, taskIds) {
.map((id) => parseInt(id, 10));
// Find the parent task
const parentTask = data.tasks.find((t) => t.id === parentTaskId);
const parentTask = tasks.find((t) => t.id === parentTaskId);
if (!parentTask || !parentTask.subtasks) {
throw new Error(
`Parent task ${parentTaskId} or its subtasks not found for subtask ${taskId}`
@@ -82,27 +94,31 @@ async function removeTask(tasksPath, taskIds) {
// Remove the subtask from the parent
parentTask.subtasks.splice(subtaskIndex, 1);
results.messages.push(`Successfully removed subtask ${taskId}`);
results.messages.push(
`Successfully removed subtask ${taskId} from tag '${currentTag}'`
);
}
// Handle main task removal
else {
const taskIdNum = parseInt(taskId, 10);
const taskIndex = data.tasks.findIndex((t) => t.id === taskIdNum);
const taskIndex = tasks.findIndex((t) => t.id === taskIdNum);
if (taskIndex === -1) {
// This case should theoretically be caught by the taskExists check above,
// but keep it as a safeguard.
throw new Error(`Task with ID ${taskId} not found`);
throw new Error(
`Task with ID ${taskId} not found in tag '${currentTag}'`
);
}
// Store the task info before removal
const removedTask = data.tasks[taskIndex];
const removedTask = tasks[taskIndex];
results.removedTasks.push(removedTask);
tasksToDeleteFiles.push(taskIdNum); // Add to list for file deletion
// Remove the task from the main array
data.tasks.splice(taskIndex, 1);
tasks.splice(taskIndex, 1);
results.messages.push(`Successfully removed task ${taskId}`);
results.messages.push(
`Successfully removed task ${taskId} from tag '${currentTag}'`
);
}
} catch (innerError) {
// Catch errors specific to processing *this* ID
@@ -117,36 +133,46 @@ async function removeTask(tasksPath, taskIds) {
// Only proceed with cleanup and saving if at least one task was potentially removed
if (results.removedTasks.length > 0) {
// Remove all references AFTER all tasks/subtasks are removed
const allRemovedIds = new Set(
taskIdsToRemove.map((id) =>
typeof id === 'string' && id.includes('.') ? id : parseInt(id, 10)
)
);
data.tasks.forEach((task) => {
// Clean dependencies in main tasks
if (task.dependencies) {
task.dependencies = task.dependencies.filter(
(depId) => !allRemovedIds.has(depId)
);
}
// Clean dependencies in remaining subtasks
if (task.subtasks) {
task.subtasks.forEach((subtask) => {
if (subtask.dependencies) {
subtask.dependencies = subtask.dependencies.filter(
(depId) =>
!allRemovedIds.has(`${task.id}.${depId}`) &&
!allRemovedIds.has(depId) // check both subtask and main task refs
// Update the tasks in the current tag of the full data structure
fullTaggedData[currentTag].tasks = tasks;
// Remove dependencies from all tags
for (const tagName in fullTaggedData) {
if (
Object.prototype.hasOwnProperty.call(fullTaggedData, tagName) &&
fullTaggedData[tagName] &&
fullTaggedData[tagName].tasks
) {
const currentTagTasks = fullTaggedData[tagName].tasks;
currentTagTasks.forEach((task) => {
if (task.dependencies) {
task.dependencies = task.dependencies.filter(
(depId) => !allRemovedIds.has(depId)
);
}
if (task.subtasks) {
task.subtasks.forEach((subtask) => {
if (subtask.dependencies) {
subtask.dependencies = subtask.dependencies.filter(
(depId) =>
!allRemovedIds.has(`${task.id}.${depId}`) &&
!allRemovedIds.has(depId)
);
}
});
}
});
}
});
}
// Save the updated tasks file ONCE
writeJSON(tasksPath, data);
// Save the updated raw data structure
writeJSON(tasksPath, fullTaggedData);
// Delete task files AFTER saving tasks.json
for (const taskIdNum of tasksToDeleteFiles) {
@@ -167,9 +193,12 @@ async function removeTask(tasksPath, taskIds) {
}
}
// Generate updated task files ONCE
// Generate updated task files ONCE, with context
try {
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
// await generateTaskFiles(tasksPath, path.dirname(tasksPath), {
// projectRoot,
// tag: currentTag
// });
results.messages.push('Task files regenerated successfully.');
} catch (genError) {
const genErrMsg = `Failed to regenerate task files: ${genError.message}`;
@@ -178,7 +207,6 @@ async function removeTask(tasksPath, taskIds) {
log('warn', genErrMsg);
}
} else if (results.errors.length === 0) {
// Case where valid IDs were provided but none existed
results.messages.push('No tasks found matching the provided IDs.');
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,14 @@ import path from 'path';
import chalk from 'chalk';
import boxen from 'boxen';
import { log, readJSON, writeJSON, findTaskById } from '../utils.js';
import {
log,
readJSON,
writeJSON,
findTaskById,
getCurrentTag,
ensureTagMetadata
} from '../utils.js';
import { displayBanner } from '../ui.js';
import { validateTaskDependencies } from '../dependency-manager.js';
import { getDebugFlag } from '../config-manager.js';
@@ -18,10 +25,17 @@ import {
* @param {string} tasksPath - Path to the tasks.json file
* @param {string} taskIdInput - Task ID(s) to update
* @param {string} newStatus - New status
* @param {Object} options - Additional options (mcpLog for MCP mode)
* @param {Object} options - Additional options (mcpLog for MCP mode, projectRoot for tag resolution)
* @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 +57,37 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) {
}
log('info', `Reading tasks from ${tasksPath}...`);
const data = readJSON(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;
}
// Determine the current tag
const currentTag = tag || getCurrentTag(options.projectRoot) || 'master';
// Ensure the tag exists in the raw data
if (
!rawData ||
!rawData[currentTag] ||
!Array.isArray(rawData[currentTag].tasks)
) {
throw new Error(
`Invalid tasks file or tag "${currentTag}" not found at ${tasksPath}`
);
}
// Get the tasks for the current tag
const data = {
tasks: rawData[currentTag].tasks,
tag: currentTag,
_rawTaggedData: rawData
};
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
}
@@ -52,37 +96,65 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) {
const taskIds = taskIdInput.split(',').map((id) => id.trim());
const updatedTasks = [];
// Update each task
// Update each task and capture old status for display
for (const id of taskIds) {
// Capture old status before updating
let oldStatus = 'unknown';
if (id.includes('.')) {
// Handle subtask
const [parentId, subtaskId] = id
.split('.')
.map((id) => parseInt(id, 10));
const parentTask = data.tasks.find((t) => t.id === parentId);
if (parentTask?.subtasks) {
const subtask = parentTask.subtasks.find((st) => st.id === subtaskId);
oldStatus = subtask?.status || 'pending';
}
} else {
// Handle regular task
const taskId = parseInt(id, 10);
const task = data.tasks.find((t) => t.id === taskId);
oldStatus = task?.status || 'pending';
}
await updateSingleTaskStatus(tasksPath, id, newStatus, data, !isMcpMode);
updatedTasks.push(id);
updatedTasks.push({ id, oldStatus, newStatus });
}
// Write the updated tasks to the file
writeJSON(tasksPath, data);
// Update the raw data structure with the modified tasks
rawData[currentTag].tasks = data.tasks;
// Ensure the tag has proper metadata
ensureTagMetadata(rawData[currentTag], {
description: `Tasks for ${currentTag} context`
});
// Write the updated raw data back to the file
// The writeJSON function will automatically filter out _rawTaggedData
writeJSON(tasksPath, rawData);
// Validate dependencies after status update
log('info', 'Validating dependencies after status update...');
validateTaskDependencies(data.tasks);
// Generate individual task files
log('info', 'Regenerating task files...');
await generateTaskFiles(tasksPath, path.dirname(tasksPath), {
mcpLog: options.mcpLog
});
// log('info', 'Regenerating task files...');
// await generateTaskFiles(tasksPath, path.dirname(tasksPath), {
// mcpLog: options.mcpLog
// });
// Display success message - only in CLI mode
if (!isMcpMode) {
for (const id of updatedTasks) {
const task = findTaskById(data.tasks, id);
const taskName = task ? task.title : id;
for (const updateInfo of updatedTasks) {
const { id, oldStatus, newStatus: updatedStatus } = updateInfo;
console.log(
boxen(
chalk.white.bold(`Successfully updated task ${id} status:`) +
'\n' +
`From: ${chalk.yellow(task ? task.status : 'unknown')}\n` +
`To: ${chalk.green(newStatus)}`,
`From: ${chalk.yellow(oldStatus)}\n` +
`To: ${chalk.green(updatedStatus)}`,
{ padding: 1, borderColor: 'green', borderStyle: 'round' }
)
);
@@ -92,9 +164,10 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) {
// Return success value for programmatic use
return {
success: true,
updatedTasks: updatedTasks.map((id) => ({
updatedTasks: updatedTasks.map(({ id, oldStatus, newStatus }) => ({
id,
status: newStatus
oldStatus,
newStatus
}))
};
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,15 @@ import {
readJSON,
writeJSON,
truncate,
isSilentMode
isSilentMode,
findProjectRoot,
flattenTasksWithSubtasks
} from '../utils.js';
import { generateTextService } from '../ai-services-unified.js';
import { getDebugFlag } from '../config-manager.js';
import generateTaskFiles from './generate-task-files.js';
import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
/**
* Update a subtask by appending additional timestamped information using the unified AI service.
@@ -42,7 +46,7 @@ async function updateSubtaskById(
context = {},
outputFormat = context.mcpLog ? 'json' : 'text'
) {
const { session, mcpLog, projectRoot } = context;
const { session, mcpLog, projectRoot: providedProjectRoot } = context;
const logFn = mcpLog || consoleLog;
const isMCP = !!mcpLog;
@@ -81,7 +85,12 @@ async function updateSubtaskById(
throw new Error(`Tasks file not found at path: ${tasksPath}`);
}
const data = readJSON(tasksPath);
const projectRoot = providedProjectRoot || findProjectRoot();
if (!projectRoot) {
throw new Error('Could not determine project root directory');
}
const data = readJSON(tasksPath, projectRoot);
if (!data || !data.tasks) {
throw new Error(
`No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.`
@@ -93,9 +102,9 @@ async function updateSubtaskById(
const subtaskIdNum = parseInt(subtaskIdStr, 10);
if (
isNaN(parentId) ||
Number.isNaN(parentId) ||
parentId <= 0 ||
isNaN(subtaskIdNum) ||
Number.isNaN(subtaskIdNum) ||
subtaskIdNum <= 0
) {
throw new Error(
@@ -125,6 +134,35 @@ async function updateSubtaskById(
const subtask = parentTask.subtasks[subtaskIndex];
// --- Context Gathering ---
let gatheredContext = '';
try {
const contextGatherer = new ContextGatherer(projectRoot);
const allTasksFlat = flattenTasksWithSubtasks(data.tasks);
const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-subtask');
const searchQuery = `${parentTask.title} ${subtask.title} ${prompt}`;
const searchResults = fuzzySearch.findRelevantTasks(searchQuery, {
maxResults: 5,
includeSelf: true
});
const relevantTaskIds = fuzzySearch.getTaskIds(searchResults);
const finalTaskIds = [
...new Set([subtaskId.toString(), ...relevantTaskIds])
];
if (finalTaskIds.length > 0) {
const contextResult = await contextGatherer.gather({
tasks: finalTaskIds,
format: 'research'
});
gatheredContext = contextResult;
}
} catch (contextError) {
report('warn', `Could not gather context: ${contextError.message}`);
}
// --- End Context Gathering ---
if (outputFormat === 'text') {
const table = new Table({
head: [
@@ -200,7 +238,11 @@ Output Requirements:
4. Ensure the generated text is concise yet complete for the update based on the user request. Avoid conversational fillers or explanations about what you are doing (e.g., do not start with "Okay, here's the update...").`;
// Pass the existing subtask.details in the user prompt for the AI's context.
const userPrompt = `Task Context:\n${contextString}\n\nUser Request: "${prompt}"\n\nBased on the User Request and all the Task Context (including current subtask details provided above), what is the new information or text that should be appended to this subtask's details? Return ONLY this new text as a plain string.`;
let userPrompt = `Task Context:\n${contextString}\n\nUser Request: "${prompt}"\n\nBased on the User Request and all the Task Context (including current subtask details provided above), what is the new information or text that should be appended to this subtask's details? Return ONLY this new text as a plain string.`;
if (gatheredContext) {
userPrompt += `\n\n# Additional Project Context\n\n${gatheredContext}`;
}
const role = useResearch ? 'research' : 'main';
report('info', `Using AI text service with role: ${role}`);
@@ -289,13 +331,13 @@ Output Requirements:
if (outputFormat === 'text' && getDebugFlag(session)) {
console.log('>>> DEBUG: About to call writeJSON with updated data...');
}
writeJSON(tasksPath, data);
writeJSON(tasksPath, data, projectRoot);
if (outputFormat === 'text' && getDebugFlag(session)) {
console.log('>>> DEBUG: writeJSON call completed.');
}
report('success', `Successfully updated subtask ${subtaskId}`);
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
// await generateTaskFiles(tasksPath, path.dirname(tasksPath));
if (outputFormat === 'text') {
if (loadingIndicator) {
@@ -324,7 +366,8 @@ Output Requirements:
return {
updatedSubtask: updatedSubtask,
telemetryData: aiServiceResponse.telemetryData
telemetryData: aiServiceResponse.telemetryData,
tagInfo: aiServiceResponse.tagInfo
};
} catch (error) {
if (outputFormat === 'text' && loadingIndicator) {

View File

@@ -10,7 +10,9 @@ import {
readJSON,
writeJSON,
truncate,
isSilentMode
isSilentMode,
flattenTasksWithSubtasks,
findProjectRoot
} from '../utils.js';
import {
@@ -26,6 +28,8 @@ import {
isApiKeySet // Keep this check
} from '../config-manager.js';
import generateTaskFiles from './generate-task-files.js';
import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
// Zod schema for post-parsing validation of the updated task object
const updatedTaskSchema = z
@@ -197,16 +201,18 @@ function parseUpdatedTaskFromText(text, expectedTaskId, logFn, isMCP) {
}
/**
* Update a single task by ID using the unified AI service.
* Update a task by ID with new information using the unified AI service.
* @param {string} tasksPath - Path to the tasks.json file
* @param {number} taskId - Task ID to update
* @param {string} prompt - Prompt with new context
* @param {number} taskId - ID of the task to update
* @param {string} prompt - Prompt for generating updated task information
* @param {boolean} [useResearch=false] - Whether to use the research AI role.
* @param {Object} context - Context object containing session and mcpLog.
* @param {Object} [context.session] - Session object from MCP server.
* @param {Object} [context.mcpLog] - MCP logger object.
* @param {string} [context.projectRoot] - Project root path.
* @param {string} [outputFormat='text'] - Output format ('text' or 'json').
* @returns {Promise<Object|null>} - Updated task data or null if task wasn't updated/found.
* @param {boolean} [appendMode=false] - If true, append to details instead of full update.
* @returns {Promise<Object|null>} - The updated task or null if update failed.
*/
async function updateTaskById(
tasksPath,
@@ -214,9 +220,10 @@ async function updateTaskById(
prompt,
useResearch = false,
context = {},
outputFormat = 'text'
outputFormat = 'text',
appendMode = false
) {
const { session, mcpLog, projectRoot } = context;
const { session, mcpLog, projectRoot: providedProjectRoot } = context;
const logFn = mcpLog || consoleLog;
const isMCP = !!mcpLog;
@@ -255,8 +262,14 @@ async function updateTaskById(
throw new Error(`Tasks file not found: ${tasksPath}`);
// --- End Input Validations ---
// Determine project root
const projectRoot = providedProjectRoot || findProjectRoot();
if (!projectRoot) {
throw new Error('Could not determine project root directory');
}
// --- Task Loading and Status Check (Keep existing) ---
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, projectRoot);
if (!data || !data.tasks)
throw new Error(`No valid tasks found in ${tasksPath}.`);
const taskIndex = data.tasks.findIndex((task) => task.id === taskId);
@@ -293,6 +306,35 @@ async function updateTaskById(
}
// --- End Task Loading ---
// --- Context Gathering ---
let gatheredContext = '';
try {
const contextGatherer = new ContextGatherer(projectRoot);
const allTasksFlat = flattenTasksWithSubtasks(data.tasks);
const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-task');
const searchQuery = `${taskToUpdate.title} ${taskToUpdate.description} ${prompt}`;
const searchResults = fuzzySearch.findRelevantTasks(searchQuery, {
maxResults: 5,
includeSelf: true
});
const relevantTaskIds = fuzzySearch.getTaskIds(searchResults);
const finalTaskIds = [
...new Set([taskId.toString(), ...relevantTaskIds])
];
if (finalTaskIds.length > 0) {
const contextResult = await contextGatherer.gather({
tasks: finalTaskIds,
format: 'research'
});
gatheredContext = contextResult;
}
} catch (contextError) {
report('warn', `Could not gather context: ${contextError.message}`);
}
// --- End Context Gathering ---
// --- Display Task Info (CLI Only - Keep existing) ---
if (outputFormat === 'text') {
// Show the task that will be updated
@@ -349,8 +391,41 @@ async function updateTaskById(
);
}
// --- Build Prompts (Keep EXACT original prompts) ---
const systemPrompt = `You are an AI assistant helping to update a software development task based on new context.
// --- Build Prompts (Different for append vs full update) ---
let systemPrompt;
let userPrompt;
if (appendMode) {
// Append mode: generate new content to add to task details
systemPrompt = `You are an AI assistant helping to append additional information to a software development task. You will be provided with the task's existing details, context, and a user request string.
Your Goal: Based *only* on the user's request and all the provided context (including existing details if relevant to the request), GENERATE the new text content that should be added to the task's details.
Focus *only* on generating the substance of the update.
Output Requirements:
1. Return *only* the newly generated text content as a plain string. Do NOT return a JSON object or any other structured data.
2. Your string response should NOT include any of the task's original details, unless the user's request explicitly asks to rephrase, summarize, or directly modify existing text.
3. Do NOT include any timestamps, XML-like tags, markdown, or any other special formatting in your string response.
4. Ensure the generated text is concise yet complete for the update based on the user request. Avoid conversational fillers or explanations about what you are doing (e.g., do not start with "Okay, here's the update...").`;
const taskContext = `
Task: ${JSON.stringify({
id: taskToUpdate.id,
title: taskToUpdate.title,
description: taskToUpdate.description,
status: taskToUpdate.status
})}
Current Task Details (for context only):\n${taskToUpdate.details || '(No existing details)'}
`;
userPrompt = `Task Context:\n${taskContext}\n\nUser Request: "${prompt}"\n\nBased on the User Request and all the Task Context (including current task details provided above), what is the new information or text that should be appended to this task's details? Return ONLY this new text as a plain string.`;
if (gatheredContext) {
userPrompt += `\n\n# Additional Project Context\n\n${gatheredContext}`;
}
} else {
// Full update mode: use original prompts
systemPrompt = `You are an AI assistant helping to update a software development task based on new context.
You will be given a task and a prompt describing changes or new implementation details.
Your job is to update the task to reflect these changes, while preserving its basic structure.
@@ -369,8 +444,15 @@ Guidelines:
The changes described in the prompt should be thoughtfully applied to make the task more accurate and actionable.`;
const taskDataString = JSON.stringify(taskToUpdate, null, 2); // Use original task data
const userPrompt = `Here is the task to update:\n${taskDataString}\n\nPlease update this task based on the following new context:\n${prompt}\n\nIMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items.\n\nReturn only the updated task as a valid JSON object.`;
const taskDataString = JSON.stringify(taskToUpdate, null, 2);
userPrompt = `Here is the task to update:\n${taskDataString}\n\nPlease update this task based on the following new context:\n${prompt}\n\nIMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items.`;
if (gatheredContext) {
userPrompt += `\n\n# Project Context\n\n${gatheredContext}`;
}
userPrompt += `\n\nReturn only the updated task as a valid JSON object.`;
}
// --- End Build Prompts ---
let loadingIndicator = null;
@@ -397,7 +479,72 @@ The changes described in the prompt should be thoughtfully applied to make the t
if (loadingIndicator)
stopLoadingIndicator(loadingIndicator, 'AI update complete.');
// Use mainResult (text) for parsing
if (appendMode) {
// Append mode: handle as plain text
const generatedContentString = aiServiceResponse.mainResult;
let newlyAddedSnippet = '';
if (generatedContentString && generatedContentString.trim()) {
const timestamp = new Date().toISOString();
const formattedBlock = `<info added on ${timestamp}>\n${generatedContentString.trim()}\n</info added on ${timestamp}>`;
newlyAddedSnippet = formattedBlock;
// Append to task details
taskToUpdate.details =
(taskToUpdate.details ? taskToUpdate.details + '\n' : '') +
formattedBlock;
} else {
report(
'warn',
'AI response was empty or whitespace after trimming. Original details remain unchanged.'
);
newlyAddedSnippet = 'No new details were added by the AI.';
}
// Update description with timestamp if prompt is short
if (prompt.length < 100) {
if (taskToUpdate.description) {
taskToUpdate.description += ` [Updated: ${new Date().toLocaleDateString()}]`;
}
}
// Write the updated task back to file
data.tasks[taskIndex] = taskToUpdate;
writeJSON(tasksPath, data);
report('success', `Successfully appended to task ${taskId}`);
// Display success message for CLI
if (outputFormat === 'text') {
console.log(
boxen(
chalk.green(`Successfully appended to task #${taskId}`) +
'\n\n' +
chalk.white.bold('Title:') +
' ' +
taskToUpdate.title +
'\n\n' +
chalk.white.bold('Newly Added Content:') +
'\n' +
chalk.white(newlyAddedSnippet),
{ padding: 1, borderColor: 'green', borderStyle: 'round' }
)
);
}
// Display AI usage telemetry for CLI users
if (outputFormat === 'text' && aiServiceResponse.telemetryData) {
displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli');
}
// Return the updated task
return {
updatedTask: taskToUpdate,
telemetryData: aiServiceResponse.telemetryData,
tagInfo: aiServiceResponse.tagInfo
};
}
// Full update mode: Use mainResult (text) for parsing
const updatedTask = parseUpdatedTaskFromText(
aiServiceResponse.mainResult,
taskId,
@@ -479,7 +626,7 @@ The changes described in the prompt should be thoughtfully applied to make the t
// --- Write File and Generate (Unchanged) ---
writeJSON(tasksPath, data);
report('success', `Successfully updated task ${taskId}`);
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
// await generateTaskFiles(tasksPath, path.dirname(tasksPath));
// --- End Write File ---
// --- Display CLI Telemetry ---
@@ -490,7 +637,8 @@ The changes described in the prompt should be thoughtfully applied to make the t
// --- Return Success with Telemetry ---
return {
updatedTask: updatedTask, // Return the updated task object
telemetryData: aiServiceResponse.telemetryData // <<< ADD telemetryData
telemetryData: aiServiceResponse.telemetryData, // <<< ADD telemetryData
tagInfo: aiServiceResponse.tagInfo
};
} catch (error) {
// Catch errors from generateTextService

View File

@@ -23,6 +23,9 @@ import { getDebugFlag } from '../config-manager.js';
import generateTaskFiles from './generate-task-files.js';
import { generateTextService } from '../ai-services-unified.js';
import { getModelConfiguration } from './models.js';
import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
import { flattenTasksWithSubtasks, findProjectRoot } from '../utils.js';
// Zod schema for validating the structure of tasks AFTER parsing
const updatedTaskSchema = z
@@ -228,7 +231,7 @@ async function updateTasks(
context = {},
outputFormat = 'text' // Default to text for CLI
) {
const { session, mcpLog, projectRoot } = context;
const { session, mcpLog, projectRoot: providedProjectRoot } = context;
// Use mcpLog if available, otherwise use the imported consoleLog function
const logFn = mcpLog || consoleLog;
// Flag to easily check which logger type we have
@@ -246,8 +249,14 @@ async function updateTasks(
`Updating tasks from ID ${fromId} with prompt: "${prompt}"`
);
// Determine project root
const projectRoot = providedProjectRoot || findProjectRoot();
if (!projectRoot) {
throw new Error('Could not determine project root directory');
}
// --- Task Loading/Filtering (Unchanged) ---
const data = readJSON(tasksPath);
const data = readJSON(tasksPath, projectRoot);
if (!data || !data.tasks)
throw new Error(`No valid tasks found in ${tasksPath}`);
const tasksToUpdate = data.tasks.filter(
@@ -263,6 +272,38 @@ async function updateTasks(
}
// --- End Task Loading/Filtering ---
// --- Context Gathering ---
let gatheredContext = '';
try {
const contextGatherer = new ContextGatherer(projectRoot);
const allTasksFlat = flattenTasksWithSubtasks(data.tasks);
const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update');
const searchResults = fuzzySearch.findRelevantTasks(prompt, {
maxResults: 5,
includeSelf: true
});
const relevantTaskIds = fuzzySearch.getTaskIds(searchResults);
const tasksToUpdateIds = tasksToUpdate.map((t) => t.id.toString());
const finalTaskIds = [
...new Set([...tasksToUpdateIds, ...relevantTaskIds])
];
if (finalTaskIds.length > 0) {
const contextResult = await contextGatherer.gather({
tasks: finalTaskIds,
format: 'research'
});
gatheredContext = contextResult; // contextResult is a string
}
} catch (contextError) {
logFn(
'warn',
`Could not gather additional context: ${contextError.message}`
);
}
// --- End Context Gathering ---
// --- Display Tasks to Update (CLI Only - Unchanged) ---
if (outputFormat === 'text') {
// Show the tasks that will be updated
@@ -344,7 +385,13 @@ The changes described in the prompt should be applied to ALL tasks in the list.`
// Keep the original user prompt logic
const taskDataString = JSON.stringify(tasksToUpdate, null, 2);
const userPrompt = `Here are the tasks to update:\n${taskDataString}\n\nPlease update these tasks based on the following new context:\n${prompt}\n\nIMPORTANT: In the tasks JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items.\n\nReturn only the updated tasks as a valid JSON array.`;
let userPrompt = `Here are the tasks to update:\n${taskDataString}\n\nPlease update these tasks based on the following new context:\n${prompt}\n\nIMPORTANT: In the tasks JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items.`;
if (gatheredContext) {
userPrompt += `\n\n# Project Context\n\n${gatheredContext}`;
}
userPrompt += `\n\nReturn only the updated tasks as a valid JSON array.`;
// --- End Build Prompts ---
// --- AI Call ---
@@ -430,7 +477,7 @@ The changes described in the prompt should be applied to ALL tasks in the list.`
'success',
`Successfully updated ${actualUpdateCount} tasks in ${tasksPath}`
);
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
// await generateTaskFiles(tasksPath, path.dirname(tasksPath));
if (outputFormat === 'text' && aiServiceResponse.telemetryData) {
displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli');
@@ -439,7 +486,8 @@ The changes described in the prompt should be applied to ALL tasks in the list.`
return {
success: true,
updatedTasks: parsedUpdatedTasks,
telemetryData: aiServiceResponse.telemetryData
telemetryData: aiServiceResponse.telemetryData,
tagInfo: aiServiceResponse.tagInfo
};
} catch (error) {
if (loadingIndicator) stopLoadingIndicator(loadingIndicator);