From 6d69d02fe03edcc785380415995d5cfcdd97acbb Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Mon, 14 Jul 2025 02:29:36 -0600 Subject: [PATCH 01/23] fix: prevent CLAUDE.md overwrite by using imports (#949) * fix: prevent CLAUDE.md overwrite by using imports - Copy Task Master instructions to .taskmaster/CLAUDE.md - Add import section to user's CLAUDE.md instead of overwriting - Preserve existing user content - Clean removal of Task Master content on uninstall Closes #929 * chore: add changeset for Claude import fix --- .changeset/claude-import-fix-new.md | 12 ++ src/profiles/claude.js | 130 +++++++++++++++++- .../claude-init-functionality.test.js | 10 +- 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 .changeset/claude-import-fix-new.md diff --git a/.changeset/claude-import-fix-new.md b/.changeset/claude-import-fix-new.md new file mode 100644 index 00000000..acaf95d3 --- /dev/null +++ b/.changeset/claude-import-fix-new.md @@ -0,0 +1,12 @@ +--- +"task-master-ai": patch +--- + +Prevent CLAUDE.md overwrite by using Claude Code's import feature + +- Task Master now creates its instructions in `.taskmaster/CLAUDE.md` instead of overwriting the user's `CLAUDE.md` +- Adds an import section to the user's CLAUDE.md that references the Task Master instructions +- Preserves existing user content in CLAUDE.md files +- Provides clean uninstall that only removes Task Master's additions + +**Breaking Change**: Task Master instructions for Claude Code are now stored in `.taskmaster/CLAUDE.md` and imported into the main CLAUDE.md file. Users who previously had Task Master content directly in their CLAUDE.md will need to run `task-master rules remove claude` followed by `task-master rules add claude` to migrate to the new structure. \ No newline at end of file diff --git a/src/profiles/claude.js b/src/profiles/claude.js index 4ce7e557..2fc347f0 100644 --- a/src/profiles/claude.js +++ b/src/profiles/claude.js @@ -59,6 +59,63 @@ function onAddRulesProfile(targetDir, assetsDir) { `[Claude] An error occurred during directory copy: ${err.message}` ); } + + // Handle CLAUDE.md import for non-destructive integration + const sourceFile = path.join(assetsDir, 'AGENTS.md'); + const userClaudeFile = path.join(targetDir, 'CLAUDE.md'); + const taskMasterClaudeFile = path.join(targetDir, '.taskmaster', 'CLAUDE.md'); + const importLine = '@./.taskmaster/CLAUDE.md'; + const importSection = `\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main CLAUDE.md file.**\n${importLine}`; + + if (fs.existsSync(sourceFile)) { + try { + // Ensure .taskmaster directory exists + const taskMasterDir = path.join(targetDir, '.taskmaster'); + if (!fs.existsSync(taskMasterDir)) { + fs.mkdirSync(taskMasterDir, { recursive: true }); + } + + // Copy Task Master instructions to .taskmaster/CLAUDE.md + fs.copyFileSync(sourceFile, taskMasterClaudeFile); + log( + 'debug', + `[Claude] Created Task Master instructions at ${taskMasterClaudeFile}` + ); + + // Handle user's CLAUDE.md + if (fs.existsSync(userClaudeFile)) { + // Check if import already exists + const content = fs.readFileSync(userClaudeFile, 'utf8'); + if (!content.includes(importLine)) { + // Append import section at the end + const updatedContent = content.trim() + '\n' + importSection + '\n'; + fs.writeFileSync(userClaudeFile, updatedContent); + log( + 'info', + `[Claude] Added Task Master import to existing ${userClaudeFile}` + ); + } else { + log( + 'info', + `[Claude] Task Master import already present in ${userClaudeFile}` + ); + } + } else { + // Create minimal CLAUDE.md with the import section + const minimalContent = `# Claude Code Instructions\n${importSection}\n`; + fs.writeFileSync(userClaudeFile, minimalContent); + log( + 'info', + `[Claude] Created ${userClaudeFile} with Task Master import` + ); + } + } catch (err) { + log( + 'error', + `[Claude] Failed to set up Claude instructions: ${err.message}` + ); + } + } } function onRemoveRulesProfile(targetDir) { @@ -67,6 +124,77 @@ function onRemoveRulesProfile(targetDir) { if (removeDirectoryRecursive(claudeDir)) { log('debug', `[Claude] Removed .claude directory from ${claudeDir}`); } + + // Clean up CLAUDE.md import + const userClaudeFile = path.join(targetDir, 'CLAUDE.md'); + const taskMasterClaudeFile = path.join(targetDir, '.taskmaster', 'CLAUDE.md'); + const importLine = '@./.taskmaster/CLAUDE.md'; + + try { + // Remove Task Master CLAUDE.md from .taskmaster + if (fs.existsSync(taskMasterClaudeFile)) { + fs.rmSync(taskMasterClaudeFile, { force: true }); + log('debug', `[Claude] Removed ${taskMasterClaudeFile}`); + } + + // Clean up import from user's CLAUDE.md + if (fs.existsSync(userClaudeFile)) { + const content = fs.readFileSync(userClaudeFile, 'utf8'); + const lines = content.split('\n'); + const filteredLines = []; + let skipNextLines = 0; + + // Remove the Task Master section + for (let i = 0; i < lines.length; i++) { + if (skipNextLines > 0) { + skipNextLines--; + continue; + } + + // Check if this is the start of our Task Master section + if (lines[i].includes('## Task Master AI Instructions')) { + // Skip this line and the next two lines (bold text and import) + skipNextLines = 2; + continue; + } + + // Also remove standalone import lines (for backward compatibility) + if (lines[i].trim() === importLine) { + continue; + } + + filteredLines.push(lines[i]); + } + + // Join back and clean up excessive newlines + let updatedContent = filteredLines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + // Check if file only contained our minimal template + if ( + updatedContent === '# Claude Code Instructions' || + updatedContent === '' + ) { + // File only contained our import, remove it + fs.rmSync(userClaudeFile, { force: true }); + log('debug', `[Claude] Removed empty ${userClaudeFile}`); + } else { + // Write back without the import + fs.writeFileSync(userClaudeFile, updatedContent + '\n'); + log( + 'debug', + `[Claude] Removed Task Master import from ${userClaudeFile}` + ); + } + } + } catch (err) { + log( + 'error', + `[Claude] Failed to remove Claude instructions: ${err.message}` + ); + } } function onPostConvertRulesProfile(targetDir, assetsDir) { @@ -86,7 +214,7 @@ export const claudeProfile = createProfile({ mcpConfigName: null, includeDefaultRules: false, fileMap: { - 'AGENTS.md': 'CLAUDE.md' + 'AGENTS.md': '.taskmaster/CLAUDE.md' }, onAdd: onAddRulesProfile, onRemove: onRemoveRulesProfile, diff --git a/tests/integration/profiles/claude-init-functionality.test.js b/tests/integration/profiles/claude-init-functionality.test.js index b597da8c..ed623630 100644 --- a/tests/integration/profiles/claude-init-functionality.test.js +++ b/tests/integration/profiles/claude-init-functionality.test.js @@ -23,7 +23,9 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfileContent).toContain("rulesDir: '.'"); // non-default expect(claudeProfileContent).toContain('mcpConfig: false'); // non-default expect(claudeProfileContent).toContain('includeDefaultRules: false'); // non-default - expect(claudeProfileContent).toContain("'AGENTS.md': 'CLAUDE.md'"); + expect(claudeProfileContent).toContain( + "'AGENTS.md': '.taskmaster/CLAUDE.md'" + ); // Check the final computed properties on the profile object expect(claudeProfile.profileName).toBe('claude'); @@ -33,7 +35,7 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfile.mcpConfig).toBe(false); expect(claudeProfile.mcpConfigName).toBe(null); // computed expect(claudeProfile.includeDefaultRules).toBe(false); - expect(claudeProfile.fileMap['AGENTS.md']).toBe('CLAUDE.md'); + expect(claudeProfile.fileMap['AGENTS.md']).toBe('.taskmaster/CLAUDE.md'); }); test('claude.js has lifecycle functions for file management', () => { @@ -44,9 +46,11 @@ describe('Claude Profile Initialization Functionality', () => { ); }); - test('claude.js handles .claude directory in lifecycle functions', () => { + test('claude.js handles .claude directory and .taskmaster/CLAUDE.md import in lifecycle functions', () => { expect(claudeProfileContent).toContain('.claude'); expect(claudeProfileContent).toContain('copyRecursiveSync'); + expect(claudeProfileContent).toContain('.taskmaster/CLAUDE.md'); + expect(claudeProfileContent).toContain('@./.taskmaster/CLAUDE.md'); }); test('claude.js has proper error handling in lifecycle functions', () => { From 4629128943f6283385f4762c09cf2752f855cc33 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:49:16 +0300 Subject: [PATCH 02/23] fix: task master (tm) custom slash commands w/ proper syntax (#968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add task master (tm) custom slash commands Add comprehensive task management system integration via custom slash commands. Includes commands for: - Project initialization and setup - Task parsing from PRD documents - Task creation, update, and removal - Subtask management - Dependency tracking and validation - Complexity analysis and task expansion - Project status and reporting - Workflow automation This provides a complete task management workflow directly within Claude Code. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * chore: add changeset --------- Co-authored-by: neno-is-ooo <204701868+neno-is-ooo@users.noreply.github.com> Co-authored-by: Claude --- .changeset/ten-glasses-feel.md | 7 + .../{index.md => add-dependency.md} | 0 .../add-subtask/{index.md => add-subtask.md} | 0 ...rom-task.md => convert-task-to-subtask.md} | 0 .../tm/add-task/{index.md => add-task.md} | 0 .../{index.md => analyze-complexity.md} | 0 .../{all.md => clear-all-subtasks.md} | 0 .../{index.md => clear-subtasks.md} | 0 .../{index.md => complexity-report.md} | 0 .../tm/expand/{all.md => expand-all-tasks.md} | 0 .../tm/expand/{index.md => expand-task.md} | 0 .../{index.md => fix-dependencies.md} | 0 .../generate/{index.md => generate-tasks.md} | 0 .claude/commands/tm/index.md | 130 ---------------- .../init/{quick.md => init-project-quick.md} | 0 .../tm/init/{index.md => init-project.md} | 0 .../{by-status.md => list-tasks-by-status.md} | 0 ...ubtasks.md => list-tasks-with-subtasks.md} | 0 .../tm/list/{index.md => list-tasks.md} | 0 .../tm/models/{setup.md => setup-models.md} | 0 .../tm/models/{index.md => view-models.md} | 0 .../tm/next/{index.md => next-task.md} | 0 ...research.md => parse-prd-with-research.md} | 0 .../tm/parse-prd/{index.md => parse-prd.md} | 0 .../{index.md => remove-dependency.md} | 0 .../{index.md => remove-subtask.md} | 0 .../remove-task/{index.md => remove-task.md} | 0 .../{install.md => install-taskmaster.md} | 0 ...install.md => quick-install-taskmaster.md} | 0 .../tm/show/{index.md => show-task.md} | 0 .../tm/status/{index.md => project-status.md} | 0 .../sync-readme/{index.md => sync-readme.md} | 0 .claude/commands/tm/tm-main.md | 146 ++++++++++++++++++ .../{single.md => update-single-task.md} | 0 .../tm/update/{index.md => update-task.md} | 0 .../{from-id.md => update-tasks-from-id.md} | 0 .../utils/{analyze.md => analyze-project.md} | 0 .../{index.md => validate-dependencies.md} | 0 ...o-implement.md => auto-implement-tasks.md} | 0 .../{pipeline.md => command-pipeline.md} | 0 .../{smart-flow.md => smart-workflow.md} | 0 41 files changed, 153 insertions(+), 130 deletions(-) create mode 100644 .changeset/ten-glasses-feel.md rename .claude/commands/tm/add-dependency/{index.md => add-dependency.md} (100%) rename .claude/commands/tm/add-subtask/{index.md => add-subtask.md} (100%) rename .claude/commands/tm/add-subtask/{from-task.md => convert-task-to-subtask.md} (100%) rename .claude/commands/tm/add-task/{index.md => add-task.md} (100%) rename .claude/commands/tm/analyze-complexity/{index.md => analyze-complexity.md} (100%) rename .claude/commands/tm/clear-subtasks/{all.md => clear-all-subtasks.md} (100%) rename .claude/commands/tm/clear-subtasks/{index.md => clear-subtasks.md} (100%) rename .claude/commands/tm/complexity-report/{index.md => complexity-report.md} (100%) rename .claude/commands/tm/expand/{all.md => expand-all-tasks.md} (100%) rename .claude/commands/tm/expand/{index.md => expand-task.md} (100%) rename .claude/commands/tm/fix-dependencies/{index.md => fix-dependencies.md} (100%) rename .claude/commands/tm/generate/{index.md => generate-tasks.md} (100%) delete mode 100644 .claude/commands/tm/index.md rename .claude/commands/tm/init/{quick.md => init-project-quick.md} (100%) rename .claude/commands/tm/init/{index.md => init-project.md} (100%) rename .claude/commands/tm/list/{by-status.md => list-tasks-by-status.md} (100%) rename .claude/commands/tm/list/{with-subtasks.md => list-tasks-with-subtasks.md} (100%) rename .claude/commands/tm/list/{index.md => list-tasks.md} (100%) rename .claude/commands/tm/models/{setup.md => setup-models.md} (100%) rename .claude/commands/tm/models/{index.md => view-models.md} (100%) rename .claude/commands/tm/next/{index.md => next-task.md} (100%) rename .claude/commands/tm/parse-prd/{with-research.md => parse-prd-with-research.md} (100%) rename .claude/commands/tm/parse-prd/{index.md => parse-prd.md} (100%) rename .claude/commands/tm/remove-dependency/{index.md => remove-dependency.md} (100%) rename .claude/commands/tm/remove-subtask/{index.md => remove-subtask.md} (100%) rename .claude/commands/tm/remove-task/{index.md => remove-task.md} (100%) rename .claude/commands/tm/setup/{install.md => install-taskmaster.md} (100%) rename .claude/commands/tm/setup/{quick-install.md => quick-install-taskmaster.md} (100%) rename .claude/commands/tm/show/{index.md => show-task.md} (100%) rename .claude/commands/tm/status/{index.md => project-status.md} (100%) rename .claude/commands/tm/sync-readme/{index.md => sync-readme.md} (100%) create mode 100644 .claude/commands/tm/tm-main.md rename .claude/commands/tm/update/{single.md => update-single-task.md} (100%) rename .claude/commands/tm/update/{index.md => update-task.md} (100%) rename .claude/commands/tm/update/{from-id.md => update-tasks-from-id.md} (100%) rename .claude/commands/tm/utils/{analyze.md => analyze-project.md} (100%) rename .claude/commands/tm/validate-dependencies/{index.md => validate-dependencies.md} (100%) rename .claude/commands/tm/workflows/{auto-implement.md => auto-implement-tasks.md} (100%) rename .claude/commands/tm/workflows/{pipeline.md => command-pipeline.md} (100%) rename .claude/commands/tm/workflows/{smart-flow.md => smart-workflow.md} (100%) diff --git a/.changeset/ten-glasses-feel.md b/.changeset/ten-glasses-feel.md new file mode 100644 index 00000000..d91910cd --- /dev/null +++ b/.changeset/ten-glasses-feel.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": patch +--- + +Fixed the comprehensive taskmaster system integration via custom slash commands with proper syntax + +- Provide claude clode with a complete set of of commands that can trigger task master events directly within Claude Code diff --git a/.claude/commands/tm/add-dependency/index.md b/.claude/commands/tm/add-dependency/add-dependency.md similarity index 100% rename from .claude/commands/tm/add-dependency/index.md rename to .claude/commands/tm/add-dependency/add-dependency.md diff --git a/.claude/commands/tm/add-subtask/index.md b/.claude/commands/tm/add-subtask/add-subtask.md similarity index 100% rename from .claude/commands/tm/add-subtask/index.md rename to .claude/commands/tm/add-subtask/add-subtask.md diff --git a/.claude/commands/tm/add-subtask/from-task.md b/.claude/commands/tm/add-subtask/convert-task-to-subtask.md similarity index 100% rename from .claude/commands/tm/add-subtask/from-task.md rename to .claude/commands/tm/add-subtask/convert-task-to-subtask.md diff --git a/.claude/commands/tm/add-task/index.md b/.claude/commands/tm/add-task/add-task.md similarity index 100% rename from .claude/commands/tm/add-task/index.md rename to .claude/commands/tm/add-task/add-task.md diff --git a/.claude/commands/tm/analyze-complexity/index.md b/.claude/commands/tm/analyze-complexity/analyze-complexity.md similarity index 100% rename from .claude/commands/tm/analyze-complexity/index.md rename to .claude/commands/tm/analyze-complexity/analyze-complexity.md diff --git a/.claude/commands/tm/clear-subtasks/all.md b/.claude/commands/tm/clear-subtasks/clear-all-subtasks.md similarity index 100% rename from .claude/commands/tm/clear-subtasks/all.md rename to .claude/commands/tm/clear-subtasks/clear-all-subtasks.md diff --git a/.claude/commands/tm/clear-subtasks/index.md b/.claude/commands/tm/clear-subtasks/clear-subtasks.md similarity index 100% rename from .claude/commands/tm/clear-subtasks/index.md rename to .claude/commands/tm/clear-subtasks/clear-subtasks.md diff --git a/.claude/commands/tm/complexity-report/index.md b/.claude/commands/tm/complexity-report/complexity-report.md similarity index 100% rename from .claude/commands/tm/complexity-report/index.md rename to .claude/commands/tm/complexity-report/complexity-report.md diff --git a/.claude/commands/tm/expand/all.md b/.claude/commands/tm/expand/expand-all-tasks.md similarity index 100% rename from .claude/commands/tm/expand/all.md rename to .claude/commands/tm/expand/expand-all-tasks.md diff --git a/.claude/commands/tm/expand/index.md b/.claude/commands/tm/expand/expand-task.md similarity index 100% rename from .claude/commands/tm/expand/index.md rename to .claude/commands/tm/expand/expand-task.md diff --git a/.claude/commands/tm/fix-dependencies/index.md b/.claude/commands/tm/fix-dependencies/fix-dependencies.md similarity index 100% rename from .claude/commands/tm/fix-dependencies/index.md rename to .claude/commands/tm/fix-dependencies/fix-dependencies.md diff --git a/.claude/commands/tm/generate/index.md b/.claude/commands/tm/generate/generate-tasks.md similarity index 100% rename from .claude/commands/tm/generate/index.md rename to .claude/commands/tm/generate/generate-tasks.md diff --git a/.claude/commands/tm/index.md b/.claude/commands/tm/index.md deleted file mode 100644 index f513bb04..00000000 --- a/.claude/commands/tm/index.md +++ /dev/null @@ -1,130 +0,0 @@ -# Task Master Command Reference - -Comprehensive command structure for Task Master integration with Claude Code. - -## Command Organization - -Commands are organized hierarchically to match Task Master's CLI structure while providing enhanced Claude Code integration. - -## Project Setup & Configuration - -### `/project:tm/init` -- `index` - Initialize new project (handles PRD files intelligently) -- `quick` - Quick setup with auto-confirmation (-y flag) - -### `/project:tm/models` -- `index` - View current AI model configuration -- `setup` - Interactive model configuration -- `set-main` - Set primary generation model -- `set-research` - Set research model -- `set-fallback` - Set fallback model - -## Task Generation - -### `/project:tm/parse-prd` -- `index` - Generate tasks from PRD document -- `with-research` - Enhanced parsing with research mode - -### `/project:tm/generate` -- Create individual task files from tasks.json - -## Task Management - -### `/project:tm/list` -- `index` - Smart listing with natural language filters -- `with-subtasks` - Include subtasks in hierarchical view -- `by-status` - Filter by specific status - -### `/project:tm/set-status` -- `to-pending` - Reset task to pending -- `to-in-progress` - Start working on task -- `to-done` - Mark task complete -- `to-review` - Submit for review -- `to-deferred` - Defer task -- `to-cancelled` - Cancel task - -### `/project:tm/sync-readme` -- Export tasks to README.md with formatting - -### `/project:tm/update` -- `index` - Update tasks with natural language -- `from-id` - Update multiple tasks from a starting point -- `single` - Update specific task - -### `/project:tm/add-task` -- `index` - Add new task with AI assistance - -### `/project:tm/remove-task` -- `index` - Remove task with confirmation - -## Subtask Management - -### `/project:tm/add-subtask` -- `index` - Add new subtask to parent -- `from-task` - Convert existing task to subtask - -### `/project:tm/remove-subtask` -- Remove subtask (with optional conversion) - -### `/project:tm/clear-subtasks` -- `index` - Clear subtasks from specific task -- `all` - Clear all subtasks globally - -## Task Analysis & Breakdown - -### `/project:tm/analyze-complexity` -- Analyze and generate expansion recommendations - -### `/project:tm/complexity-report` -- Display complexity analysis report - -### `/project:tm/expand` -- `index` - Break down specific task -- `all` - Expand all eligible tasks -- `with-research` - Enhanced expansion - -## Task Navigation - -### `/project:tm/next` -- Intelligent next task recommendation - -### `/project:tm/show` -- Display detailed task information - -### `/project:tm/status` -- Comprehensive project dashboard - -## Dependency Management - -### `/project:tm/add-dependency` -- Add task dependency - -### `/project:tm/remove-dependency` -- Remove task dependency - -### `/project:tm/validate-dependencies` -- Check for dependency issues - -### `/project:tm/fix-dependencies` -- Automatically fix dependency problems - -## Usage Patterns - -### Natural Language -Most commands accept natural language arguments: -``` -/project:tm/add-task create user authentication system -/project:tm/update mark all API tasks as high priority -/project:tm/list show blocked tasks -``` - -### ID-Based Commands -Commands requiring IDs intelligently parse from $ARGUMENTS: -``` -/project:tm/show 45 -/project:tm/expand 23 -/project:tm/set-status/to-done 67 -``` - -### Smart Defaults -Commands provide intelligent defaults and suggestions based on context. \ No newline at end of file diff --git a/.claude/commands/tm/init/quick.md b/.claude/commands/tm/init/init-project-quick.md similarity index 100% rename from .claude/commands/tm/init/quick.md rename to .claude/commands/tm/init/init-project-quick.md diff --git a/.claude/commands/tm/init/index.md b/.claude/commands/tm/init/init-project.md similarity index 100% rename from .claude/commands/tm/init/index.md rename to .claude/commands/tm/init/init-project.md diff --git a/.claude/commands/tm/list/by-status.md b/.claude/commands/tm/list/list-tasks-by-status.md similarity index 100% rename from .claude/commands/tm/list/by-status.md rename to .claude/commands/tm/list/list-tasks-by-status.md diff --git a/.claude/commands/tm/list/with-subtasks.md b/.claude/commands/tm/list/list-tasks-with-subtasks.md similarity index 100% rename from .claude/commands/tm/list/with-subtasks.md rename to .claude/commands/tm/list/list-tasks-with-subtasks.md diff --git a/.claude/commands/tm/list/index.md b/.claude/commands/tm/list/list-tasks.md similarity index 100% rename from .claude/commands/tm/list/index.md rename to .claude/commands/tm/list/list-tasks.md diff --git a/.claude/commands/tm/models/setup.md b/.claude/commands/tm/models/setup-models.md similarity index 100% rename from .claude/commands/tm/models/setup.md rename to .claude/commands/tm/models/setup-models.md diff --git a/.claude/commands/tm/models/index.md b/.claude/commands/tm/models/view-models.md similarity index 100% rename from .claude/commands/tm/models/index.md rename to .claude/commands/tm/models/view-models.md diff --git a/.claude/commands/tm/next/index.md b/.claude/commands/tm/next/next-task.md similarity index 100% rename from .claude/commands/tm/next/index.md rename to .claude/commands/tm/next/next-task.md diff --git a/.claude/commands/tm/parse-prd/with-research.md b/.claude/commands/tm/parse-prd/parse-prd-with-research.md similarity index 100% rename from .claude/commands/tm/parse-prd/with-research.md rename to .claude/commands/tm/parse-prd/parse-prd-with-research.md diff --git a/.claude/commands/tm/parse-prd/index.md b/.claude/commands/tm/parse-prd/parse-prd.md similarity index 100% rename from .claude/commands/tm/parse-prd/index.md rename to .claude/commands/tm/parse-prd/parse-prd.md diff --git a/.claude/commands/tm/remove-dependency/index.md b/.claude/commands/tm/remove-dependency/remove-dependency.md similarity index 100% rename from .claude/commands/tm/remove-dependency/index.md rename to .claude/commands/tm/remove-dependency/remove-dependency.md diff --git a/.claude/commands/tm/remove-subtask/index.md b/.claude/commands/tm/remove-subtask/remove-subtask.md similarity index 100% rename from .claude/commands/tm/remove-subtask/index.md rename to .claude/commands/tm/remove-subtask/remove-subtask.md diff --git a/.claude/commands/tm/remove-task/index.md b/.claude/commands/tm/remove-task/remove-task.md similarity index 100% rename from .claude/commands/tm/remove-task/index.md rename to .claude/commands/tm/remove-task/remove-task.md diff --git a/.claude/commands/tm/setup/install.md b/.claude/commands/tm/setup/install-taskmaster.md similarity index 100% rename from .claude/commands/tm/setup/install.md rename to .claude/commands/tm/setup/install-taskmaster.md diff --git a/.claude/commands/tm/setup/quick-install.md b/.claude/commands/tm/setup/quick-install-taskmaster.md similarity index 100% rename from .claude/commands/tm/setup/quick-install.md rename to .claude/commands/tm/setup/quick-install-taskmaster.md diff --git a/.claude/commands/tm/show/index.md b/.claude/commands/tm/show/show-task.md similarity index 100% rename from .claude/commands/tm/show/index.md rename to .claude/commands/tm/show/show-task.md diff --git a/.claude/commands/tm/status/index.md b/.claude/commands/tm/status/project-status.md similarity index 100% rename from .claude/commands/tm/status/index.md rename to .claude/commands/tm/status/project-status.md diff --git a/.claude/commands/tm/sync-readme/index.md b/.claude/commands/tm/sync-readme/sync-readme.md similarity index 100% rename from .claude/commands/tm/sync-readme/index.md rename to .claude/commands/tm/sync-readme/sync-readme.md diff --git a/.claude/commands/tm/tm-main.md b/.claude/commands/tm/tm-main.md new file mode 100644 index 00000000..92946364 --- /dev/null +++ b/.claude/commands/tm/tm-main.md @@ -0,0 +1,146 @@ +# Task Master Command Reference + +Comprehensive command structure for Task Master integration with Claude Code. + +## Command Organization + +Commands are organized hierarchically to match Task Master's CLI structure while providing enhanced Claude Code integration. + +## Project Setup & Configuration + +### `/project:tm/init` +- `init-project` - Initialize new project (handles PRD files intelligently) +- `init-project-quick` - Quick setup with auto-confirmation (-y flag) + +### `/project:tm/models` +- `view-models` - View current AI model configuration +- `setup-models` - Interactive model configuration +- `set-main` - Set primary generation model +- `set-research` - Set research model +- `set-fallback` - Set fallback model + +## Task Generation + +### `/project:tm/parse-prd` +- `parse-prd` - Generate tasks from PRD document +- `parse-prd-with-research` - Enhanced parsing with research mode + +### `/project:tm/generate` +- `generate-tasks` - Create individual task files from tasks.json + +## Task Management + +### `/project:tm/list` +- `list-tasks` - Smart listing with natural language filters +- `list-tasks-with-subtasks` - Include subtasks in hierarchical view +- `list-tasks-by-status` - Filter by specific status + +### `/project:tm/set-status` +- `to-pending` - Reset task to pending +- `to-in-progress` - Start working on task +- `to-done` - Mark task complete +- `to-review` - Submit for review +- `to-deferred` - Defer task +- `to-cancelled` - Cancel task + +### `/project:tm/sync-readme` +- `sync-readme` - Export tasks to README.md with formatting + +### `/project:tm/update` +- `update-task` - Update tasks with natural language +- `update-tasks-from-id` - Update multiple tasks from a starting point +- `update-single-task` - Update specific task + +### `/project:tm/add-task` +- `add-task` - Add new task with AI assistance + +### `/project:tm/remove-task` +- `remove-task` - Remove task with confirmation + +## Subtask Management + +### `/project:tm/add-subtask` +- `add-subtask` - Add new subtask to parent +- `convert-task-to-subtask` - Convert existing task to subtask + +### `/project:tm/remove-subtask` +- `remove-subtask` - Remove subtask (with optional conversion) + +### `/project:tm/clear-subtasks` +- `clear-subtasks` - Clear subtasks from specific task +- `clear-all-subtasks` - Clear all subtasks globally + +## Task Analysis & Breakdown + +### `/project:tm/analyze-complexity` +- `analyze-complexity` - Analyze and generate expansion recommendations + +### `/project:tm/complexity-report` +- `complexity-report` - Display complexity analysis report + +### `/project:tm/expand` +- `expand-task` - Break down specific task +- `expand-all-tasks` - Expand all eligible tasks +- `with-research` - Enhanced expansion + +## Task Navigation + +### `/project:tm/next` +- `next-task` - Intelligent next task recommendation + +### `/project:tm/show` +- `show-task` - Display detailed task information + +### `/project:tm/status` +- `project-status` - Comprehensive project dashboard + +## Dependency Management + +### `/project:tm/add-dependency` +- `add-dependency` - Add task dependency + +### `/project:tm/remove-dependency` +- `remove-dependency` - Remove task dependency + +### `/project:tm/validate-dependencies` +- `validate-dependencies` - Check for dependency issues + +### `/project:tm/fix-dependencies` +- `fix-dependencies` - Automatically fix dependency problems + +## Workflows & Automation + +### `/project:tm/workflows` +- `smart-workflow` - Context-aware intelligent workflow execution +- `command-pipeline` - Chain multiple commands together +- `auto-implement-tasks` - Advanced auto-implementation with code generation + +## Utilities + +### `/project:tm/utils` +- `analyze-project` - Deep project analysis and insights + +### `/project:tm/setup` +- `install-taskmaster` - Comprehensive installation guide +- `quick-install-taskmaster` - One-line global installation + +## Usage Patterns + +### Natural Language +Most commands accept natural language arguments: +``` +/project:tm/add-task create user authentication system +/project:tm/update mark all API tasks as high priority +/project:tm/list show blocked tasks +``` + +### ID-Based Commands +Commands requiring IDs intelligently parse from $ARGUMENTS: +``` +/project:tm/show 45 +/project:tm/expand 23 +/project:tm/set-status/to-done 67 +``` + +### Smart Defaults +Commands provide intelligent defaults and suggestions based on context. \ No newline at end of file diff --git a/.claude/commands/tm/update/single.md b/.claude/commands/tm/update/update-single-task.md similarity index 100% rename from .claude/commands/tm/update/single.md rename to .claude/commands/tm/update/update-single-task.md diff --git a/.claude/commands/tm/update/index.md b/.claude/commands/tm/update/update-task.md similarity index 100% rename from .claude/commands/tm/update/index.md rename to .claude/commands/tm/update/update-task.md diff --git a/.claude/commands/tm/update/from-id.md b/.claude/commands/tm/update/update-tasks-from-id.md similarity index 100% rename from .claude/commands/tm/update/from-id.md rename to .claude/commands/tm/update/update-tasks-from-id.md diff --git a/.claude/commands/tm/utils/analyze.md b/.claude/commands/tm/utils/analyze-project.md similarity index 100% rename from .claude/commands/tm/utils/analyze.md rename to .claude/commands/tm/utils/analyze-project.md diff --git a/.claude/commands/tm/validate-dependencies/index.md b/.claude/commands/tm/validate-dependencies/validate-dependencies.md similarity index 100% rename from .claude/commands/tm/validate-dependencies/index.md rename to .claude/commands/tm/validate-dependencies/validate-dependencies.md diff --git a/.claude/commands/tm/workflows/auto-implement.md b/.claude/commands/tm/workflows/auto-implement-tasks.md similarity index 100% rename from .claude/commands/tm/workflows/auto-implement.md rename to .claude/commands/tm/workflows/auto-implement-tasks.md diff --git a/.claude/commands/tm/workflows/pipeline.md b/.claude/commands/tm/workflows/command-pipeline.md similarity index 100% rename from .claude/commands/tm/workflows/pipeline.md rename to .claude/commands/tm/workflows/command-pipeline.md diff --git a/.claude/commands/tm/workflows/smart-flow.md b/.claude/commands/tm/workflows/smart-workflow.md similarity index 100% rename from .claude/commands/tm/workflows/smart-flow.md rename to .claude/commands/tm/workflows/smart-workflow.md From 901eec1058ae7576fad9b3f25b58d9c251897d3a Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:00:33 +0300 Subject: [PATCH 03/23] chore: create extension scaffolding (#989) * chore: create extension scaffolding * chore: fix workspace for changeset * chore: fix package-lock --- apps/extension/package.json | 15 +++++ apps/extension/src/index.ts | 1 + apps/extension/tsconfig.json | 113 +++++++++++++++++++++++++++++++++++ package-lock.json | 37 +++++++++++- package.json | 1 + 5 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 apps/extension/package.json create mode 100644 apps/extension/src/index.ts create mode 100644 apps/extension/tsconfig.json diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 00000000..93d20ec4 --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,15 @@ +{ + "name": "extension", + "version": "0.20.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "typescript": "^5.8.3" + } +} diff --git a/apps/extension/src/index.ts b/apps/extension/src/index.ts new file mode 100644 index 00000000..6be02374 --- /dev/null +++ b/apps/extension/src/index.ts @@ -0,0 +1 @@ +console.log('hello world'); diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 00000000..1f396eb2 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,113 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "libReplacement": true, /* Enable lib replacement. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/package-lock.json b/package-lock.json index ebe5ec04..b35439c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,17 @@ { "name": "task-master-ai", - "version": "0.19.0", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "task-master-ai", - "version": "0.19.0", + "version": "0.20.0", "license": "MIT WITH Commons-Clause", + "workspaces": [ + "apps/*", + "." + ], "dependencies": { "@ai-sdk/amazon-bedrock": "^2.2.9", "@ai-sdk/anthropic": "^1.2.10", @@ -80,6 +84,13 @@ "ai-sdk-provider-gemini-cli": "^0.0.4" } }, + "apps/extension": { + "version": "0.20.0", + "license": "ISC", + "devDependencies": { + "typescript": "^5.8.3" + } + }, "node_modules/@ai-sdk/amazon-bedrock": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-2.2.10.tgz", @@ -7340,6 +7351,10 @@ "dev": true, "license": "MIT" }, + "node_modules/extension": { + "resolved": "apps/extension", + "link": true + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -12977,6 +12992,10 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/task-master-ai": { + "resolved": "", + "link": true + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -13185,6 +13204,20 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uint8array-extras": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", diff --git a/package.json b/package.json index c4941415..7af991f2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "task-master-mcp": "mcp-server/server.js", "task-master-ai": "mcp-server/server.js" }, + "workspaces": ["apps/*", "."], "scripts": { "test": "node --experimental-vm-modules node_modules/.bin/jest", "test:fails": "node --experimental-vm-modules node_modules/.bin/jest --onlyFailures", From 0a70ab6179cb2b5b4b2d9dc256a7a3b69a0e5dd6 Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Wed, 16 Jul 2025 03:07:33 -0400 Subject: [PATCH 04/23] feat(profiles): Add MCP configuration to Claude Code rules (#980) * add .mcp.json with claude profile * add changeset * update changeset * update test --- .changeset/swift-turtles-sit.md | 5 ++ src/profiles/base-profile.js | 4 +- src/profiles/claude.js | 67 ++++++++++++++++++- .../claude-init-functionality.test.js | 7 +- .../unit/profiles/claude-integration.test.js | 16 ++++- .../profiles/mcp-config-validation.test.js | 65 ++++++++++-------- tests/unit/profiles/rule-transformer.test.js | 51 ++++++-------- 7 files changed, 149 insertions(+), 66 deletions(-) create mode 100644 .changeset/swift-turtles-sit.md diff --git a/.changeset/swift-turtles-sit.md b/.changeset/swift-turtles-sit.md new file mode 100644 index 00000000..b5f57475 --- /dev/null +++ b/.changeset/swift-turtles-sit.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Add MCP configuration support to Claude Code rules diff --git a/src/profiles/base-profile.js b/src/profiles/base-profile.js index 6f6add59..6f9c5e56 100644 --- a/src/profiles/base-profile.js +++ b/src/profiles/base-profile.js @@ -46,7 +46,9 @@ export function createProfile(editorConfig) { onPostConvert } = editorConfig; - const mcpConfigPath = mcpConfigName ? `${profileDir}/${mcpConfigName}` : null; + const mcpConfigPath = mcpConfigName + ? path.join(profileDir, mcpConfigName) + : null; // Standard file mapping with custom overrides // Use taskmaster subdirectory only if profile supports it diff --git a/src/profiles/claude.js b/src/profiles/claude.js index 2fc347f0..9790a2a8 100644 --- a/src/profiles/claude.js +++ b/src/profiles/claude.js @@ -197,9 +197,73 @@ function onRemoveRulesProfile(targetDir) { } } +/** + * Transform standard MCP config format to Claude format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed Claude configuration object + */ +function transformToClaudeFormat(mcpConfig) { + const claudeConfig = {}; + + // Transform mcpServers to servers (keeping the same structure but adding type) + if (mcpConfig.mcpServers) { + claudeConfig.mcpServers = {}; + + for (const [serverName, serverConfig] of Object.entries( + mcpConfig.mcpServers + )) { + // Transform server configuration with type as first key + const reorderedServer = {}; + + // Add type: "stdio" as the first key + reorderedServer.type = 'stdio'; + + // Then add the rest of the properties in order + if (serverConfig.command) reorderedServer.command = serverConfig.command; + if (serverConfig.args) reorderedServer.args = serverConfig.args; + if (serverConfig.env) reorderedServer.env = serverConfig.env; + + // Add any other properties that might exist + Object.keys(serverConfig).forEach((key) => { + if (!['command', 'args', 'env', 'type'].includes(key)) { + reorderedServer[key] = serverConfig[key]; + } + }); + + claudeConfig.mcpServers[serverName] = reorderedServer; + } + } + + return claudeConfig; +} + function onPostConvertRulesProfile(targetDir, assetsDir) { // For Claude, post-convert is the same as add since we don't transform rules onAddRulesProfile(targetDir, assetsDir); + + // Transform MCP configuration to Claude format + const mcpConfigPath = path.join(targetDir, '.mcp.json'); + if (fs.existsSync(mcpConfigPath)) { + try { + const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); + const claudeConfig = transformToClaudeFormat(mcpConfig); + + // Write back the transformed configuration + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(claudeConfig, null, '\t') + '\n' + ); + log( + 'debug', + `[Claude] Transformed MCP configuration to Claude format at ${mcpConfigPath}` + ); + } catch (err) { + log( + 'error', + `[Claude] Failed to transform MCP configuration: ${err.message}` + ); + } + } } // Create and export claude profile using the base factory @@ -210,8 +274,7 @@ export const claudeProfile = createProfile({ docsUrl: 'docs.anthropic.com/en/docs/claude-code', profileDir: '.', // Root directory rulesDir: '.', // No specific rules directory needed - mcpConfig: false, - mcpConfigName: null, + mcpConfigName: '.mcp.json', // Place MCP config in project root includeDefaultRules: false, fileMap: { 'AGENTS.md': '.taskmaster/CLAUDE.md' diff --git a/tests/integration/profiles/claude-init-functionality.test.js b/tests/integration/profiles/claude-init-functionality.test.js index ed623630..7ae49dc3 100644 --- a/tests/integration/profiles/claude-init-functionality.test.js +++ b/tests/integration/profiles/claude-init-functionality.test.js @@ -21,7 +21,7 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfileContent).toContain("displayName: 'Claude Code'"); expect(claudeProfileContent).toContain("profileDir: '.'"); // non-default expect(claudeProfileContent).toContain("rulesDir: '.'"); // non-default - expect(claudeProfileContent).toContain('mcpConfig: false'); // non-default + expect(claudeProfileContent).toContain("mcpConfigName: '.mcp.json'"); // non-default expect(claudeProfileContent).toContain('includeDefaultRules: false'); // non-default expect(claudeProfileContent).toContain( "'AGENTS.md': '.taskmaster/CLAUDE.md'" @@ -32,8 +32,9 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfile.displayName).toBe('Claude Code'); expect(claudeProfile.profileDir).toBe('.'); expect(claudeProfile.rulesDir).toBe('.'); - expect(claudeProfile.mcpConfig).toBe(false); - expect(claudeProfile.mcpConfigName).toBe(null); // computed + expect(claudeProfile.mcpConfig).toBe(true); // default from base profile + expect(claudeProfile.mcpConfigName).toBe('.mcp.json'); // explicitly set + expect(claudeProfile.mcpConfigPath).toBe('.mcp.json'); // computed expect(claudeProfile.includeDefaultRules).toBe(false); expect(claudeProfile.fileMap['AGENTS.md']).toBe('.taskmaster/CLAUDE.md'); }); diff --git a/tests/unit/profiles/claude-integration.test.js b/tests/unit/profiles/claude-integration.test.js index 4fe723a8..900468e3 100644 --- a/tests/unit/profiles/claude-integration.test.js +++ b/tests/unit/profiles/claude-integration.test.js @@ -2,6 +2,7 @@ import { jest } from '@jest/globals'; import fs from 'fs'; import path from 'path'; import os from 'os'; +import { claudeProfile } from '../../../src/profiles/claude.js'; // Mock external modules jest.mock('child_process', () => ({ @@ -77,11 +78,22 @@ describe('Claude Profile Integration', () => { expect(mkdirCalls).toHaveLength(0); }); - test('does not create MCP configuration files', () => { + test('supports MCP configuration when using rule transformer', () => { + // This test verifies that the Claude profile is configured to support MCP + // The actual MCP file creation is handled by the rule transformer + + // Assert - Claude profile should now support MCP configuration + expect(claudeProfile.mcpConfig).toBe(true); + expect(claudeProfile.mcpConfigName).toBe('.mcp.json'); + expect(claudeProfile.mcpConfigPath).toBe('.mcp.json'); + }); + + test('mock function does not create MCP configuration files', () => { // Act mockCreateClaudeStructure(); - // Assert - Claude profile should not create any MCP config files + // Assert - The mock function should not create MCP config files + // (This is expected since the mock doesn't use the rule transformer) const writeFileCalls = fs.writeFileSync.mock.calls; const mcpConfigCalls = writeFileCalls.filter( (call) => diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index 9397ae9f..91f4c0cb 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -92,7 +92,12 @@ describe('MCP Configuration Validation', () => { RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/); + // Claude profile uses root directory (.), so its path is just '.mcp.json' + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/); + } } }); }); @@ -123,17 +128,13 @@ describe('MCP Configuration Validation', () => { }); test('should have null config name for non-MCP profiles', () => { - const clineProfile = getRulesProfile('cline'); - expect(clineProfile.mcpConfigName).toBe(null); + // Only codex, cline, and trae profiles should have null config names + const nonMcpProfiles = ['codex', 'cline', 'trae']; - const traeProfile = getRulesProfile('trae'); - expect(traeProfile.mcpConfigName).toBe(null); - - const claudeProfile = getRulesProfile('claude'); - expect(claudeProfile.mcpConfigName).toBe(null); - - const codexProfile = getRulesProfile('codex'); - expect(codexProfile.mcpConfigName).toBe(null); + for (const profileName of nonMcpProfiles) { + const profile = getRulesProfile(profileName); + expect(profile.mcpConfigName).toBe(null); + } }); }); @@ -185,17 +186,19 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Creation Logic', () => { test('should indicate which profiles require MCP configuration creation', () => { + // Get all profiles that have MCP configuration enabled const mcpEnabledProfiles = RULE_PROFILES.filter((profileName) => { const profile = getRulesProfile(profileName); return profile.mcpConfig !== false; }); + // Verify expected MCP-enabled profiles + expect(mcpEnabledProfiles).toContain('claude'); expect(mcpEnabledProfiles).toContain('cursor'); expect(mcpEnabledProfiles).toContain('gemini'); expect(mcpEnabledProfiles).toContain('roo'); expect(mcpEnabledProfiles).toContain('vscode'); expect(mcpEnabledProfiles).toContain('windsurf'); - expect(mcpEnabledProfiles).not.toContain('claude'); expect(mcpEnabledProfiles).not.toContain('cline'); expect(mcpEnabledProfiles).not.toContain('codex'); expect(mcpEnabledProfiles).not.toContain('trae'); @@ -215,18 +218,25 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Path Usage Verification', () => { test('should verify that rule transformer functions use mcpConfigPath correctly', () => { - // This test verifies that the mcpConfigPath property exists and is properly formatted - // for use with the setupMCPConfiguration function RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { // Verify the path is properly formatted for path.join usage expect(profile.mcpConfigPath.startsWith('/')).toBe(false); - expect(profile.mcpConfigPath).toContain('/'); + + // Claude profile uses root directory (.), so its path is just '.mcp.json' + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + expect(profile.mcpConfigPath).toContain('/'); + } // Verify it matches the expected pattern: profileDir/configName const expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; - expect(profile.mcpConfigPath).toBe(expectedPath); + // For Claude, path.join('.', '.mcp.json') returns '.mcp.json' + const normalizedExpected = + profileName === 'claude' ? '.mcp.json' : expectedPath; + expect(profile.mcpConfigPath).toBe(normalizedExpected); } }); }); @@ -250,20 +260,19 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Function Integration', () => { test('should verify that setupMCPConfiguration receives the correct mcpConfigPath parameter', () => { - // This test verifies the integration between rule transformer and mcp-utils RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - // Verify that the mcpConfigPath can be used directly with setupMCPConfiguration - // The function signature is: setupMCPConfiguration(projectDir, mcpConfigPath) - expect(profile.mcpConfigPath).toBeDefined(); - expect(typeof profile.mcpConfigPath).toBe('string'); - // Verify the path structure is correct for the new function signature - const parts = profile.mcpConfigPath.split('/'); - expect(parts).toHaveLength(2); // Should be profileDir/configName - expect(parts[0]).toBe(profile.profileDir); - expect(parts[1]).toBe(profile.mcpConfigName); + if (profileName === 'claude') { + // Claude profile uses root directory, so path is just '.mcp.json' + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + const parts = profile.mcpConfigPath.split('/'); + expect(parts).toHaveLength(2); // Should be profileDir/configName + expect(parts[0]).toBe(profile.profileDir); + expect(parts[1]).toBe(profile.mcpConfigName); + } } }); }); @@ -271,7 +280,9 @@ describe('MCP Configuration Validation', () => { describe('MCP configuration validation', () => { const mcpProfiles = ['cursor', 'gemini', 'roo', 'windsurf', 'vscode']; - const nonMcpProfiles = ['claude', 'codex', 'cline', 'trae']; + const nonMcpProfiles = ['codex', 'cline', 'trae']; + const profilesWithLifecycle = ['claude']; + const profilesWithoutLifecycle = ['codex']; test.each(mcpProfiles)( 'should have valid MCP config for %s profile', diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index 7950d738..6ab1083a 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -3,6 +3,7 @@ import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; import { RULE_PROFILES } from '../../../src/constants/profiles.js'; +import path from 'path'; describe('Rule Transformer - General', () => { describe('Profile Configuration Validation', () => { @@ -166,19 +167,13 @@ describe('Rule Transformer - General', () => { // Check types based on MCP configuration expect(typeof profileConfig.mcpConfig).toBe('boolean'); - if (profileConfig.mcpConfig === false) { - // Profiles without MCP configuration - expect(profileConfig.mcpConfigName).toBe(null); - expect(profileConfig.mcpConfigPath).toBe(null); - } else { - // Profiles with MCP configuration - expect(typeof profileConfig.mcpConfigName).toBe('string'); - expect(typeof profileConfig.mcpConfigPath).toBe('string'); - + if (profileConfig.mcpConfig !== false) { // Check that mcpConfigPath is properly constructed - expect(profileConfig.mcpConfigPath).toBe( - `${profileConfig.profileDir}/${profileConfig.mcpConfigName}` + const expectedPath = path.join( + profileConfig.profileDir, + profileConfig.mcpConfigName ); + expect(profileConfig.mcpConfigPath).toBe(expectedPath); } }); }); @@ -186,9 +181,9 @@ describe('Rule Transformer - General', () => { it('should have correct MCP configuration for each profile', () => { const expectedConfigs = { claude: { - mcpConfig: false, - mcpConfigName: null, - expectedPath: null + mcpConfig: true, + mcpConfigName: '.mcp.json', + expectedPath: '.mcp.json' }, cline: { mcpConfig: false, @@ -245,25 +240,19 @@ describe('Rule Transformer - General', () => { it('should have consistent profileDir and mcpConfigPath relationship', () => { RULE_PROFILES.forEach((profile) => { const profileConfig = getRulesProfile(profile); - - if (profileConfig.mcpConfig === false) { - // Profiles without MCP configuration have null mcpConfigPath - expect(profileConfig.mcpConfigPath).toBe(null); - } else { + if (profileConfig.mcpConfig !== false) { // Profiles with MCP configuration should have valid paths // The mcpConfigPath should start with the profileDir - expect(profileConfig.mcpConfigPath).toMatch( - new RegExp( - `^${profileConfig.profileDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/` - ) - ); - - // The mcpConfigPath should end with the mcpConfigName - expect(profileConfig.mcpConfigPath).toMatch( - new RegExp( - `${profileConfig.mcpConfigName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$` - ) - ); + if (profile === 'claude') { + // Claude uses root directory (.), so path.join('.', '.mcp.json') = '.mcp.json' + expect(profileConfig.mcpConfigPath).toBe('.mcp.json'); + } else { + expect(profileConfig.mcpConfigPath).toMatch( + new RegExp( + `^${profileConfig.profileDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/` + ) + ); + } } }); }); From 624922ca598c4ce8afe9a5646ebb375d4616db63 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Wed, 16 Jul 2025 01:24:06 -0600 Subject: [PATCH 05/23] fix: show command no longer requires complexity report to exist (#979) Co-authored-by: Ben Vargas --- .changeset/fix-show-command-complexity.md | 7 +++++++ scripts/modules/commands.js | 12 ++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-show-command-complexity.md diff --git a/.changeset/fix-show-command-complexity.md b/.changeset/fix-show-command-complexity.md new file mode 100644 index 00000000..c73e131d --- /dev/null +++ b/.changeset/fix-show-command-complexity.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": patch +--- + +Fix: show command no longer requires complexity report file to exist + +The `tm show` command was incorrectly requiring the complexity report file to exist even when not needed. Now it only validates the complexity report path when a custom report file is explicitly provided via the -r/--report option. \ No newline at end of file diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 4dabf8a7..f68d4706 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -2353,10 +2353,14 @@ ${result.result} .option('--tag ', 'Specify tag context for task operations') .action(async (taskId, options) => { // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true, - complexityReportPath: options.report || false - }); + const initOptions = { + tasksPath: options.file || true + }; + // Only pass complexityReportPath if user provided a custom path + if (options.report && options.report !== COMPLEXITY_REPORT_FILE) { + initOptions.complexityReportPath = options.report; + } + const taskMaster = initTaskMaster(initOptions); const idArg = taskId || options.id; const statusFilter = options.status; From 58a301c380d18a9d9509137f3e989d24200a5faa Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Wed, 16 Jul 2025 06:42:12 -0600 Subject: [PATCH 06/23] feat: complete Groq provider integration and add Kimi K2 model (#978) * feat: complete Groq provider integration and add Kimi K2 model - Add missing getRequiredApiKeyName() method to GroqProvider class - Register GroqProvider in ai-services-unified.js PROVIDERS object - Add Groq API key handling to config-manager.js (isApiKeySet and getMcpApiKeyStatus) - Add GROQ_API_KEY to env.example with format hint - Add moonshotai/kimi-k2-instruct model to Groq provider ($1/$3 per 1M tokens, 16k max) - Fix import sorting for linting compliance - Add GroqProvider mock to ai-services-unified tests Fixes missing implementation pieces that prevented Groq provider from working. * chore: improve changeset --------- Co-authored-by: Ben Vargas Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> --- .changeset/groq-kimi-k2-support.md | 10 +++++ assets/env.example | 1 + scripts/modules/ai-services-unified.js | 52 +++++++++++++------------- scripts/modules/config-manager.js | 17 ++++++--- scripts/modules/supported-models.json | 10 +++++ src/ai-providers/groq.js | 8 ++++ tests/unit/ai-services-unified.test.js | 7 ++++ 7 files changed, 74 insertions(+), 31 deletions(-) create mode 100644 .changeset/groq-kimi-k2-support.md diff --git a/.changeset/groq-kimi-k2-support.md b/.changeset/groq-kimi-k2-support.md new file mode 100644 index 00000000..663a0f5f --- /dev/null +++ b/.changeset/groq-kimi-k2-support.md @@ -0,0 +1,10 @@ +--- +"task-master-ai": minor +--- + +Complete Groq provider integration and add MoonshotAI Kimi K2 model support + +- Fixed Groq provider registration +- Added Groq API key validation +- Added GROQ_API_KEY to .env.example +- Added moonshotai/kimi-k2-instruct model with $1/$3 per 1M token pricing and 16k max output \ No newline at end of file diff --git a/assets/env.example b/assets/env.example index 2c5babf0..4ebc91e1 100644 --- a/assets/env.example +++ b/assets/env.example @@ -5,6 +5,7 @@ OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI/Ope GOOGLE_API_KEY="your_google_api_key_here" # Optional, for Google Gemini models. MISTRAL_API_KEY="your_mistral_key_here" # Optional, for Mistral AI models. XAI_API_KEY="YOUR_XAI_KEY_HERE" # Optional, for xAI AI models. +GROQ_API_KEY="your_groq_api_key_here" # Optional, for Groq models. Format: gsk_... AZURE_OPENAI_API_KEY="your_azure_key_here" # Optional, for Azure OpenAI models (requires endpoint in .taskmaster/config.json). OLLAMA_API_KEY="your_ollama_api_key_here" # Optional: For remote Ollama servers that require authentication. GITHUB_API_KEY="your_github_api_key_here" # Optional: For GitHub import/export features. Format: ghp_... or github_pat_... \ No newline at end of file diff --git a/scripts/modules/ai-services-unified.js b/scripts/modules/ai-services-unified.js index aefae8dc..0df4bd1a 100644 --- a/scripts/modules/ai-services-unified.js +++ b/scripts/modules/ai-services-unified.js @@ -8,47 +8,48 @@ // --- Core Dependencies --- import { - getMainProvider, - getMainModelId, - getResearchProvider, - getResearchModelId, - getFallbackProvider, + MODEL_MAP, + getAzureBaseURL, + getBaseUrlForRole, + getBedrockBaseURL, + getDebugFlag, getFallbackModelId, + getFallbackProvider, + getMainModelId, + getMainProvider, + getOllamaBaseURL, getParametersForRole, + getResearchModelId, + getResearchProvider, getResponseLanguage, getUserId, - MODEL_MAP, - getDebugFlag, - getBaseUrlForRole, - isApiKeySet, - getOllamaBaseURL, - getAzureBaseURL, - getBedrockBaseURL, - getVertexProjectId, getVertexLocation, + getVertexProjectId, + isApiKeySet, providersWithoutApiKeys } from './config-manager.js'; import { - log, findProjectRoot, - resolveEnvVariable, - getCurrentTag + getCurrentTag, + log, + resolveEnvVariable } from './utils.js'; // Import provider classes import { AnthropicAIProvider, - PerplexityAIProvider, - GoogleAIProvider, - OpenAIProvider, - XAIProvider, - OpenRouterAIProvider, - OllamaAIProvider, - BedrockAIProvider, AzureProvider, - VertexAIProvider, + BedrockAIProvider, ClaudeCodeProvider, - GeminiCliProvider + GeminiCliProvider, + GoogleAIProvider, + GroqProvider, + OllamaAIProvider, + OpenAIProvider, + OpenRouterAIProvider, + PerplexityAIProvider, + VertexAIProvider, + XAIProvider } from '../../src/ai-providers/index.js'; // Import the provider registry @@ -61,6 +62,7 @@ const PROVIDERS = { google: new GoogleAIProvider(), openai: new OpenAIProvider(), xai: new XAIProvider(), + groq: new GroqProvider(), openrouter: new OpenRouterAIProvider(), ollama: new OllamaAIProvider(), bedrock: new BedrockAIProvider(), diff --git a/scripts/modules/config-manager.js b/scripts/modules/config-manager.js index e688897b..ed9a3ebc 100644 --- a/scripts/modules/config-manager.js +++ b/scripts/modules/config-manager.js @@ -1,21 +1,21 @@ import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import chalk from 'chalk'; import { z } from 'zod'; -import { fileURLToPath } from 'url'; -import { log, findProjectRoot, resolveEnvVariable, isEmpty } from './utils.js'; +import { AI_COMMAND_NAMES } from '../../src/constants/commands.js'; import { LEGACY_CONFIG_FILE, TASKMASTER_DIR } from '../../src/constants/paths.js'; -import { findConfigPath } from '../../src/utils/path-utils.js'; import { - VALIDATED_PROVIDERS, + ALL_PROVIDERS, CUSTOM_PROVIDERS, CUSTOM_PROVIDERS_ARRAY, - ALL_PROVIDERS + VALIDATED_PROVIDERS } from '../../src/constants/providers.js'; -import { AI_COMMAND_NAMES } from '../../src/constants/commands.js'; +import { findConfigPath } from '../../src/utils/path-utils.js'; +import { findProjectRoot, isEmpty, log, resolveEnvVariable } from './utils.js'; // Calculate __dirname in ESM const __filename = fileURLToPath(import.meta.url); @@ -641,6 +641,7 @@ function isApiKeySet(providerName, session = null, projectRoot = null) { azure: 'AZURE_OPENAI_API_KEY', openrouter: 'OPENROUTER_API_KEY', xai: 'XAI_API_KEY', + groq: 'GROQ_API_KEY', vertex: 'GOOGLE_API_KEY', // Vertex uses the same key as Google 'claude-code': 'CLAUDE_CODE_API_KEY', // Not actually used, but included for consistency bedrock: 'AWS_ACCESS_KEY_ID' // Bedrock uses AWS credentials @@ -726,6 +727,10 @@ function getMcpApiKeyStatus(providerName, projectRoot = null) { apiKeyToCheck = mcpEnv.XAI_API_KEY; placeholderValue = 'YOUR_XAI_API_KEY_HERE'; break; + case 'groq': + apiKeyToCheck = mcpEnv.GROQ_API_KEY; + placeholderValue = 'YOUR_GROQ_API_KEY_HERE'; + break; case 'ollama': return true; // No key needed case 'claude-code': diff --git a/scripts/modules/supported-models.json b/scripts/modules/supported-models.json index 0960b2b9..a321e6ac 100644 --- a/scripts/modules/supported-models.json +++ b/scripts/modules/supported-models.json @@ -295,6 +295,16 @@ } ], "groq": [ + { + "id": "moonshotai/kimi-k2-instruct", + "swe_score": 0.66, + "cost_per_1m_tokens": { + "input": 1.0, + "output": 3.0 + }, + "allowed_roles": ["main", "fallback"], + "max_tokens": 16384 + }, { "id": "llama-3.3-70b-versatile", "swe_score": 0.55, diff --git a/src/ai-providers/groq.js b/src/ai-providers/groq.js index f8eda87d..8acbd6df 100644 --- a/src/ai-providers/groq.js +++ b/src/ai-providers/groq.js @@ -14,6 +14,14 @@ export class GroqProvider extends BaseAIProvider { this.name = 'Groq'; } + /** + * Returns the environment variable name required for this provider's API key. + * @returns {string} The environment variable name for the Groq API key + */ + getRequiredApiKeyName() { + return 'GROQ_API_KEY'; + } + /** * Creates and returns a Groq client instance. * @param {object} params - Parameters for client initialization diff --git a/tests/unit/ai-services-unified.test.js b/tests/unit/ai-services-unified.test.js index 3759333a..bbbe65c4 100644 --- a/tests/unit/ai-services-unified.test.js +++ b/tests/unit/ai-services-unified.test.js @@ -177,6 +177,13 @@ jest.unstable_mockModule('../../src/ai-providers/index.js', () => ({ getRequiredApiKeyName: jest.fn(() => 'XAI_API_KEY'), isRequiredApiKey: jest.fn(() => true) })), + GroqProvider: jest.fn(() => ({ + generateText: jest.fn(), + streamText: jest.fn(), + generateObject: jest.fn(), + getRequiredApiKeyName: jest.fn(() => 'GROQ_API_KEY'), + isRequiredApiKey: jest.fn(() => true) + })), OpenRouterAIProvider: jest.fn(() => ({ generateText: jest.fn(), streamText: jest.fn(), From 8774e7d5ae9e33d2ccf1f54f4a921cb8c5838efa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 16 Jul 2025 12:42:21 +0000 Subject: [PATCH 07/23] docs: Auto-update and format models.md --- docs/models.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/models.md b/docs/models.md index a5d12ef0..733a9ca3 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,4 +1,4 @@ -# Available Models as of July 10, 2025 +# Available Models as of July 16, 2025 ## Main Models @@ -32,6 +32,7 @@ | xai | grok-3 | — | 3 | 15 | | xai | grok-3-fast | — | 5 | 25 | | xai | grok-4 | — | 3 | 15 | +| groq | moonshotai/kimi-k2-instruct | 0.66 | 1 | 3 | | groq | llama-3.3-70b-versatile | 0.55 | 0.59 | 0.79 | | groq | llama-3.1-8b-instant | 0.32 | 0.05 | 0.08 | | groq | llama-4-scout | 0.45 | 0.11 | 0.34 | @@ -144,6 +145,7 @@ | xai | grok-3 | — | 3 | 15 | | xai | grok-3-fast | — | 5 | 25 | | xai | grok-4 | — | 3 | 15 | +| groq | moonshotai/kimi-k2-instruct | 0.66 | 1 | 3 | | groq | llama-3.3-70b-versatile | 0.55 | 0.59 | 0.79 | | groq | llama-3.1-8b-instant | 0.32 | 0.05 | 0.08 | | groq | llama-4-scout | 0.45 | 0.11 | 0.34 | From 6c5e0f97f8403c4da85c1abba31cb8b1789511a7 Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Wed, 16 Jul 2025 08:44:37 -0400 Subject: [PATCH 08/23] feat: Add Amp rule profile with AGENT.md and MCP config (#973) * Amp profile + tests * generatlize to Agent instead of Claude Code to support any agent * add changeset * unnecessary tab formatting * fix exports * fix formatting --- .changeset/public-crabs-ask.md | 5 + assets/AGENTS.md | 2 +- src/constants/profiles.js | 4 +- src/profiles/amp.js | 277 ++++++++++++++ src/profiles/index.js | 1 + src/utils/profiles.js | 8 +- .../profiles/amp-init-functionality.test.js | 346 ++++++++++++++++++ tests/unit/profiles/amp-integration.test.js | 299 +++++++++++++++ .../profiles/mcp-config-validation.test.js | 21 +- tests/unit/profiles/rule-transformer.test.js | 5 + 10 files changed, 958 insertions(+), 10 deletions(-) create mode 100644 .changeset/public-crabs-ask.md create mode 100644 src/profiles/amp.js create mode 100644 tests/integration/profiles/amp-init-functionality.test.js create mode 100644 tests/unit/profiles/amp-integration.test.js diff --git a/.changeset/public-crabs-ask.md b/.changeset/public-crabs-ask.md new file mode 100644 index 00000000..f122c1e8 --- /dev/null +++ b/.changeset/public-crabs-ask.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": minor +--- + +Add Amp rule profile with AGENT.md and MCP config diff --git a/assets/AGENTS.md b/assets/AGENTS.md index 83f3f786..6f664815 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -1,4 +1,4 @@ -# Task Master AI - Claude Code Integration Guide +# Task Master AI - Agent Integration Guide ## Essential Commands diff --git a/src/constants/profiles.js b/src/constants/profiles.js index 861ed406..bd861474 100644 --- a/src/constants/profiles.js +++ b/src/constants/profiles.js @@ -1,5 +1,5 @@ /** - * @typedef {'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode'} RulesProfile + * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode'} RulesProfile */ /** @@ -10,6 +10,7 @@ * * @type {RulesProfile[]} * @description Defines possible rule profile sets: + * - amp: Amp Code integration * - claude: Claude Code integration * - cline: Cline IDE rules * - codex: Codex integration @@ -26,6 +27,7 @@ * 3. Export it as {profile}Profile in src/profiles/index.js */ export const RULE_PROFILES = [ + 'amp', 'claude', 'cline', 'codex', diff --git a/src/profiles/amp.js b/src/profiles/amp.js new file mode 100644 index 00000000..6c487c66 --- /dev/null +++ b/src/profiles/amp.js @@ -0,0 +1,277 @@ +// Amp profile for rule-transformer +import path from 'path'; +import fs from 'fs'; +import { isSilentMode, log } from '../../scripts/modules/utils.js'; +import { createProfile } from './base-profile.js'; + +/** + * Transform standard MCP config format to Amp format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed Amp configuration object + */ +function transformToAmpFormat(mcpConfig) { + const ampConfig = {}; + + // Transform mcpServers to amp.mcpServers + if (mcpConfig.mcpServers) { + ampConfig['amp.mcpServers'] = mcpConfig.mcpServers; + } + + // Preserve any other existing settings + for (const [key, value] of Object.entries(mcpConfig)) { + if (key !== 'mcpServers') { + ampConfig[key] = value; + } + } + + return ampConfig; +} + +// Lifecycle functions for Amp profile +function onAddRulesProfile(targetDir, assetsDir) { + // Handle AGENT.md import for non-destructive integration (Amp uses AGENT.md, copies from AGENTS.md) + const sourceFile = path.join(assetsDir, 'AGENTS.md'); + const userAgentFile = path.join(targetDir, 'AGENT.md'); + const taskMasterAgentFile = path.join(targetDir, '.taskmaster', 'AGENT.md'); + const importLine = '@./.taskmaster/AGENT.md'; + const importSection = `\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main AGENT.md file.**\n${importLine}`; + + if (fs.existsSync(sourceFile)) { + try { + // Ensure .taskmaster directory exists + const taskMasterDir = path.join(targetDir, '.taskmaster'); + if (!fs.existsSync(taskMasterDir)) { + fs.mkdirSync(taskMasterDir, { recursive: true }); + } + + // Copy Task Master instructions to .taskmaster/AGENT.md + fs.copyFileSync(sourceFile, taskMasterAgentFile); + log( + 'debug', + `[Amp] Created Task Master instructions at ${taskMasterAgentFile}` + ); + + // Handle user's AGENT.md + if (fs.existsSync(userAgentFile)) { + // Check if import already exists + const content = fs.readFileSync(userAgentFile, 'utf8'); + if (!content.includes(importLine)) { + // Append import section at the end + const updatedContent = content.trim() + '\n' + importSection + '\n'; + fs.writeFileSync(userAgentFile, updatedContent); + log( + 'info', + `[Amp] Added Task Master import to existing ${userAgentFile}` + ); + } else { + log( + 'info', + `[Amp] Task Master import already present in ${userAgentFile}` + ); + } + } else { + // Create minimal AGENT.md with the import section + const minimalContent = `# Amp Instructions\n${importSection}\n`; + fs.writeFileSync(userAgentFile, minimalContent); + log('info', `[Amp] Created ${userAgentFile} with Task Master import`); + } + } catch (err) { + log('error', `[Amp] Failed to set up Amp instructions: ${err.message}`); + } + } + + // MCP transformation will be handled in onPostConvertRulesProfile +} + +function onRemoveRulesProfile(targetDir) { + // Clean up AGENT.md import (Amp uses AGENT.md, not AGENTS.md) + const userAgentFile = path.join(targetDir, 'AGENT.md'); + const taskMasterAgentFile = path.join(targetDir, '.taskmaster', 'AGENT.md'); + const importLine = '@./.taskmaster/AGENT.md'; + + try { + // Remove Task Master AGENT.md from .taskmaster + if (fs.existsSync(taskMasterAgentFile)) { + fs.rmSync(taskMasterAgentFile, { force: true }); + log('debug', `[Amp] Removed ${taskMasterAgentFile}`); + } + + // Clean up import from user's AGENT.md + if (fs.existsSync(userAgentFile)) { + const content = fs.readFileSync(userAgentFile, 'utf8'); + const lines = content.split('\n'); + const filteredLines = []; + let skipNextLines = 0; + + // Remove the Task Master section + for (let i = 0; i < lines.length; i++) { + if (skipNextLines > 0) { + skipNextLines--; + continue; + } + + // Check if this is the start of our Task Master section + if (lines[i].includes('## Task Master AI Instructions')) { + // Skip this line and the next two lines (bold text and import) + skipNextLines = 2; + continue; + } + + // Also remove standalone import lines (for backward compatibility) + if (lines[i].trim() === importLine) { + continue; + } + + filteredLines.push(lines[i]); + } + + // Join back and clean up excessive newlines + let updatedContent = filteredLines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + // Check if file only contained our minimal template + if (updatedContent === '# Amp Instructions' || updatedContent === '') { + // File only contained our import, remove it + fs.rmSync(userAgentFile, { force: true }); + log('debug', `[Amp] Removed empty ${userAgentFile}`); + } else { + // Write back without the import + fs.writeFileSync(userAgentFile, updatedContent + '\n'); + log('debug', `[Amp] Removed Task Master import from ${userAgentFile}`); + } + } + } catch (err) { + log('error', `[Amp] Failed to remove Amp instructions: ${err.message}`); + } + + // MCP Removal: Remove amp.mcpServers section + const mcpConfigPath = path.join(targetDir, '.vscode', 'settings.json'); + + if (!fs.existsSync(mcpConfigPath)) { + log('debug', '[Amp] No .vscode/settings.json found to clean up'); + return; + } + + try { + // Read the current config + const configContent = fs.readFileSync(mcpConfigPath, 'utf8'); + const config = JSON.parse(configContent); + + // Check if it has the amp.mcpServers section and task-master-ai server + if ( + config['amp.mcpServers'] && + config['amp.mcpServers']['task-master-ai'] + ) { + // Remove task-master-ai server + delete config['amp.mcpServers']['task-master-ai']; + + // Check if there are other MCP servers in amp.mcpServers + const remainingServers = Object.keys(config['amp.mcpServers']); + + if (remainingServers.length === 0) { + // No other servers, remove entire amp.mcpServers section + delete config['amp.mcpServers']; + log('debug', '[Amp] Removed empty amp.mcpServers section'); + } + + // Check if config is now empty + const remainingKeys = Object.keys(config); + + if (remainingKeys.length === 0) { + // Config is empty, remove entire file + fs.rmSync(mcpConfigPath, { force: true }); + log('info', '[Amp] Removed empty settings.json file'); + + // Check if .vscode directory is empty + const vscodeDirPath = path.join(targetDir, '.vscode'); + if (fs.existsSync(vscodeDirPath)) { + const remainingContents = fs.readdirSync(vscodeDirPath); + if (remainingContents.length === 0) { + fs.rmSync(vscodeDirPath, { recursive: true, force: true }); + log('debug', '[Amp] Removed empty .vscode directory'); + } + } + } else { + // Write back the modified config + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(config, null, '\t') + '\n' + ); + log( + 'info', + '[Amp] Removed TaskMaster from settings.json, preserved other configurations' + ); + } + } else { + log('debug', '[Amp] TaskMaster not found in amp.mcpServers'); + } + } catch (error) { + log('error', `[Amp] Failed to clean up settings.json: ${error.message}`); + } +} + +function onPostConvertRulesProfile(targetDir, assetsDir) { + // Handle AGENT.md setup (same as onAddRulesProfile) + onAddRulesProfile(targetDir, assetsDir); + + // Transform MCP config to Amp format + const mcpConfigPath = path.join(targetDir, '.vscode', 'settings.json'); + + if (!fs.existsSync(mcpConfigPath)) { + log('debug', '[Amp] No .vscode/settings.json found to transform'); + return; + } + + try { + // Read the generated standard MCP config + const mcpConfigContent = fs.readFileSync(mcpConfigPath, 'utf8'); + const mcpConfig = JSON.parse(mcpConfigContent); + + // Check if it's already in Amp format (has amp.mcpServers) + if (mcpConfig['amp.mcpServers']) { + log( + 'info', + '[Amp] settings.json already in Amp format, skipping transformation' + ); + return; + } + + // Transform to Amp format + const ampConfig = transformToAmpFormat(mcpConfig); + + // Write back the transformed config with proper formatting + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(ampConfig, null, '\t') + '\n' + ); + + log('info', '[Amp] Transformed settings.json to Amp format'); + log('debug', '[Amp] Renamed mcpServers to amp.mcpServers'); + } catch (error) { + log('error', `[Amp] Failed to transform settings.json: ${error.message}`); + } +} + +// Create and export amp profile using the base factory +export const ampProfile = createProfile({ + name: 'amp', + displayName: 'Amp', + url: 'ampcode.com', + docsUrl: 'ampcode.com/manual', + profileDir: '.vscode', + rulesDir: '.', + mcpConfig: true, + mcpConfigName: 'settings.json', + includeDefaultRules: false, + fileMap: { + 'AGENTS.md': '.taskmaster/AGENT.md' + }, + onAdd: onAddRulesProfile, + onRemove: onRemoveRulesProfile, + onPostConvert: onPostConvertRulesProfile +}); + +// Export lifecycle functions separately to avoid naming conflicts +export { onAddRulesProfile, onRemoveRulesProfile, onPostConvertRulesProfile }; diff --git a/src/profiles/index.js b/src/profiles/index.js index 01b1b9fc..f603d1c9 100644 --- a/src/profiles/index.js +++ b/src/profiles/index.js @@ -1,4 +1,5 @@ // Profile exports for centralized importing +export { ampProfile } from './amp.js'; export { claudeProfile } from './claude.js'; export { clineProfile } from './cline.js'; export { codexProfile } from './codex.js'; diff --git a/src/utils/profiles.js b/src/utils/profiles.js index 32a2b7cf..def22ff1 100644 --- a/src/utils/profiles.js +++ b/src/utils/profiles.js @@ -113,13 +113,15 @@ export async function runInteractiveProfilesSetup() { const hasMcpConfig = profile.mcpConfig === true; if (!profile.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini) - don't include standard coding rules + // Integration guide profiles (claude, codex, gemini, amp) - don't include standard coding rules if (profileName === 'claude') { description = 'Integration guide with Task Master slash commands'; } else if (profileName === 'codex') { description = 'Comprehensive Task Master integration guide'; } else if (profileName === 'gemini') { description = 'Integration guide and MCP config'; + } else if (profileName === 'amp') { + description = 'Integration guide and MCP config'; } else { description = 'Integration guide'; } @@ -199,7 +201,7 @@ export function generateProfileSummary(profileName, addResult) { const profileConfig = getRulesProfile(profileName); if (!profileConfig.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini) + // Integration guide profiles (claude, codex, gemini, amp) return `Summary for ${profileName}: Integration guide installed.`; } else { // Rule profiles with coding guidelines @@ -225,7 +227,7 @@ export function generateProfileRemovalSummary(profileName, removeResult) { const profileConfig = getRulesProfile(profileName); if (!profileConfig.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini) + // Integration guide profiles (claude, codex, gemini, amp) const baseMessage = `Summary for ${profileName}: Integration guide removed`; if (removeResult.notice) { return `${baseMessage} (${removeResult.notice})`; diff --git a/tests/integration/profiles/amp-init-functionality.test.js b/tests/integration/profiles/amp-init-functionality.test.js new file mode 100644 index 00000000..dcf862b6 --- /dev/null +++ b/tests/integration/profiles/amp-init-functionality.test.js @@ -0,0 +1,346 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; +import { convertAllRulesToProfileRules } from '../../../src/utils/rule-transformer.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('Amp Profile Init Functionality', () => { + let tempDir; + let ampProfile; + + beforeEach(() => { + // Create temporary directory for testing + tempDir = fs.mkdtempSync(path.join(__dirname, 'temp-amp-')); + + // Get the Amp profile + ampProfile = getRulesProfile('amp'); + }); + + afterEach(() => { + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('Profile Configuration', () => { + test('should have correct profile metadata', () => { + expect(ampProfile).toBeDefined(); + expect(ampProfile.profileName).toBe('amp'); + expect(ampProfile.displayName).toBe('Amp'); + expect(ampProfile.profileDir).toBe('.vscode'); + expect(ampProfile.rulesDir).toBe('.'); + expect(ampProfile.mcpConfig).toBe(true); + expect(ampProfile.mcpConfigName).toBe('settings.json'); + expect(ampProfile.mcpConfigPath).toBe('.vscode/settings.json'); + expect(ampProfile.includeDefaultRules).toBe(false); + }); + + test('should have correct file mapping', () => { + expect(ampProfile.fileMap).toBeDefined(); + expect(ampProfile.fileMap['AGENTS.md']).toBe('.taskmaster/AGENT.md'); + }); + + test('should have lifecycle functions', () => { + expect(typeof ampProfile.onAddRulesProfile).toBe('function'); + expect(typeof ampProfile.onRemoveRulesProfile).toBe('function'); + expect(typeof ampProfile.onPostConvertRulesProfile).toBe('function'); + }); + }); + + describe('AGENT.md Handling', () => { + test('should create AGENT.md with import when none exists', () => { + // Create mock AGENTS.md source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Check that AGENT.md was created with import + const agentFile = path.join(tempDir, 'AGENT.md'); + expect(fs.existsSync(agentFile)).toBe(true); + + const content = fs.readFileSync(agentFile, 'utf8'); + expect(content).toContain('# Amp Instructions'); + expect(content).toContain('## Task Master AI Instructions'); + expect(content).toContain('@./.taskmaster/AGENT.md'); + + // Check that .taskmaster/AGENT.md was created + const taskMasterAgent = path.join(tempDir, '.taskmaster', 'AGENT.md'); + expect(fs.existsSync(taskMasterAgent)).toBe(true); + }); + + test('should append import to existing AGENT.md', () => { + // Create existing AGENT.md + const existingContent = + '# My Existing Amp Instructions\n\nSome content here.'; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), existingContent); + + // Create mock AGENTS.md source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Check that import was appended + const agentFile = path.join(tempDir, 'AGENT.md'); + const content = fs.readFileSync(agentFile, 'utf8'); + expect(content).toContain('# My Existing Amp Instructions'); + expect(content).toContain('Some content here.'); + expect(content).toContain('## Task Master AI Instructions'); + expect(content).toContain('@./.taskmaster/AGENT.md'); + }); + + test('should not duplicate import if already exists', () => { + // Create AGENT.md with existing import + const existingContent = + "# My Amp Instructions\n\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main AGENT.md file.**\n@./.taskmaster/AGENT.md"; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), existingContent); + + // Create mock AGENTS.md source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Check that import was not duplicated + const agentFile = path.join(tempDir, 'AGENT.md'); + const content = fs.readFileSync(agentFile, 'utf8'); + const importCount = (content.match(/@\.\/.taskmaster\/AGENT\.md/g) || []) + .length; + expect(importCount).toBe(1); + }); + }); + + describe('MCP Configuration', () => { + test('should rename mcpServers to amp.mcpServers', () => { + // Create .vscode directory and settings.json with mcpServers + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + } + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onPostConvertRulesProfile (which should transform mcpServers to amp.mcpServers) + ampProfile.onPostConvertRulesProfile( + tempDir, + path.join(tempDir, 'assets') + ); + + // Check that mcpServers was renamed to amp.mcpServers + const settingsFile = path.join(vscodeDirPath, 'settings.json'); + const content = fs.readFileSync(settingsFile, 'utf8'); + const config = JSON.parse(content); + + expect(config.mcpServers).toBeUndefined(); + expect(config['amp.mcpServers']).toBeDefined(); + expect(config['amp.mcpServers']['task-master-ai']).toBeDefined(); + }); + + test('should not rename if amp.mcpServers already exists', () => { + // Create .vscode directory and settings.json with both mcpServers and amp.mcpServers + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + mcpServers: { + 'some-other-server': { + command: 'other-command' + } + }, + 'amp.mcpServers': { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + } + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, path.join(tempDir, 'assets')); + + // Check that both sections remain unchanged + const settingsFile = path.join(vscodeDirPath, 'settings.json'); + const content = fs.readFileSync(settingsFile, 'utf8'); + const config = JSON.parse(content); + + expect(config.mcpServers).toBeDefined(); + expect(config.mcpServers['some-other-server']).toBeDefined(); + expect(config['amp.mcpServers']).toBeDefined(); + expect(config['amp.mcpServers']['task-master-ai']).toBeDefined(); + }); + }); + + describe('Removal Functionality', () => { + test('should remove AGENT.md import and clean up files', () => { + // Setup: Create AGENT.md with import and .taskmaster/AGENT.md + const agentContent = + "# My Amp Instructions\n\nSome content.\n\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main AGENT.md file.**\n@./.taskmaster/AGENT.md\n"; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), agentContent); + + fs.mkdirSync(path.join(tempDir, '.taskmaster'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, '.taskmaster', 'AGENT.md'), + 'Task Master instructions' + ); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that .taskmaster/AGENT.md was removed + expect(fs.existsSync(path.join(tempDir, '.taskmaster', 'AGENT.md'))).toBe( + false + ); + + // Check that import was removed from AGENT.md + const remainingContent = fs.readFileSync( + path.join(tempDir, 'AGENT.md'), + 'utf8' + ); + expect(remainingContent).not.toContain('## Task Master AI Instructions'); + expect(remainingContent).not.toContain('@./.taskmaster/AGENT.md'); + expect(remainingContent).toContain('# My Amp Instructions'); + expect(remainingContent).toContain('Some content.'); + }); + + test('should remove empty AGENT.md if only contained import', () => { + // Setup: Create AGENT.md with only import + const agentContent = + "# Amp Instructions\n\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main AGENT.md file.**\n@./.taskmaster/AGENT.md"; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), agentContent); + + fs.mkdirSync(path.join(tempDir, '.taskmaster'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, '.taskmaster', 'AGENT.md'), + 'Task Master instructions' + ); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that AGENT.md was removed + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(false); + }); + + test('should remove amp.mcpServers section from settings.json', () => { + // Setup: Create .vscode/settings.json with amp.mcpServers and other settings + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + 'amp.mcpServers': { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + }, + 'other.setting': 'value' + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that amp.mcpServers was removed but other settings remain + const settingsFile = path.join(vscodeDirPath, 'settings.json'); + expect(fs.existsSync(settingsFile)).toBe(true); + + const content = fs.readFileSync(settingsFile, 'utf8'); + const config = JSON.parse(content); + + expect(config['amp.mcpServers']).toBeUndefined(); + expect(config['other.setting']).toBe('value'); + }); + + test('should remove settings.json and .vscode directory if empty after removal', () => { + // Setup: Create .vscode/settings.json with only amp.mcpServers + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + 'amp.mcpServers': { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + } + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that settings.json and .vscode directory were removed + expect(fs.existsSync(path.join(vscodeDirPath, 'settings.json'))).toBe( + false + ); + expect(fs.existsSync(vscodeDirPath)).toBe(false); + }); + }); + + describe('Full Integration', () => { + test('should work with convertAllRulesToProfileRules', () => { + // This test ensures the profile works with the full rule transformer + const result = convertAllRulesToProfileRules(tempDir, ampProfile); + + expect(result.success).toBeGreaterThan(0); + expect(result.failed).toBe(0); + + // Check that .taskmaster/AGENT.md was created + expect(fs.existsSync(path.join(tempDir, '.taskmaster', 'AGENT.md'))).toBe( + true + ); + + // Check that AGENT.md was created with import + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(true); + const agentContent = fs.readFileSync( + path.join(tempDir, 'AGENT.md'), + 'utf8' + ); + expect(agentContent).toContain('@./.taskmaster/AGENT.md'); + }); + }); +}); diff --git a/tests/unit/profiles/amp-integration.test.js b/tests/unit/profiles/amp-integration.test.js new file mode 100644 index 00000000..53eff784 --- /dev/null +++ b/tests/unit/profiles/amp-integration.test.js @@ -0,0 +1,299 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('Amp Profile Integration', () => { + let tempDir; + let ampProfile; + + beforeEach(() => { + // Create temporary directory for testing + tempDir = fs.mkdtempSync(path.join(__dirname, 'temp-amp-unit-')); + + // Get the Amp profile + ampProfile = getRulesProfile('amp'); + }); + + afterEach(() => { + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('Profile Structure', () => { + test('should have expected profile structure', () => { + expect(ampProfile).toBeDefined(); + expect(ampProfile.profileName).toBe('amp'); + expect(ampProfile.displayName).toBe('Amp'); + expect(ampProfile.profileDir).toBe('.vscode'); + expect(ampProfile.rulesDir).toBe('.'); + expect(ampProfile.mcpConfig).toBe(true); + expect(ampProfile.mcpConfigName).toBe('settings.json'); + expect(ampProfile.mcpConfigPath).toBe('.vscode/settings.json'); + expect(ampProfile.includeDefaultRules).toBe(false); + }); + + test('should have correct file mapping', () => { + expect(ampProfile.fileMap).toEqual({ + 'AGENTS.md': '.taskmaster/AGENT.md' + }); + }); + + test('should not create unnecessary directories', () => { + // Unlike profiles that copy entire directories, Amp should only create what's needed + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Should only have created .taskmaster directory and AGENT.md + expect(fs.existsSync(path.join(tempDir, '.taskmaster'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(true); + + // Should not have created any other directories (like .claude) + expect(fs.existsSync(path.join(tempDir, '.amp'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, '.claude'))).toBe(false); + }); + }); + + describe('AGENT.md Import Logic', () => { + test('should handle missing source file gracefully', () => { + // Call onAddRulesProfile without creating source file + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + + // Should not throw error + expect(() => { + ampProfile.onAddRulesProfile(tempDir, assetsDir); + }).not.toThrow(); + + // Should not create any files + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, '.taskmaster', 'AGENT.md'))).toBe( + false + ); + }); + + test('should preserve existing content when adding import', () => { + // Create existing AGENT.md with specific content + const existingContent = + '# My Custom Amp Setup\n\nThis is my custom configuration.\n\n## Custom Section\n\nSome custom rules here.'; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), existingContent); + + // Create mock source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Check that existing content is preserved + const updatedContent = fs.readFileSync( + path.join(tempDir, 'AGENT.md'), + 'utf8' + ); + expect(updatedContent).toContain('# My Custom Amp Setup'); + expect(updatedContent).toContain('This is my custom configuration.'); + expect(updatedContent).toContain('## Custom Section'); + expect(updatedContent).toContain('Some custom rules here.'); + expect(updatedContent).toContain('@./.taskmaster/AGENT.md'); + }); + }); + + describe('MCP Configuration Handling', () => { + test('should handle missing .vscode directory gracefully', () => { + // Call onAddRulesProfile without .vscode directory + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + + // Should not throw error + expect(() => { + ampProfile.onAddRulesProfile(tempDir, assetsDir); + }).not.toThrow(); + }); + + test('should handle malformed JSON gracefully', () => { + // Create .vscode directory with malformed JSON + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + '{ malformed json' + ); + + // Should not throw error + expect(() => { + ampProfile.onAddRulesProfile(tempDir, path.join(tempDir, 'assets')); + }).not.toThrow(); + }); + + test('should preserve other VS Code settings when renaming', () => { + // Create .vscode/settings.json with various settings + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + 'editor.fontSize': 14, + 'editor.tabSize': 2, + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + }, + 'workbench.colorTheme': 'Dark+' + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onPostConvertRulesProfile (which handles MCP transformation) + ampProfile.onPostConvertRulesProfile( + tempDir, + path.join(tempDir, 'assets') + ); + + // Check that other settings are preserved + const settingsFile = path.join(vscodeDirPath, 'settings.json'); + const content = fs.readFileSync(settingsFile, 'utf8'); + const config = JSON.parse(content); + + expect(config['editor.fontSize']).toBe(14); + expect(config['editor.tabSize']).toBe(2); + expect(config['workbench.colorTheme']).toBe('Dark+'); + expect(config['amp.mcpServers']).toBeDefined(); + expect(config.mcpServers).toBeUndefined(); + }); + }); + + describe('Removal Logic', () => { + test('should handle missing files gracefully during removal', () => { + // Should not throw error when removing non-existent files + expect(() => { + ampProfile.onRemoveRulesProfile(tempDir); + }).not.toThrow(); + }); + + test('should handle malformed JSON gracefully during removal', () => { + // Create .vscode directory with malformed JSON + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + '{ malformed json' + ); + + // Should not throw error + expect(() => { + ampProfile.onRemoveRulesProfile(tempDir); + }).not.toThrow(); + }); + + test('should preserve .vscode directory if it contains other files', () => { + // Create .vscode directory with amp.mcpServers and other files + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + 'amp.mcpServers': { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + } + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Create another file in .vscode + fs.writeFileSync(path.join(vscodeDirPath, 'launch.json'), '{}'); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that .vscode directory is preserved + expect(fs.existsSync(vscodeDirPath)).toBe(true); + expect(fs.existsSync(path.join(vscodeDirPath, 'launch.json'))).toBe(true); + }); + }); + + describe('Lifecycle Function Integration', () => { + test('should have all required lifecycle functions', () => { + expect(typeof ampProfile.onAddRulesProfile).toBe('function'); + expect(typeof ampProfile.onRemoveRulesProfile).toBe('function'); + expect(typeof ampProfile.onPostConvertRulesProfile).toBe('function'); + }); + + test('onPostConvertRulesProfile should behave like onAddRulesProfile', () => { + // Create mock source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onPostConvertRulesProfile + ampProfile.onPostConvertRulesProfile(tempDir, assetsDir); + + // Should have same result as onAddRulesProfile + expect(fs.existsSync(path.join(tempDir, '.taskmaster', 'AGENT.md'))).toBe( + true + ); + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(true); + + const agentContent = fs.readFileSync( + path.join(tempDir, 'AGENT.md'), + 'utf8' + ); + expect(agentContent).toContain('@./.taskmaster/AGENT.md'); + }); + }); + + describe('Error Handling', () => { + test('should handle file system errors gracefully', () => { + // Mock fs.writeFileSync to throw an error + const originalWriteFileSync = fs.writeFileSync; + fs.writeFileSync = jest.fn().mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Create mock source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + originalWriteFileSync.call( + fs, + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Should not throw error + expect(() => { + ampProfile.onAddRulesProfile(tempDir, assetsDir); + }).not.toThrow(); + + // Restore original function + fs.writeFileSync = originalWriteFileSync; + }); + }); +}); diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index 91f4c0cb..b1545fb2 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -143,6 +143,8 @@ describe('MCP Configuration Validation', () => { const profileDirs = new Set(); // Profiles that use root directory (can share the same directory) const rootProfiles = ['claude', 'codex', 'gemini']; + // Profiles that intentionally share the same directory + const sharedDirectoryProfiles = ['amp', 'vscode']; // Both use .vscode RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); @@ -152,10 +154,18 @@ describe('MCP Configuration Validation', () => { expect(profile.rulesDir).toBe('.'); } - // Profile directories should be unique (except for root profiles) - if (!rootProfiles.includes(profileName) || profile.profileDir !== '.') { - expect(profileDirs.has(profile.profileDir)).toBe(false); - profileDirs.add(profile.profileDir); + // Profile directories should be unique (except for root profiles and shared directory profiles) + if ( + !rootProfiles.includes(profileName) && + !sharedDirectoryProfiles.includes(profileName) + ) { + if (profile.profileDir !== '.') { + expect(profileDirs.has(profile.profileDir)).toBe(false); + profileDirs.add(profile.profileDir); + } + } else if (sharedDirectoryProfiles.includes(profileName)) { + // Shared directory profiles should use .vscode + expect(profile.profileDir).toBe('.vscode'); } }); }); @@ -307,6 +317,7 @@ describe('MCP Configuration Validation', () => { describe('Profile structure validation', () => { const mcpProfiles = [ + 'amp', 'cursor', 'gemini', 'roo', @@ -315,7 +326,7 @@ describe('MCP Configuration Validation', () => { 'trae', 'vscode' ]; - const profilesWithLifecycle = ['claude']; + const profilesWithLifecycle = ['amp', 'claude']; const profilesWithoutLifecycle = ['codex']; test.each(mcpProfiles)( diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index 6ab1083a..07a669f3 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -180,6 +180,11 @@ describe('Rule Transformer - General', () => { it('should have correct MCP configuration for each profile', () => { const expectedConfigs = { + amp: { + mcpConfig: true, + mcpConfigName: 'settings.json', + expectedPath: '.vscode/settings.json' + }, claude: { mcpConfig: true, mcpConfigName: '.mcp.json', From b0e09c76ed73b00434ac95606679f570f1015a3d Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Wed, 16 Jul 2025 12:13:21 -0400 Subject: [PATCH 09/23] feat: Add Zed editor rule profile with agent rules and MCP config (#974) * zed profile * add changeset * update changeset --- .changeset/metal-papers-stay.md | 7 + src/constants/profiles.js | 6 +- src/profiles/index.js | 1 + src/profiles/zed.js | 178 +++++++++++++++ src/utils/profiles.js | 4 +- .../profiles/mcp-config-validation.test.js | 6 + .../profiles/rule-transformer-zed.test.js | 212 ++++++++++++++++++ tests/unit/profiles/rule-transformer.test.js | 8 +- tests/unit/profiles/zed-integration.test.js | 99 ++++++++ 9 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 .changeset/metal-papers-stay.md create mode 100644 src/profiles/zed.js create mode 100644 tests/unit/profiles/rule-transformer-zed.test.js create mode 100644 tests/unit/profiles/zed-integration.test.js diff --git a/.changeset/metal-papers-stay.md b/.changeset/metal-papers-stay.md new file mode 100644 index 00000000..6b957f81 --- /dev/null +++ b/.changeset/metal-papers-stay.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": minor +--- + +feat: Add Zed editor rule profile with agent rules and MCP config + +- Resolves #637 \ No newline at end of file diff --git a/src/constants/profiles.js b/src/constants/profiles.js index bd861474..edc59fe1 100644 --- a/src/constants/profiles.js +++ b/src/constants/profiles.js @@ -1,5 +1,5 @@ /** - * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode'} RulesProfile + * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile */ /** @@ -20,6 +20,7 @@ * - trae: Trae IDE rules * - vscode: VS Code with GitHub Copilot integration * - windsurf: Windsurf IDE rules + * - zed: Zed IDE rules * * To add a new rule profile: * 1. Add the profile name to this array @@ -36,7 +37,8 @@ export const RULE_PROFILES = [ 'roo', 'trae', 'vscode', - 'windsurf' + 'windsurf', + 'zed' ]; /** diff --git a/src/profiles/index.js b/src/profiles/index.js index f603d1c9..e353533c 100644 --- a/src/profiles/index.js +++ b/src/profiles/index.js @@ -9,3 +9,4 @@ export { rooProfile } from './roo.js'; export { traeProfile } from './trae.js'; export { vscodeProfile } from './vscode.js'; export { windsurfProfile } from './windsurf.js'; +export { zedProfile } from './zed.js'; diff --git a/src/profiles/zed.js b/src/profiles/zed.js new file mode 100644 index 00000000..989f7cd3 --- /dev/null +++ b/src/profiles/zed.js @@ -0,0 +1,178 @@ +// Zed profile for rule-transformer +import path from 'path'; +import fs from 'fs'; +import { isSilentMode, log } from '../../scripts/modules/utils.js'; +import { createProfile } from './base-profile.js'; + +/** + * Transform standard MCP config format to Zed format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed Zed configuration object + */ +function transformToZedFormat(mcpConfig) { + const zedConfig = {}; + + // Transform mcpServers to context_servers + if (mcpConfig.mcpServers) { + zedConfig['context_servers'] = mcpConfig.mcpServers; + } + + // Preserve any other existing settings + for (const [key, value] of Object.entries(mcpConfig)) { + if (key !== 'mcpServers') { + zedConfig[key] = value; + } + } + + return zedConfig; +} + +// Lifecycle functions for Zed profile +function onAddRulesProfile(targetDir, assetsDir) { + // MCP transformation will be handled in onPostConvertRulesProfile + // File copying is handled by the base profile via fileMap +} + +function onRemoveRulesProfile(targetDir) { + // Clean up .rules (Zed uses .rules directly in root) + const userRulesFile = path.join(targetDir, '.rules'); + + try { + // Remove Task Master .rules + if (fs.existsSync(userRulesFile)) { + fs.rmSync(userRulesFile, { force: true }); + log('debug', `[Zed] Removed ${userRulesFile}`); + } + } catch (err) { + log('error', `[Zed] Failed to remove Zed instructions: ${err.message}`); + } + + // MCP Removal: Remove context_servers section + const mcpConfigPath = path.join(targetDir, '.zed', 'settings.json'); + + if (!fs.existsSync(mcpConfigPath)) { + log('debug', '[Zed] No .zed/settings.json found to clean up'); + return; + } + + try { + // Read the current config + const configContent = fs.readFileSync(mcpConfigPath, 'utf8'); + const config = JSON.parse(configContent); + + // Check if it has the context_servers section and task-master-ai server + if ( + config['context_servers'] && + config['context_servers']['task-master-ai'] + ) { + // Remove task-master-ai server + delete config['context_servers']['task-master-ai']; + + // Check if there are other MCP servers in context_servers + const remainingServers = Object.keys(config['context_servers']); + + if (remainingServers.length === 0) { + // No other servers, remove entire context_servers section + delete config['context_servers']; + log('debug', '[Zed] Removed empty context_servers section'); + } + + // Check if config is now empty + const remainingKeys = Object.keys(config); + + if (remainingKeys.length === 0) { + // Config is empty, remove entire file + fs.rmSync(mcpConfigPath, { force: true }); + log('info', '[Zed] Removed empty settings.json file'); + + // Check if .zed directory is empty + const zedDirPath = path.join(targetDir, '.zed'); + if (fs.existsSync(zedDirPath)) { + const remainingContents = fs.readdirSync(zedDirPath); + if (remainingContents.length === 0) { + fs.rmSync(zedDirPath, { recursive: true, force: true }); + log('debug', '[Zed] Removed empty .zed directory'); + } + } + } else { + // Write back the modified config + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(config, null, '\t') + '\n' + ); + log( + 'info', + '[Zed] Removed TaskMaster from settings.json, preserved other configurations' + ); + } + } else { + log('debug', '[Zed] TaskMaster not found in context_servers'); + } + } catch (error) { + log('error', `[Zed] Failed to clean up settings.json: ${error.message}`); + } +} + +function onPostConvertRulesProfile(targetDir, assetsDir) { + // Handle .rules setup (same as onAddRulesProfile) + onAddRulesProfile(targetDir, assetsDir); + + // Transform MCP config to Zed format + const mcpConfigPath = path.join(targetDir, '.zed', 'settings.json'); + + if (!fs.existsSync(mcpConfigPath)) { + log('debug', '[Zed] No .zed/settings.json found to transform'); + return; + } + + try { + // Read the generated standard MCP config + const mcpConfigContent = fs.readFileSync(mcpConfigPath, 'utf8'); + const mcpConfig = JSON.parse(mcpConfigContent); + + // Check if it's already in Zed format (has context_servers) + if (mcpConfig['context_servers']) { + log( + 'info', + '[Zed] settings.json already in Zed format, skipping transformation' + ); + return; + } + + // Transform to Zed format + const zedConfig = transformToZedFormat(mcpConfig); + + // Write back the transformed config with proper formatting + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(zedConfig, null, '\t') + '\n' + ); + + log('info', '[Zed] Transformed settings.json to Zed format'); + log('debug', '[Zed] Renamed mcpServers to context_servers'); + } catch (error) { + log('error', `[Zed] Failed to transform settings.json: ${error.message}`); + } +} + +// Create and export zed profile using the base factory +export const zedProfile = createProfile({ + name: 'zed', + displayName: 'Zed', + url: 'zed.dev', + docsUrl: 'zed.dev/docs', + profileDir: '.zed', + rulesDir: '.', + mcpConfig: true, + mcpConfigName: 'settings.json', + includeDefaultRules: false, + fileMap: { + 'AGENTS.md': '.rules' + }, + onAdd: onAddRulesProfile, + onRemove: onRemoveRulesProfile, + onPostConvert: onPostConvertRulesProfile +}); + +// Export lifecycle functions separately to avoid naming conflicts +export { onAddRulesProfile, onRemoveRulesProfile, onPostConvertRulesProfile }; diff --git a/src/utils/profiles.js b/src/utils/profiles.js index def22ff1..cdf9cbd0 100644 --- a/src/utils/profiles.js +++ b/src/utils/profiles.js @@ -113,12 +113,12 @@ export async function runInteractiveProfilesSetup() { const hasMcpConfig = profile.mcpConfig === true; if (!profile.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini, amp) - don't include standard coding rules + // Integration guide profiles (claude, codex, gemini, zed, amp) - don't include standard coding rules if (profileName === 'claude') { description = 'Integration guide with Task Master slash commands'; } else if (profileName === 'codex') { description = 'Comprehensive Task Master integration guide'; - } else if (profileName === 'gemini') { + } else if (profileName === 'gemini' || profileName === 'zed') { description = 'Integration guide and MCP config'; } else if (profileName === 'amp') { description = 'Integration guide and MCP config'; diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index b1545fb2..d9cc2554 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -46,6 +46,12 @@ describe('MCP Configuration Validation', () => { expectedDir: '.windsurf', expectedConfigName: 'mcp.json', expectedPath: '.windsurf/mcp.json' + }, + zed: { + shouldHaveMcp: true, + expectedDir: '.zed', + expectedConfigName: 'settings.json', + expectedPath: '.zed/settings.json' } }; diff --git a/tests/unit/profiles/rule-transformer-zed.test.js b/tests/unit/profiles/rule-transformer-zed.test.js new file mode 100644 index 00000000..55dc4801 --- /dev/null +++ b/tests/unit/profiles/rule-transformer-zed.test.js @@ -0,0 +1,212 @@ +import { jest } from '@jest/globals'; + +// Mock fs module before importing anything that uses it +jest.mock('fs', () => ({ + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + existsSync: jest.fn(), + mkdirSync: jest.fn() +})); + +// Import modules after mocking +import fs from 'fs'; +import { convertRuleToProfileRule } from '../../../src/utils/rule-transformer.js'; +import { zedProfile } from '../../../src/profiles/zed.js'; + +describe('Zed Rule Transformer', () => { + // Set up spies on the mocked modules + const mockReadFileSync = jest.spyOn(fs, 'readFileSync'); + const mockWriteFileSync = jest.spyOn(fs, 'writeFileSync'); + const mockExistsSync = jest.spyOn(fs, 'existsSync'); + const mockMkdirSync = jest.spyOn(fs, 'mkdirSync'); + const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + beforeEach(() => { + jest.clearAllMocks(); + // Setup default mocks + mockReadFileSync.mockReturnValue(''); + mockWriteFileSync.mockImplementation(() => {}); + mockExistsSync.mockReturnValue(true); + mockMkdirSync.mockImplementation(() => {}); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should correctly convert basic terms', () => { + const testContent = `--- +description: Test Cursor rule for basic terms +globs: **/* +alwaysApply: true +--- + +This is a Cursor rule that references cursor.so and uses the word Cursor multiple times. +Also has references to .mdc files.`; + + // Mock file read to return our test content + mockReadFileSync.mockReturnValue(testContent); + + // Mock file system operations + mockExistsSync.mockReturnValue(true); + + // Call the function + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + // Verify the result + expect(result).toBe(true); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + + // Get the transformed content + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify Cursor -> Zed transformations + expect(transformedContent).toContain('zed.dev'); + expect(transformedContent).toContain('Zed'); + expect(transformedContent).not.toContain('cursor.so'); + expect(transformedContent).not.toContain('Cursor'); + expect(transformedContent).toContain('.md'); + expect(transformedContent).not.toContain('.mdc'); + }); + + it('should handle URL transformations', () => { + const testContent = `Visit https://cursor.so/docs for more information. +Also check out cursor.so and www.cursor.so for updates.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify URL transformations + expect(transformedContent).toContain('https://zed.dev'); + expect(transformedContent).toContain('zed.dev'); + expect(transformedContent).not.toContain('cursor.so'); + }); + + it('should handle file extension transformations', () => { + const testContent = `This rule references file.mdc and another.mdc file. +Use the .mdc extension for all rule files.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify file extension transformations + expect(transformedContent).toContain('file.md'); + expect(transformedContent).toContain('another.md'); + expect(transformedContent).toContain('.md extension'); + expect(transformedContent).not.toContain('.mdc'); + }); + + it('should handle case variations', () => { + const testContent = `CURSOR, Cursor, cursor should all be transformed.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify case transformations + // Due to regex order, the case-insensitive rule runs first: + // CURSOR -> Zed (because it starts with 'C'), Cursor -> Zed, cursor -> zed + expect(transformedContent).toContain('Zed'); + expect(transformedContent).toContain('zed'); + expect(transformedContent).not.toContain('CURSOR'); + expect(transformedContent).not.toContain('Cursor'); + expect(transformedContent).not.toContain('cursor'); + }); + + it('should create target directory if it does not exist', () => { + const testContent = 'Test content'; + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(false); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'nested/path/test-target.md', + zedProfile + ); + + expect(result).toBe(true); + expect(mockMkdirSync).toHaveBeenCalledWith('nested/path', { + recursive: true + }); + }); + + it('should handle file system errors gracefully', () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(false); + expect(mockConsoleError).toHaveBeenCalledWith( + 'Error converting rule file: File not found' + ); + }); + + it('should handle write errors gracefully', () => { + mockReadFileSync.mockReturnValue('Test content'); + mockWriteFileSync.mockImplementation(() => { + throw new Error('Write permission denied'); + }); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(false); + expect(mockConsoleError).toHaveBeenCalledWith( + 'Error converting rule file: Write permission denied' + ); + }); + + it('should verify profile configuration', () => { + expect(zedProfile.profileName).toBe('zed'); + expect(zedProfile.displayName).toBe('Zed'); + expect(zedProfile.profileDir).toBe('.zed'); + expect(zedProfile.mcpConfig).toBe(true); + expect(zedProfile.mcpConfigName).toBe('settings.json'); + expect(zedProfile.mcpConfigPath).toBe('.zed/settings.json'); + expect(zedProfile.includeDefaultRules).toBe(false); + expect(zedProfile.fileMap).toEqual({ + 'AGENTS.md': '.rules' + }); + }); +}); diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index 07a669f3..33b417c2 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -22,7 +22,8 @@ describe('Rule Transformer - General', () => { 'roo', 'trae', 'vscode', - 'windsurf' + 'windsurf', + 'zed' ]; expectedProfiles.forEach((profile) => { expect(RULE_PROFILES).toContain(profile); @@ -229,6 +230,11 @@ describe('Rule Transformer - General', () => { mcpConfig: true, mcpConfigName: 'mcp.json', expectedPath: '.windsurf/mcp.json' + }, + zed: { + mcpConfig: true, + mcpConfigName: 'settings.json', + expectedPath: '.zed/settings.json' } }; diff --git a/tests/unit/profiles/zed-integration.test.js b/tests/unit/profiles/zed-integration.test.js new file mode 100644 index 00000000..67cdbcbf --- /dev/null +++ b/tests/unit/profiles/zed-integration.test.js @@ -0,0 +1,99 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Mock external modules +jest.mock('child_process', () => ({ + execSync: jest.fn() +})); + +// Mock console methods +jest.mock('console', () => ({ + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + clear: jest.fn() +})); + +describe('Zed Integration', () => { + let tempDir; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); + + // Spy on fs methods + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath.toString().includes('settings.json')) { + return JSON.stringify({ context_servers: {} }, null, 2); + } + return '{}'; + }); + jest.spyOn(fs, 'existsSync').mockImplementation(() => false); + jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); + }); + + afterEach(() => { + // Clean up the temporary directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + console.error(`Error cleaning up: ${err.message}`); + } + }); + + // Test function that simulates the createProjectStructure behavior for Zed files + function mockCreateZedStructure() { + // Create main .zed directory + fs.mkdirSync(path.join(tempDir, '.zed'), { recursive: true }); + + // Create MCP config file (settings.json) + fs.writeFileSync( + path.join(tempDir, '.zed', 'settings.json'), + JSON.stringify({ context_servers: {} }, null, 2) + ); + + // Create AGENTS.md in project root + fs.writeFileSync( + path.join(tempDir, 'AGENTS.md'), + '# Task Master Instructions\n\nThis is the Task Master agents file.' + ); + } + + test('creates all required .zed directories', () => { + // Act + mockCreateZedStructure(); + + // Assert + expect(fs.mkdirSync).toHaveBeenCalledWith(path.join(tempDir, '.zed'), { + recursive: true + }); + }); + + test('creates Zed settings.json with context_servers format', () => { + // Act + mockCreateZedStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, '.zed', 'settings.json'), + JSON.stringify({ context_servers: {} }, null, 2) + ); + }); + + test('creates AGENTS.md in project root', () => { + // Act + mockCreateZedStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, 'AGENTS.md'), + '# Task Master Instructions\n\nThis is the Task Master agents file.' + ); + }); +}); From 88c434a9393e429d9277f59b3e20f1005076bbe0 Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Wed, 16 Jul 2025 12:19:52 -0400 Subject: [PATCH 10/23] fix: Add missing API keys to .env.example and README.md (#972) * add OLLAMA_API_KEY * add missing API keys * add changeset * update keys and fix OpenAI comment * chore: create extension scaffolding (#989) * chore: create extension scaffolding * chore: fix workspace for changeset * chore: fix package-lock * feat(profiles): Add MCP configuration to Claude Code rules (#980) * add .mcp.json with claude profile * add changeset * update changeset * update test * fix: show command no longer requires complexity report to exist (#979) Co-authored-by: Ben Vargas * feat: complete Groq provider integration and add Kimi K2 model (#978) * feat: complete Groq provider integration and add Kimi K2 model - Add missing getRequiredApiKeyName() method to GroqProvider class - Register GroqProvider in ai-services-unified.js PROVIDERS object - Add Groq API key handling to config-manager.js (isApiKeySet and getMcpApiKeyStatus) - Add GROQ_API_KEY to env.example with format hint - Add moonshotai/kimi-k2-instruct model to Groq provider ($1/$3 per 1M tokens, 16k max) - Fix import sorting for linting compliance - Add GroqProvider mock to ai-services-unified tests Fixes missing implementation pieces that prevented Groq provider from working. * chore: improve changeset --------- Co-authored-by: Ben Vargas Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> * docs: Auto-update and format models.md * feat: Add Amp rule profile with AGENT.md and MCP config (#973) * Amp profile + tests * generatlize to Agent instead of Claude Code to support any agent * add changeset * unnecessary tab formatting * fix exports * fix formatting * feat: Add Zed editor rule profile with agent rules and MCP config (#974) * zed profile * add changeset * update changeset --------- Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Co-authored-by: Ben Vargas Co-authored-by: Ben Vargas Co-authored-by: github-actions[bot] --- .changeset/yummy-walls-eat.md | 5 +++++ .env.example | 1 + README.md | 5 ++++- assets/env.example | 5 +++-- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .changeset/yummy-walls-eat.md diff --git a/.changeset/yummy-walls-eat.md b/.changeset/yummy-walls-eat.md new file mode 100644 index 00000000..64df1d3c --- /dev/null +++ b/.changeset/yummy-walls-eat.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Add missing API keys to .env.example and README.md diff --git a/.env.example b/.env.example index 54429bf5..b97c1efd 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ GROQ_API_KEY=YOUR_GROQ_KEY_HERE OPENROUTER_API_KEY=YOUR_OPENROUTER_KEY_HERE XAI_API_KEY=YOUR_XAI_KEY_HERE AZURE_OPENAI_API_KEY=YOUR_AZURE_KEY_HERE +OLLAMA_API_KEY=YOUR_OLLAMA_API_KEY_HERE # Google Vertex AI Configuration VERTEX_PROJECT_ID=your-gcp-project-id diff --git a/README.md b/README.md index 617688f9..075922d7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ MCP (Model Control Protocol) lets you run Task Master directly from your editor. "OPENAI_API_KEY": "YOUR_OPENAI_KEY_HERE", "GOOGLE_API_KEY": "YOUR_GOOGLE_KEY_HERE", "MISTRAL_API_KEY": "YOUR_MISTRAL_KEY_HERE", + "GROQ_API_KEY": "YOUR_GROQ_KEY_HERE", "OPENROUTER_API_KEY": "YOUR_OPENROUTER_KEY_HERE", "XAI_API_KEY": "YOUR_XAI_KEY_HERE", "AZURE_OPENAI_API_KEY": "YOUR_AZURE_KEY_HERE", @@ -110,9 +111,11 @@ MCP (Model Control Protocol) lets you run Task Master directly from your editor. "OPENAI_API_KEY": "YOUR_OPENAI_KEY_HERE", "GOOGLE_API_KEY": "YOUR_GOOGLE_KEY_HERE", "MISTRAL_API_KEY": "YOUR_MISTRAL_KEY_HERE", + "GROQ_API_KEY": "YOUR_GROQ_KEY_HERE", "OPENROUTER_API_KEY": "YOUR_OPENROUTER_KEY_HERE", "XAI_API_KEY": "YOUR_XAI_KEY_HERE", - "AZURE_OPENAI_API_KEY": "YOUR_AZURE_KEY_HERE" + "AZURE_OPENAI_API_KEY": "YOUR_AZURE_KEY_HERE", + "OLLAMA_API_KEY": "YOUR_OLLAMA_API_KEY_HERE" }, "type": "stdio" } diff --git a/assets/env.example b/assets/env.example index 4ebc91e1..60bd23e8 100644 --- a/assets/env.example +++ b/assets/env.example @@ -1,11 +1,12 @@ # API Keys (Required to enable respective provider) ANTHROPIC_API_KEY="your_anthropic_api_key_here" # Required: Format: sk-ant-api03-... PERPLEXITY_API_KEY="your_perplexity_api_key_here" # Optional: Format: pplx-... -OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI/OpenRouter models. Format: sk-proj-... +OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI models. Format: sk-proj-... GOOGLE_API_KEY="your_google_api_key_here" # Optional, for Google Gemini models. MISTRAL_API_KEY="your_mistral_key_here" # Optional, for Mistral AI models. XAI_API_KEY="YOUR_XAI_KEY_HERE" # Optional, for xAI AI models. -GROQ_API_KEY="your_groq_api_key_here" # Optional, for Groq models. Format: gsk_... +GROQ_API_KEY="YOUR_GROQ_KEY_HERE" # Optional, for Groq models. +OPENROUTER_API_KEY="YOUR_OPENROUTER_KEY_HERE" # Optional, for OpenRouter models. AZURE_OPENAI_API_KEY="your_azure_key_here" # Optional, for Azure OpenAI models (requires endpoint in .taskmaster/config.json). OLLAMA_API_KEY="your_ollama_api_key_here" # Optional: For remote Ollama servers that require authentication. GITHUB_API_KEY="your_github_api_key_here" # Optional: For GitHub import/export features. Format: ghp_... or github_pat_... \ No newline at end of file From 36c4a7a86924c927ad7f86a4f891f66ad55eb4d2 Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Wed, 16 Jul 2025 13:01:02 -0400 Subject: [PATCH 11/23] feat: Add OpenCode rule profile with AGENTS.md and MCP config (#970) * add opencode to profile lists * add opencode profile / modify mcp config after add * add changeset * not necessary; main config being updated * add issue link * add/fix tests * fix url and docsUrl * update test for new urls * fix formatting * update/fix tests --- .changeset/yellow-showers-heal.md | 7 + src/constants/profiles.js | 4 +- src/profiles/index.js | 1 + src/profiles/opencode.js | 183 ++++++++++++++++++ src/utils/profiles.js | 6 +- .../opencode-init-functionality.test.js | 85 ++++++++ .../profiles/mcp-config-validation.test.js | 153 ++++++++++++--- .../profiles/opencode-integration.test.js | 123 ++++++++++++ .../rule-transformer-opencode.test.js | 59 ++++++ tests/unit/profiles/rule-transformer.test.js | 22 ++- 10 files changed, 605 insertions(+), 38 deletions(-) create mode 100644 .changeset/yellow-showers-heal.md create mode 100644 src/profiles/opencode.js create mode 100644 tests/integration/profiles/opencode-init-functionality.test.js create mode 100644 tests/unit/profiles/opencode-integration.test.js create mode 100644 tests/unit/profiles/rule-transformer-opencode.test.js diff --git a/.changeset/yellow-showers-heal.md b/.changeset/yellow-showers-heal.md new file mode 100644 index 00000000..e403b25b --- /dev/null +++ b/.changeset/yellow-showers-heal.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": minor +--- + +Add OpenCode profile with AGENTS.md and MCP config + +- Resolves #965 diff --git a/src/constants/profiles.js b/src/constants/profiles.js index edc59fe1..8521b4d8 100644 --- a/src/constants/profiles.js +++ b/src/constants/profiles.js @@ -1,5 +1,5 @@ /** - * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile + * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'opencode' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile */ /** @@ -16,6 +16,7 @@ * - codex: Codex integration * - cursor: Cursor IDE rules * - gemini: Gemini integration + * - opencode: OpenCode integration * - roo: Roo Code IDE rules * - trae: Trae IDE rules * - vscode: VS Code with GitHub Copilot integration @@ -34,6 +35,7 @@ export const RULE_PROFILES = [ 'codex', 'cursor', 'gemini', + 'opencode', 'roo', 'trae', 'vscode', diff --git a/src/profiles/index.js b/src/profiles/index.js index e353533c..202f2663 100644 --- a/src/profiles/index.js +++ b/src/profiles/index.js @@ -5,6 +5,7 @@ export { clineProfile } from './cline.js'; export { codexProfile } from './codex.js'; export { cursorProfile } from './cursor.js'; export { geminiProfile } from './gemini.js'; +export { opencodeProfile } from './opencode.js'; export { rooProfile } from './roo.js'; export { traeProfile } from './trae.js'; export { vscodeProfile } from './vscode.js'; diff --git a/src/profiles/opencode.js b/src/profiles/opencode.js new file mode 100644 index 00000000..8705abcb --- /dev/null +++ b/src/profiles/opencode.js @@ -0,0 +1,183 @@ +// Opencode profile for rule-transformer +import path from 'path'; +import fs from 'fs'; +import { log } from '../../scripts/modules/utils.js'; +import { createProfile } from './base-profile.js'; + +/** + * Transform standard MCP config format to OpenCode format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed OpenCode configuration object + */ +function transformToOpenCodeFormat(mcpConfig) { + const openCodeConfig = { + $schema: 'https://opencode.ai/config.json' + }; + + // Transform mcpServers to mcp + if (mcpConfig.mcpServers) { + openCodeConfig.mcp = {}; + + for (const [serverName, serverConfig] of Object.entries( + mcpConfig.mcpServers + )) { + // Transform server configuration + const transformedServer = { + type: 'local' + }; + + // Combine command and args into single command array + if (serverConfig.command && serverConfig.args) { + transformedServer.command = [ + serverConfig.command, + ...serverConfig.args + ]; + } else if (serverConfig.command) { + transformedServer.command = [serverConfig.command]; + } + + // Add enabled flag + transformedServer.enabled = true; + + // Transform env to environment + if (serverConfig.env) { + transformedServer.environment = serverConfig.env; + } + + // update with transformed config + openCodeConfig.mcp[serverName] = transformedServer; + } + } + + return openCodeConfig; +} + +/** + * Lifecycle function called after MCP config generation to transform to OpenCode format + * @param {string} targetDir - Target project directory + * @param {string} assetsDir - Assets directory (unused for OpenCode) + */ +function onPostConvertRulesProfile(targetDir, assetsDir) { + const openCodeConfigPath = path.join(targetDir, 'opencode.json'); + + if (!fs.existsSync(openCodeConfigPath)) { + log('debug', '[OpenCode] No opencode.json found to transform'); + return; + } + + try { + // Read the generated standard MCP config + const mcpConfigContent = fs.readFileSync(openCodeConfigPath, 'utf8'); + const mcpConfig = JSON.parse(mcpConfigContent); + + // Check if it's already in OpenCode format (has $schema) + if (mcpConfig.$schema) { + log( + 'info', + '[OpenCode] opencode.json already in OpenCode format, skipping transformation' + ); + return; + } + + // Transform to OpenCode format + const openCodeConfig = transformToOpenCodeFormat(mcpConfig); + + // Write back the transformed config with proper formatting + fs.writeFileSync( + openCodeConfigPath, + JSON.stringify(openCodeConfig, null, 2) + '\n' + ); + + log('info', '[OpenCode] Transformed opencode.json to OpenCode format'); + log( + 'debug', + `[OpenCode] Added schema, renamed mcpServers->mcp, combined command+args, added type/enabled, renamed env->environment` + ); + } catch (error) { + log( + 'error', + `[OpenCode] Failed to transform opencode.json: ${error.message}` + ); + } +} + +/** + * Lifecycle function called when removing OpenCode profile + * @param {string} targetDir - Target project directory + */ +function onRemoveRulesProfile(targetDir) { + const openCodeConfigPath = path.join(targetDir, 'opencode.json'); + + if (!fs.existsSync(openCodeConfigPath)) { + log('debug', '[OpenCode] No opencode.json found to clean up'); + return; + } + + try { + // Read the current config + const configContent = fs.readFileSync(openCodeConfigPath, 'utf8'); + const config = JSON.parse(configContent); + + // Check if it has the mcp section and taskmaster-ai server + if (config.mcp && config.mcp['taskmaster-ai']) { + // Remove taskmaster-ai server + delete config.mcp['taskmaster-ai']; + + // Check if there are other MCP servers + const remainingServers = Object.keys(config.mcp); + + if (remainingServers.length === 0) { + // No other servers, remove entire mcp section + delete config.mcp; + } + + // Check if config is now empty (only has $schema) + const remainingKeys = Object.keys(config).filter( + (key) => key !== '$schema' + ); + + if (remainingKeys.length === 0) { + // Config only has schema left, remove entire file + fs.rmSync(openCodeConfigPath, { force: true }); + log('info', '[OpenCode] Removed empty opencode.json file'); + } else { + // Write back the modified config + fs.writeFileSync( + openCodeConfigPath, + JSON.stringify(config, null, 2) + '\n' + ); + log( + 'info', + '[OpenCode] Removed TaskMaster from opencode.json, preserved other configurations' + ); + } + } else { + log('debug', '[OpenCode] TaskMaster not found in opencode.json'); + } + } catch (error) { + log( + 'error', + `[OpenCode] Failed to clean up opencode.json: ${error.message}` + ); + } +} + +// Create and export opencode profile using the base factory +export const opencodeProfile = createProfile({ + name: 'opencode', + displayName: 'OpenCode', + url: 'opencode.ai', + docsUrl: 'opencode.ai/docs/', + profileDir: '.', // Root directory + rulesDir: '.', // Root directory for AGENTS.md + mcpConfigName: 'opencode.json', // Override default 'mcp.json' + includeDefaultRules: false, + fileMap: { + 'AGENTS.md': 'AGENTS.md' + }, + onPostConvert: onPostConvertRulesProfile, + onRemove: onRemoveRulesProfile +}); + +// Export lifecycle functions separately to avoid naming conflicts +export { onPostConvertRulesProfile, onRemoveRulesProfile }; diff --git a/src/utils/profiles.js b/src/utils/profiles.js index cdf9cbd0..567ee9ec 100644 --- a/src/utils/profiles.js +++ b/src/utils/profiles.js @@ -113,14 +113,12 @@ export async function runInteractiveProfilesSetup() { const hasMcpConfig = profile.mcpConfig === true; if (!profile.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini, zed, amp) - don't include standard coding rules + // Integration guide profiles (claude, codex, gemini, opencode, zed, amp) - don't include standard coding rules if (profileName === 'claude') { description = 'Integration guide with Task Master slash commands'; } else if (profileName === 'codex') { description = 'Comprehensive Task Master integration guide'; - } else if (profileName === 'gemini' || profileName === 'zed') { - description = 'Integration guide and MCP config'; - } else if (profileName === 'amp') { + } else if (hasMcpConfig) { description = 'Integration guide and MCP config'; } else { description = 'Integration guide'; diff --git a/tests/integration/profiles/opencode-init-functionality.test.js b/tests/integration/profiles/opencode-init-functionality.test.js new file mode 100644 index 00000000..5b3c02cc --- /dev/null +++ b/tests/integration/profiles/opencode-init-functionality.test.js @@ -0,0 +1,85 @@ +import fs from 'fs'; +import path from 'path'; +import { opencodeProfile } from '../../../src/profiles/opencode.js'; + +describe('OpenCode Profile Initialization Functionality', () => { + let opencodeProfileContent; + + beforeAll(() => { + const opencodeJsPath = path.join( + process.cwd(), + 'src', + 'profiles', + 'opencode.js' + ); + opencodeProfileContent = fs.readFileSync(opencodeJsPath, 'utf8'); + }); + + test('opencode.js has correct asset-only profile configuration', () => { + // Check for explicit, non-default values in the source file + expect(opencodeProfileContent).toContain("name: 'opencode'"); + expect(opencodeProfileContent).toContain("displayName: 'OpenCode'"); + expect(opencodeProfileContent).toContain("url: 'opencode.ai'"); + expect(opencodeProfileContent).toContain("docsUrl: 'opencode.ai/docs/'"); + expect(opencodeProfileContent).toContain("profileDir: '.'"); // non-default + expect(opencodeProfileContent).toContain("rulesDir: '.'"); // non-default + expect(opencodeProfileContent).toContain("mcpConfigName: 'opencode.json'"); // non-default + expect(opencodeProfileContent).toContain('includeDefaultRules: false'); // non-default + expect(opencodeProfileContent).toContain("'AGENTS.md': 'AGENTS.md'"); + + // Check the final computed properties on the profile object + expect(opencodeProfile.profileName).toBe('opencode'); + expect(opencodeProfile.displayName).toBe('OpenCode'); + expect(opencodeProfile.profileDir).toBe('.'); + expect(opencodeProfile.rulesDir).toBe('.'); + expect(opencodeProfile.mcpConfig).toBe(true); // computed from mcpConfigName + expect(opencodeProfile.mcpConfigName).toBe('opencode.json'); + expect(opencodeProfile.mcpConfigPath).toBe('opencode.json'); // computed + expect(opencodeProfile.includeDefaultRules).toBe(false); + expect(opencodeProfile.fileMap['AGENTS.md']).toBe('AGENTS.md'); + }); + + test('opencode.js has lifecycle functions for MCP config transformation', () => { + expect(opencodeProfileContent).toContain( + 'function onPostConvertRulesProfile' + ); + expect(opencodeProfileContent).toContain('function onRemoveRulesProfile'); + expect(opencodeProfileContent).toContain('transformToOpenCodeFormat'); + }); + + test('opencode.js handles opencode.json transformation in lifecycle functions', () => { + expect(opencodeProfileContent).toContain('opencode.json'); + expect(opencodeProfileContent).toContain('transformToOpenCodeFormat'); + expect(opencodeProfileContent).toContain('$schema'); + expect(opencodeProfileContent).toContain('mcpServers'); + expect(opencodeProfileContent).toContain('mcp'); + }); + + test('opencode.js has proper error handling in lifecycle functions', () => { + expect(opencodeProfileContent).toContain('try {'); + expect(opencodeProfileContent).toContain('} catch (error) {'); + expect(opencodeProfileContent).toContain('log('); + }); + + test('opencode.js uses custom MCP config name', () => { + // OpenCode uses opencode.json instead of mcp.json + expect(opencodeProfileContent).toContain("mcpConfigName: 'opencode.json'"); + // Should not contain mcp.json as a config value (comments are OK) + expect(opencodeProfileContent).not.toMatch( + /mcpConfigName:\s*['"]mcp\.json['"]/ + ); + }); + + test('opencode.js has transformation logic for OpenCode format', () => { + // Check for transformation function + expect(opencodeProfileContent).toContain('transformToOpenCodeFormat'); + + // Check for specific transformation logic + expect(opencodeProfileContent).toContain('mcpServers'); + expect(opencodeProfileContent).toContain('command'); + expect(opencodeProfileContent).toContain('args'); + expect(opencodeProfileContent).toContain('environment'); + expect(opencodeProfileContent).toContain('enabled'); + expect(opencodeProfileContent).toContain('type'); + }); +}); diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index d9cc2554..6e3aff24 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -5,12 +5,30 @@ import path from 'path'; describe('MCP Configuration Validation', () => { describe('Profile MCP Configuration Properties', () => { const expectedMcpConfigurations = { + amp: { + shouldHaveMcp: true, + expectedDir: '.vscode', + expectedConfigName: 'settings.json', + expectedPath: '.vscode/settings.json' + }, + claude: { + shouldHaveMcp: true, + expectedDir: '.', + expectedConfigName: '.mcp.json', + expectedPath: '.mcp.json' + }, cline: { shouldHaveMcp: false, expectedDir: '.clinerules', expectedConfigName: null, expectedPath: null }, + codex: { + shouldHaveMcp: false, + expectedDir: '.', + expectedConfigName: null, + expectedPath: null + }, cursor: { shouldHaveMcp: true, expectedDir: '.cursor', @@ -23,6 +41,12 @@ describe('MCP Configuration Validation', () => { expectedConfigName: 'settings.json', expectedPath: '.gemini/settings.json' }, + opencode: { + shouldHaveMcp: true, + expectedDir: '.', + expectedConfigName: 'opencode.json', + expectedPath: 'opencode.json' + }, roo: { shouldHaveMcp: true, expectedDir: '.roo', @@ -74,10 +98,18 @@ describe('MCP Configuration Validation', () => { RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - const expectedPath = path.join( - profile.profileDir, - profile.mcpConfigName - ); + // For root directory profiles, path.join('.', filename) normalizes to just 'filename' + // except for Claude which uses '.mcp.json' explicitly + let expectedPath; + if (profile.profileDir === '.') { + if (profileName === 'claude') { + expectedPath = '.mcp.json'; // Claude explicitly uses '.mcp.json' + } else { + expectedPath = profile.mcpConfigName; // Other root profiles normalize to just the filename + } + } else { + expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; + } expect(profile.mcpConfigPath).toBe(expectedPath); } }); @@ -95,13 +127,21 @@ describe('MCP Configuration Validation', () => { }); test('should ensure all MCP-enabled profiles use proper directory structure', () => { + const rootProfiles = ['opencode', 'claude', 'codex']; // Profiles that use root directory for config + RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - // Claude profile uses root directory (.), so its path is just '.mcp.json' - if (profileName === 'claude') { - expect(profile.mcpConfigPath).toBe('.mcp.json'); + if (rootProfiles.includes(profileName)) { + // Root profiles have different patterns + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + // Other root profiles normalize to just the filename (no ./ prefix) + expect(profile.mcpConfigPath).toMatch(/^[\w_.]+$/); + } } else { + // Other profiles should have config files in their specific directories expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/); } } @@ -148,7 +188,7 @@ describe('MCP Configuration Validation', () => { test('should ensure each profile has a unique directory', () => { const profileDirs = new Set(); // Profiles that use root directory (can share the same directory) - const rootProfiles = ['claude', 'codex', 'gemini']; + const rootProfiles = ['claude', 'codex', 'gemini', 'opencode']; // Profiles that intentionally share the same directory const sharedDirectoryProfiles = ['amp', 'vscode']; // Both use .vscode @@ -178,7 +218,7 @@ describe('MCP Configuration Validation', () => { test('should ensure profile directories follow expected naming convention', () => { // Profiles that use root directory for rules - const rootRulesProfiles = ['claude', 'codex', 'gemini']; + const rootRulesProfiles = ['claude', 'codex', 'gemini', 'opencode']; RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); @@ -209,12 +249,15 @@ describe('MCP Configuration Validation', () => { }); // Verify expected MCP-enabled profiles + expect(mcpEnabledProfiles).toContain('amp'); expect(mcpEnabledProfiles).toContain('claude'); expect(mcpEnabledProfiles).toContain('cursor'); expect(mcpEnabledProfiles).toContain('gemini'); + expect(mcpEnabledProfiles).toContain('opencode'); expect(mcpEnabledProfiles).toContain('roo'); expect(mcpEnabledProfiles).toContain('vscode'); expect(mcpEnabledProfiles).toContain('windsurf'); + expect(mcpEnabledProfiles).toContain('zed'); expect(mcpEnabledProfiles).not.toContain('cline'); expect(mcpEnabledProfiles).not.toContain('codex'); expect(mcpEnabledProfiles).not.toContain('trae'); @@ -240,19 +283,31 @@ describe('MCP Configuration Validation', () => { // Verify the path is properly formatted for path.join usage expect(profile.mcpConfigPath.startsWith('/')).toBe(false); - // Claude profile uses root directory (.), so its path is just '.mcp.json' - if (profileName === 'claude') { - expect(profile.mcpConfigPath).toBe('.mcp.json'); + // Root directory profiles have different patterns + if (profile.profileDir === '.') { + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + // Other root profiles (opencode) normalize to just the filename + expect(profile.mcpConfigPath).toBe(profile.mcpConfigName); + } } else { + // Non-root profiles should contain a directory separator expect(profile.mcpConfigPath).toContain('/'); } - // Verify it matches the expected pattern: profileDir/configName - const expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; - // For Claude, path.join('.', '.mcp.json') returns '.mcp.json' - const normalizedExpected = - profileName === 'claude' ? '.mcp.json' : expectedPath; - expect(profile.mcpConfigPath).toBe(normalizedExpected); + // Verify it matches the expected pattern based on how path.join works + let expectedPath; + if (profile.profileDir === '.') { + if (profileName === 'claude') { + expectedPath = '.mcp.json'; // Claude explicitly uses '.mcp.json' + } else { + expectedPath = profile.mcpConfigName; // path.join('.', 'filename') normalizes to 'filename' + } + } else { + expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; + } + expect(profile.mcpConfigPath).toBe(expectedPath); } }); }); @@ -266,8 +321,12 @@ describe('MCP Configuration Validation', () => { const fullPath = path.join(testProjectRoot, profile.mcpConfigPath); // Should result in a proper absolute path - expect(fullPath).toBe(`${testProjectRoot}/${profile.mcpConfigPath}`); - expect(fullPath).toContain(profile.profileDir); + // Note: path.join normalizes paths, so './opencode.json' becomes 'opencode.json' + const normalizedExpectedPath = path.join( + testProjectRoot, + profile.mcpConfigPath + ); + expect(fullPath).toBe(normalizedExpectedPath); expect(fullPath).toContain(profile.mcpConfigName); } }); @@ -280,10 +339,16 @@ describe('MCP Configuration Validation', () => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { // Verify the path structure is correct for the new function signature - if (profileName === 'claude') { - // Claude profile uses root directory, so path is just '.mcp.json' - expect(profile.mcpConfigPath).toBe('.mcp.json'); + if (profile.profileDir === '.') { + // Root directory profiles have special handling + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + // Other root profiles normalize to just the filename + expect(profile.mcpConfigPath).toBe(profile.mcpConfigName); + } } else { + // Non-root profiles should have profileDir/configName structure const parts = profile.mcpConfigPath.split('/'); expect(parts).toHaveLength(2); // Should be profileDir/configName expect(parts[0]).toBe(profile.profileDir); @@ -295,7 +360,17 @@ describe('MCP Configuration Validation', () => { }); describe('MCP configuration validation', () => { - const mcpProfiles = ['cursor', 'gemini', 'roo', 'windsurf', 'vscode']; + const mcpProfiles = [ + 'amp', + 'claude', + 'cursor', + 'gemini', + 'opencode', + 'roo', + 'windsurf', + 'vscode', + 'zed' + ]; const nonMcpProfiles = ['codex', 'cline', 'trae']; const profilesWithLifecycle = ['claude']; const profilesWithoutLifecycle = ['codex']; @@ -322,20 +397,25 @@ describe('MCP Configuration Validation', () => { }); describe('Profile structure validation', () => { - const mcpProfiles = [ + const allProfiles = [ 'amp', + 'claude', + 'cline', + 'codex', 'cursor', 'gemini', + 'opencode', 'roo', - 'windsurf', - 'cline', 'trae', - 'vscode' + 'vscode', + 'windsurf', + 'zed' ]; const profilesWithLifecycle = ['amp', 'claude']; + const profilesWithPostConvertLifecycle = ['opencode']; const profilesWithoutLifecycle = ['codex']; - test.each(mcpProfiles)( + test.each(allProfiles)( 'should have file mappings for %s profile', (profileName) => { const profile = getRulesProfile(profileName); @@ -361,6 +441,21 @@ describe('MCP Configuration Validation', () => { } ); + test.each(profilesWithPostConvertLifecycle)( + 'should have file mappings and post-convert lifecycle functions for %s profile', + (profileName) => { + const profile = getRulesProfile(profileName); + expect(profile).toBeDefined(); + // OpenCode profile has fileMap and post-convert lifecycle functions + expect(profile.fileMap).toBeDefined(); + expect(typeof profile.fileMap).toBe('object'); + expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0); + expect(profile.onAddRulesProfile).toBeUndefined(); // OpenCode doesn't have onAdd + expect(typeof profile.onRemoveRulesProfile).toBe('function'); + expect(typeof profile.onPostConvertRulesProfile).toBe('function'); + } + ); + test.each(profilesWithoutLifecycle)( 'should have file mappings without lifecycle functions for %s profile', (profileName) => { diff --git a/tests/unit/profiles/opencode-integration.test.js b/tests/unit/profiles/opencode-integration.test.js new file mode 100644 index 00000000..a3daf21c --- /dev/null +++ b/tests/unit/profiles/opencode-integration.test.js @@ -0,0 +1,123 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +describe('OpenCode Profile Integration', () => { + let tempDir; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); + + // Spy on fs methods + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath.toString().includes('AGENTS.md')) { + return 'Sample AGENTS.md content for OpenCode integration'; + } + if (filePath.toString().includes('opencode.json')) { + return JSON.stringify({ mcpServers: {} }, null, 2); + } + return '{}'; + }); + jest.spyOn(fs, 'existsSync').mockImplementation(() => false); + jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); + }); + + afterEach(() => { + // Clean up the temporary directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + console.error(`Error cleaning up: ${err.message}`); + } + }); + + // Test function that simulates the OpenCode profile file copying behavior + function mockCreateOpenCodeStructure() { + // OpenCode profile copies AGENTS.md to AGENTS.md in project root (same name) + const sourceContent = 'Sample AGENTS.md content for OpenCode integration'; + fs.writeFileSync(path.join(tempDir, 'AGENTS.md'), sourceContent); + + // OpenCode profile creates opencode.json config file + const configContent = JSON.stringify({ mcpServers: {} }, null, 2); + fs.writeFileSync(path.join(tempDir, 'opencode.json'), configContent); + } + + test('creates AGENTS.md file in project root', () => { + // Act + mockCreateOpenCodeStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, 'AGENTS.md'), + 'Sample AGENTS.md content for OpenCode integration' + ); + }); + + test('creates opencode.json config file in project root', () => { + // Act + mockCreateOpenCodeStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, 'opencode.json'), + JSON.stringify({ mcpServers: {} }, null, 2) + ); + }); + + test('does not create any profile directories', () => { + // Act + mockCreateOpenCodeStructure(); + + // Assert - OpenCode profile should not create any directories + // Only the temp directory creation calls should exist + const mkdirCalls = fs.mkdirSync.mock.calls.filter( + (call) => !call[0].includes('task-master-test-') + ); + expect(mkdirCalls).toHaveLength(0); + }); + + test('handles transformation of MCP config format', () => { + // This test simulates the transformation behavior that would happen in onPostConvert + const standardMcpConfig = { + mcpServers: { + 'taskmaster-ai': { + command: 'node', + args: ['path/to/server.js'], + env: { + API_KEY: 'test-key' + } + } + } + }; + + const expectedOpenCodeConfig = { + $schema: 'https://opencode.ai/config.json', + mcp: { + 'taskmaster-ai': { + type: 'local', + command: ['node', 'path/to/server.js'], + enabled: true, + environment: { + API_KEY: 'test-key' + } + } + } + }; + + // Mock the transformation behavior + fs.writeFileSync( + path.join(tempDir, 'opencode.json'), + JSON.stringify(expectedOpenCodeConfig, null, 2) + ); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, 'opencode.json'), + JSON.stringify(expectedOpenCodeConfig, null, 2) + ); + }); +}); diff --git a/tests/unit/profiles/rule-transformer-opencode.test.js b/tests/unit/profiles/rule-transformer-opencode.test.js new file mode 100644 index 00000000..74b8dd42 --- /dev/null +++ b/tests/unit/profiles/rule-transformer-opencode.test.js @@ -0,0 +1,59 @@ +import { jest } from '@jest/globals'; +import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; +import { opencodeProfile } from '../../../src/profiles/opencode.js'; + +describe('Rule Transformer - OpenCode Profile', () => { + test('should have correct profile configuration', () => { + const opencodeProfile = getRulesProfile('opencode'); + + expect(opencodeProfile).toBeDefined(); + expect(opencodeProfile.profileName).toBe('opencode'); + expect(opencodeProfile.displayName).toBe('OpenCode'); + expect(opencodeProfile.profileDir).toBe('.'); + expect(opencodeProfile.rulesDir).toBe('.'); + expect(opencodeProfile.mcpConfig).toBe(true); + expect(opencodeProfile.mcpConfigName).toBe('opencode.json'); + expect(opencodeProfile.mcpConfigPath).toBe('opencode.json'); + expect(opencodeProfile.includeDefaultRules).toBe(false); + expect(opencodeProfile.fileMap).toEqual({ + 'AGENTS.md': 'AGENTS.md' + }); + }); + + test('should have lifecycle functions for MCP config transformation', () => { + // Verify that opencode.js has lifecycle functions + expect(opencodeProfile.onPostConvertRulesProfile).toBeDefined(); + expect(typeof opencodeProfile.onPostConvertRulesProfile).toBe('function'); + expect(opencodeProfile.onRemoveRulesProfile).toBeDefined(); + expect(typeof opencodeProfile.onRemoveRulesProfile).toBe('function'); + }); + + test('should use opencode.json instead of mcp.json', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.mcpConfigName).toBe('opencode.json'); + expect(opencodeProfile.mcpConfigPath).toBe('opencode.json'); + }); + + test('should not include default rules', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.includeDefaultRules).toBe(false); + }); + + test('should have correct file mapping', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.fileMap).toEqual({ + 'AGENTS.md': 'AGENTS.md' + }); + }); + + test('should use root directory for both profile and rules', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.profileDir).toBe('.'); + expect(opencodeProfile.rulesDir).toBe('.'); + }); + + test('should have MCP configuration enabled', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.mcpConfig).toBe(true); + }); +}); diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index 33b417c2..c93f957c 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -19,6 +19,7 @@ describe('Rule Transformer - General', () => { 'codex', 'cursor', 'gemini', + 'opencode', 'roo', 'trae', 'vscode', @@ -211,6 +212,11 @@ describe('Rule Transformer - General', () => { mcpConfigName: 'settings.json', expectedPath: '.gemini/settings.json' }, + opencode: { + mcpConfig: true, + mcpConfigName: 'opencode.json', + expectedPath: 'opencode.json' + }, roo: { mcpConfig: true, mcpConfigName: 'mcp.json', @@ -253,11 +259,19 @@ describe('Rule Transformer - General', () => { const profileConfig = getRulesProfile(profile); if (profileConfig.mcpConfig !== false) { // Profiles with MCP configuration should have valid paths - // The mcpConfigPath should start with the profileDir - if (profile === 'claude') { - // Claude uses root directory (.), so path.join('.', '.mcp.json') = '.mcp.json' - expect(profileConfig.mcpConfigPath).toBe('.mcp.json'); + // Handle root directory profiles differently + if (profileConfig.profileDir === '.') { + if (profile === 'claude') { + // Claude explicitly uses '.mcp.json' + expect(profileConfig.mcpConfigPath).toBe('.mcp.json'); + } else { + // Other root profiles normalize to just the filename + expect(profileConfig.mcpConfigPath).toBe( + profileConfig.mcpConfigName + ); + } } else { + // Non-root profiles should have profileDir/configName pattern expect(profileConfig.mcpConfigPath).toMatch( new RegExp( `^${profileConfig.profileDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/` From 806ec999393b438c5b4ce7575c7aa923330d1aac Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Wed, 16 Jul 2025 20:01:19 +0300 Subject: [PATCH 12/23] chore: add coderabbit configuration (#992) * chore: add coderabbit configuration * chore: fix coderabbit config * chore: improve coderabbit config * chore: more coderabbit reviews * chore: remove all defaults --- .coderabbit.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..0f96eb68 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,10 @@ +reviews: + profile: assertive + poem: false + auto_review: + base_branches: + - rc + - beta + - alpha + - production + - next \ No newline at end of file From 0886c83d0c678417c0313256a6dd96f7ee2c9ac6 Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Thu, 17 Jul 2025 08:38:37 -0400 Subject: [PATCH 13/23] docs: Update MCP server name for consistency and use 'Add to Cursor' button (#995) * update MCP server name to task-master-ai for consistency * add changeset * update cursor link & switch to https * switch back to Add to Cursor button (https link) * update changeset * update changeset * update changeset * update changeset * use GitHub markdown format --- .changeset/update-mcp-readme.md | 5 +++++ README.md | 10 +++------- 2 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 .changeset/update-mcp-readme.md diff --git a/.changeset/update-mcp-readme.md b/.changeset/update-mcp-readme.md new file mode 100644 index 00000000..22a7faf1 --- /dev/null +++ b/.changeset/update-mcp-readme.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Correct MCP server name and use 'Add to Cursor' button with updated placeholder keys. diff --git a/README.md b/README.md index 075922d7..180d2a56 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,7 @@ For more detailed information, check out the documentation in the `docs` directo #### Quick Install for Cursor 1.0+ (One-Click) -📋 Click the copy button (top-right of code block) then paste into your browser: - -```text -cursor://anysphere.cursor-deeplink/mcp/install?name=taskmaster-ai&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIi0tcGFja2FnZT10YXNrLW1hc3Rlci1haSIsInRhc2stbWFzdGVyLWFpIl0sImVudiI6eyJBTlRIUk9QSUNfQVBJX0tFWSI6IllPVVJfQU5USFJPUElDX0FQSV9LRVlfSEVSRSIsIlBFUlBMRVhJVFlfQVBJX0tFWSI6IllPVVJfUEVSUExFWElUWV9BUElfS0VZX0hFUkUiLCJPUEVOQUlfQVBJX0tFWSI6IllPVVJfT1BFTkFJX0tFWV9IRVJFIiwiR09PR0xFX0FQSV9LRVkiOiJZT1VSX0dPT0dMRV9LRVlfSEVSRSIsIk1JU1RSQUxfQVBJX0tFWSI6IllPVVJfTUlTVFJBTF9LRVlfSEVSRSIsIk9QRU5ST1VURVJfQVBJX0tFWSI6IllPVVJfT1BFTlJPVVRFUl9LRVlfSEVSRSIsIlhBSV9BUElfS0VZIjoiWU9VUl9YQUlfS0VZX0hFUkUiLCJBWlVSRV9PUEVOQUlfQVBJX0tFWSI6IllPVVJfQVpVUkVfS0VZX0hFUkUiLCJPTExBTUFfQVBJX0tFWSI6IllPVVJfT0xMQU1BX0FQSV9LRVlfSEVSRSJ9fQo= -``` +[![Add task-master-ai MCP server to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=task-master-ai&config=eyJjb21tYW5kIjoibnB4IC15IC0tcGFja2FnZT10YXNrLW1hc3Rlci1haSB0YXNrLW1hc3Rlci1haSIsImVudiI6eyJBTlRIUk9QSUNfQVBJX0tFWSI6IllPVVJfQU5USFJPUElDX0FQSV9LRVlfSEVSRSIsIlBFUlBMRVhJVFlfQVBJX0tFWSI6IllPVVJfUEVSUExFWElUWV9BUElfS0VZX0hFUkUiLCJPUEVOQUlfQVBJX0tFWSI6IllPVVJfT1BFTkFJX0tFWV9IRVJFIiwiR09PR0xFX0FQSV9LRVkiOiJZT1VSX0dPT0dMRV9LRVlfSEVSRSIsIk1JU1RSQUxfQVBJX0tFWSI6IllPVVJfTUlTVFJBTF9LRVlfSEVSRSIsIkdST1FfQVBJX0tFWSI6IllPVVJfR1JPUV9LRVlfSEVSRSIsIk9QRU5ST1VURVJfQVBJX0tFWSI6IllPVVJfT1BFTlJPVVRFUl9LRVlfSEVSRSIsIlhBSV9BUElfS0VZIjoiWU9VUl9YQUlfS0VZX0hFUkUiLCJBWlVSRV9PUEVOQUlfQVBJX0tFWSI6IllPVVJfQVpVUkVfS0VZX0hFUkUiLCJPTExBTUFfQVBJX0tFWSI6IllPVVJfT0xMQU1BX0FQSV9LRVlfSEVSRSJ9fQ%3D%3D) > **Note:** After clicking the link, you'll still need to add your API keys to the configuration. The link installs the MCP server with placeholder keys that you'll need to replace with your actual API keys. @@ -73,7 +69,7 @@ MCP (Model Control Protocol) lets you run Task Master directly from your editor. ```json { "mcpServers": { - "taskmaster-ai": { + "task-master-ai": { "command": "npx", "args": ["-y", "--package=task-master-ai", "task-master-ai"], "env": { @@ -102,7 +98,7 @@ MCP (Model Control Protocol) lets you run Task Master directly from your editor. ```json { "servers": { - "taskmaster-ai": { + "task-master-ai": { "command": "npx", "args": ["-y", "--package=task-master-ai", "task-master-ai"], "env": { From f772a96d00355503f97583d756da8facb5e5053b Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Thu, 17 Jul 2025 22:34:23 +0300 Subject: [PATCH 14/23] fix(ai-validation): comprehensive fixes for AI response validation issues (#1000) * fix(ai-validation): comprehensive fixes for AI response validation issues - Fix update command validation when AI omits subtasks/status/dependencies - Fix add-task command when AI returns non-string details field - Fix update-task command when AI subtasks miss required fields - Add preprocessing to ensure proper field types before validation - Prevent split() errors on non-string fields - Set proper defaults for missing required fields * chore: run format * chore: implement coderabbit suggestions --- .../modules/task-manager/update-task-by-id.js | 39 ++++++++++++++++++- scripts/modules/task-manager/update-tasks.js | 25 +++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/scripts/modules/task-manager/update-task-by-id.js b/scripts/modules/task-manager/update-task-by-id.js index b77044fc..19603897 100644 --- a/scripts/modules/task-manager/update-task-by-id.js +++ b/scripts/modules/task-manager/update-task-by-id.js @@ -190,8 +190,45 @@ function parseUpdatedTaskFromText(text, expectedTaskId, logFn, isMCP) { throw new Error('Parsed AI response is not a valid JSON object.'); } + // Preprocess the task to ensure subtasks have proper structure + const preprocessedTask = { + ...parsedTask, + status: parsedTask.status || 'pending', + dependencies: Array.isArray(parsedTask.dependencies) + ? parsedTask.dependencies + : [], + details: + typeof parsedTask.details === 'string' + ? parsedTask.details + : String(parsedTask.details || ''), + testStrategy: + typeof parsedTask.testStrategy === 'string' + ? parsedTask.testStrategy + : String(parsedTask.testStrategy || ''), + // Ensure subtasks is an array and each subtask has required fields + subtasks: Array.isArray(parsedTask.subtasks) + ? parsedTask.subtasks.map((subtask) => ({ + ...subtask, + title: subtask.title || '', + description: subtask.description || '', + status: subtask.status || 'pending', + dependencies: Array.isArray(subtask.dependencies) + ? subtask.dependencies + : [], + details: + typeof subtask.details === 'string' + ? subtask.details + : String(subtask.details || ''), + testStrategy: + typeof subtask.testStrategy === 'string' + ? subtask.testStrategy + : String(subtask.testStrategy || '') + })) + : [] + }; + // Validate the parsed task object using Zod - const validationResult = updatedTaskSchema.safeParse(parsedTask); + const validationResult = updatedTaskSchema.safeParse(preprocessedTask); if (!validationResult.success) { report('error', 'Parsed task object failed Zod validation.'); validationResult.error.errors.forEach((err) => { diff --git a/scripts/modules/task-manager/update-tasks.js b/scripts/modules/task-manager/update-tasks.js index fa048037..43b854b2 100644 --- a/scripts/modules/task-manager/update-tasks.js +++ b/scripts/modules/task-manager/update-tasks.js @@ -196,7 +196,18 @@ function parseUpdatedTasksFromText(text, expectedCount, logFn, isMCP) { ); } - const validationResult = updatedTaskArraySchema.safeParse(parsedTasks); + // Preprocess tasks to ensure required fields have proper defaults + const preprocessedTasks = parsedTasks.map((task) => ({ + ...task, + // Ensure subtasks is always an array (not null or undefined) + subtasks: Array.isArray(task.subtasks) ? task.subtasks : [], + // Ensure status has a default value if missing + status: task.status || 'pending', + // Ensure dependencies is always an array + dependencies: Array.isArray(task.dependencies) ? task.dependencies : [] + })); + + const validationResult = updatedTaskArraySchema.safeParse(preprocessedTasks); if (!validationResult.success) { report('error', 'Parsed task array failed Zod validation.'); validationResult.error.errors.forEach((err) => { @@ -442,7 +453,17 @@ async function updateTasks( data.tasks.forEach((task, index) => { if (updatedTasksMap.has(task.id)) { // Only update if the task was part of the set sent to AI - data.tasks[index] = updatedTasksMap.get(task.id); + const updatedTask = updatedTasksMap.get(task.id); + // Merge the updated task with the existing one to preserve fields like subtasks + data.tasks[index] = { + ...task, // Keep all existing fields + ...updatedTask, // Override with updated fields + // Ensure subtasks field is preserved if not provided by AI + subtasks: + updatedTask.subtasks !== undefined + ? updatedTask.subtasks + : task.subtasks + }; actualUpdateCount++; } }); From 9c58a922436c0c5e7ff1b20ed2edbc269990c772 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Fri, 18 Jul 2025 01:02:30 +0300 Subject: [PATCH 15/23] feat: add kiro profile (#1001) * feat: add kiro profile * chore: fix format * chore: implement requested changes * chore: fix CI --- .changeset/add-kiro-profile.md | 9 + src/constants/profiles.js | 4 +- src/profiles/index.js | 1 + src/profiles/kiro.js | 42 ++++ tests/unit/profiles/kiro-integration.test.js | 142 ++++++++++++ .../profiles/mcp-config-validation.test.js | 19 ++ .../profiles/rule-transformer-kiro.test.js | 215 ++++++++++++++++++ tests/unit/profiles/rule-transformer.test.js | 6 + 8 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-kiro-profile.md create mode 100644 src/profiles/kiro.js create mode 100644 tests/unit/profiles/kiro-integration.test.js create mode 100644 tests/unit/profiles/rule-transformer-kiro.test.js diff --git a/.changeset/add-kiro-profile.md b/.changeset/add-kiro-profile.md new file mode 100644 index 00000000..a23ff26c --- /dev/null +++ b/.changeset/add-kiro-profile.md @@ -0,0 +1,9 @@ +--- +"task-master-ai": minor +--- + +Add Kiro editor rule profile support + +- Add support for Kiro IDE with custom rule files and MCP configuration +- Generate rule files in `.kiro/steering/` directory with markdown format +- Include MCP server configuration with enhanced file inclusion patterns \ No newline at end of file diff --git a/src/constants/profiles.js b/src/constants/profiles.js index 8521b4d8..9c24648e 100644 --- a/src/constants/profiles.js +++ b/src/constants/profiles.js @@ -1,5 +1,5 @@ /** - * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'opencode' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile + * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'kiro' | 'opencode' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile */ /** @@ -16,6 +16,7 @@ * - codex: Codex integration * - cursor: Cursor IDE rules * - gemini: Gemini integration + * - kiro: Kiro IDE rules * - opencode: OpenCode integration * - roo: Roo Code IDE rules * - trae: Trae IDE rules @@ -35,6 +36,7 @@ export const RULE_PROFILES = [ 'codex', 'cursor', 'gemini', + 'kiro', 'opencode', 'roo', 'trae', diff --git a/src/profiles/index.js b/src/profiles/index.js index 202f2663..d906e474 100644 --- a/src/profiles/index.js +++ b/src/profiles/index.js @@ -5,6 +5,7 @@ export { clineProfile } from './cline.js'; export { codexProfile } from './codex.js'; export { cursorProfile } from './cursor.js'; export { geminiProfile } from './gemini.js'; +export { kiroProfile } from './kiro.js'; export { opencodeProfile } from './opencode.js'; export { rooProfile } from './roo.js'; export { traeProfile } from './trae.js'; diff --git a/src/profiles/kiro.js b/src/profiles/kiro.js new file mode 100644 index 00000000..5dff0604 --- /dev/null +++ b/src/profiles/kiro.js @@ -0,0 +1,42 @@ +// Kiro profile for rule-transformer +import { createProfile } from './base-profile.js'; + +// Create and export kiro profile using the base factory +export const kiroProfile = createProfile({ + name: 'kiro', + displayName: 'Kiro', + url: 'kiro.dev', + docsUrl: 'kiro.dev/docs', + profileDir: '.kiro', + rulesDir: '.kiro/steering', // Kiro rules location (full path) + mcpConfig: true, + mcpConfigName: 'settings/mcp.json', // Create directly in settings subdirectory + includeDefaultRules: true, // Include default rules to get all the standard files + targetExtension: '.md', + fileMap: { + // Override specific mappings - the base profile will create: + // 'rules/cursor_rules.mdc': 'kiro_rules.md' + // 'rules/dev_workflow.mdc': 'dev_workflow.md' + // 'rules/self_improve.mdc': 'self_improve.md' + // 'rules/taskmaster.mdc': 'taskmaster.md' + // We can add additional custom mappings here if needed + }, + customReplacements: [ + // Core Kiro directory structure changes + { from: /\.cursor\/rules/g, to: '.kiro/steering' }, + { from: /\.cursor\/mcp\.json/g, to: '.kiro/settings/mcp.json' }, + + // Fix any remaining kiro/rules references that might be created during transformation + { from: /\.kiro\/rules/g, to: '.kiro/steering' }, + + // Essential markdown link transformations for Kiro structure + { + from: /\[(.+?)\]\(mdc:\.cursor\/rules\/(.+?)\.mdc\)/g, + to: '[$1](.kiro/steering/$2.md)' + }, + + // Kiro specific terminology + { from: /rules directory/g, to: 'steering directory' }, + { from: /cursor rules/gi, to: 'Kiro steering files' } + ] +}); diff --git a/tests/unit/profiles/kiro-integration.test.js b/tests/unit/profiles/kiro-integration.test.js new file mode 100644 index 00000000..5f1e9e59 --- /dev/null +++ b/tests/unit/profiles/kiro-integration.test.js @@ -0,0 +1,142 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Mock external modules +jest.mock('child_process', () => ({ + execSync: jest.fn() +})); + +// Mock console methods +jest.mock('console', () => ({ + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + clear: jest.fn() +})); + +describe('Kiro Integration', () => { + let tempDir; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); + + // Spy on fs methods + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath.toString().includes('mcp.json')) { + return JSON.stringify({ mcpServers: {} }, null, 2); + } + return '{}'; + }); + jest.spyOn(fs, 'existsSync').mockImplementation(() => false); + jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); + }); + + afterEach(() => { + // Clean up the temporary directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + console.error(`Error cleaning up: ${err.message}`); + } + }); + + // Test function that simulates the createProjectStructure behavior for Kiro files + function mockCreateKiroStructure() { + // This function simulates the actual kiro profile creation logic + // It explicitly calls the mocked fs methods to ensure consistency with the test environment + + // Simulate directory creation calls - these will call the mocked mkdirSync + fs.mkdirSync(path.join(tempDir, '.kiro'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, '.kiro', 'steering'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, '.kiro', 'settings'), { recursive: true }); + + // Create MCP config file at .kiro/settings/mcp.json + // This will call the mocked writeFileSync + fs.writeFileSync( + path.join(tempDir, '.kiro', 'settings', 'mcp.json'), + JSON.stringify({ mcpServers: {} }, null, 2) + ); + + // Create kiro rule files in steering directory + // All these will call the mocked writeFileSync + fs.writeFileSync( + path.join(tempDir, '.kiro', 'steering', 'kiro_rules.md'), + '# Kiro Rules\n\nKiro-specific rules and instructions.' + ); + fs.writeFileSync( + path.join(tempDir, '.kiro', 'steering', 'dev_workflow.md'), + '# Development Workflow\n\nDevelopment workflow instructions.' + ); + fs.writeFileSync( + path.join(tempDir, '.kiro', 'steering', 'self_improve.md'), + '# Self Improvement\n\nSelf improvement guidelines.' + ); + fs.writeFileSync( + path.join(tempDir, '.kiro', 'steering', 'taskmaster.md'), + '# Task Master\n\nTask Master integration instructions.' + ); + } + + test('creates all required .kiro directories', () => { + // Act + mockCreateKiroStructure(); + + // Assert + expect(fs.mkdirSync).toHaveBeenCalledWith(path.join(tempDir, '.kiro'), { + recursive: true + }); + expect(fs.mkdirSync).toHaveBeenCalledWith( + path.join(tempDir, '.kiro', 'steering'), + { + recursive: true + } + ); + expect(fs.mkdirSync).toHaveBeenCalledWith( + path.join(tempDir, '.kiro', 'settings'), + { + recursive: true + } + ); + }); + + test('creates Kiro mcp.json with mcpServers format', () => { + // Act + mockCreateKiroStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, '.kiro', 'settings', 'mcp.json'), + JSON.stringify({ mcpServers: {} }, null, 2) + ); + }); + + test('creates rule files in steering directory', () => { + // Act + mockCreateKiroStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, '.kiro', 'steering', 'kiro_rules.md'), + '# Kiro Rules\n\nKiro-specific rules and instructions.' + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, '.kiro', 'steering', 'dev_workflow.md'), + '# Development Workflow\n\nDevelopment workflow instructions.' + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, '.kiro', 'steering', 'self_improve.md'), + '# Self Improvement\n\nSelf improvement guidelines.' + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, '.kiro', 'steering', 'taskmaster.md'), + '# Task Master\n\nTask Master integration instructions.' + ); + }); +}); diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index 6e3aff24..edf3ac78 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -41,6 +41,12 @@ describe('MCP Configuration Validation', () => { expectedConfigName: 'settings.json', expectedPath: '.gemini/settings.json' }, + kiro: { + shouldHaveMcp: true, + expectedDir: '.kiro', + expectedConfigName: 'settings/mcp.json', + expectedPath: '.kiro/settings/mcp.json' + }, opencode: { shouldHaveMcp: true, expectedDir: '.', @@ -128,6 +134,7 @@ describe('MCP Configuration Validation', () => { test('should ensure all MCP-enabled profiles use proper directory structure', () => { const rootProfiles = ['opencode', 'claude', 'codex']; // Profiles that use root directory for config + const nestedConfigProfiles = ['kiro']; // Profiles that use nested directories for config RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); @@ -140,6 +147,11 @@ describe('MCP Configuration Validation', () => { // Other root profiles normalize to just the filename (no ./ prefix) expect(profile.mcpConfigPath).toMatch(/^[\w_.]+$/); } + } else if (nestedConfigProfiles.includes(profileName)) { + // Profiles with nested config directories + expect(profile.mcpConfigPath).toMatch( + /^\.[\w-]+\/[\w-]+\/[\w_.]+$/ + ); } else { // Other profiles should have config files in their specific directories expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/); @@ -347,6 +359,13 @@ describe('MCP Configuration Validation', () => { // Other root profiles normalize to just the filename expect(profile.mcpConfigPath).toBe(profile.mcpConfigName); } + } else if (profileName === 'kiro') { + // Kiro has a nested config structure + const parts = profile.mcpConfigPath.split('/'); + expect(parts).toHaveLength(3); // Should be profileDir/settings/mcp.json + expect(parts[0]).toBe(profile.profileDir); + expect(parts[1]).toBe('settings'); + expect(parts[2]).toBe('mcp.json'); } else { // Non-root profiles should have profileDir/configName structure const parts = profile.mcpConfigPath.split('/'); diff --git a/tests/unit/profiles/rule-transformer-kiro.test.js b/tests/unit/profiles/rule-transformer-kiro.test.js new file mode 100644 index 00000000..b1a2ce81 --- /dev/null +++ b/tests/unit/profiles/rule-transformer-kiro.test.js @@ -0,0 +1,215 @@ +import { jest } from '@jest/globals'; + +// Mock fs module before importing anything that uses it +jest.mock('fs', () => ({ + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + existsSync: jest.fn(), + mkdirSync: jest.fn() +})); + +// Import modules after mocking +import fs from 'fs'; +import { convertRuleToProfileRule } from '../../../src/utils/rule-transformer.js'; +import { kiroProfile } from '../../../src/profiles/kiro.js'; + +describe('Kiro Rule Transformer', () => { + // Set up spies on the mocked modules + const mockReadFileSync = jest.spyOn(fs, 'readFileSync'); + const mockWriteFileSync = jest.spyOn(fs, 'writeFileSync'); + const mockExistsSync = jest.spyOn(fs, 'existsSync'); + const mockMkdirSync = jest.spyOn(fs, 'mkdirSync'); + const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + beforeEach(() => { + jest.clearAllMocks(); + // Setup default mocks + mockReadFileSync.mockReturnValue(''); + mockWriteFileSync.mockImplementation(() => {}); + mockExistsSync.mockReturnValue(true); + mockMkdirSync.mockImplementation(() => {}); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should correctly convert basic terms', () => { + const testContent = `--- +description: Test Cursor rule for basic terms +globs: **/* +alwaysApply: true +--- + +This is a Cursor rule that references cursor.so and uses the word Cursor multiple times. +Also has references to .mdc files.`; + + // Mock file read to return our test content + mockReadFileSync.mockReturnValue(testContent); + + // Mock file system operations + mockExistsSync.mockReturnValue(true); + + // Call the function + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + kiroProfile + ); + + // Verify the result + expect(result).toBe(true); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + + // Get the transformed content + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify Cursor -> Kiro transformations + expect(transformedContent).toContain('kiro.dev'); + expect(transformedContent).toContain('Kiro'); + expect(transformedContent).not.toContain('cursor.so'); + expect(transformedContent).not.toContain('Cursor'); + expect(transformedContent).toContain('.md'); + expect(transformedContent).not.toContain('.mdc'); + }); + + it('should handle URL transformations', () => { + const testContent = `Visit https://cursor.so/docs for more information. +Also check out cursor.so and www.cursor.so for updates.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + kiroProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify URL transformations + expect(transformedContent).toContain('https://kiro.dev'); + expect(transformedContent).toContain('kiro.dev'); + expect(transformedContent).not.toContain('cursor.so'); + }); + + it('should handle file extension transformations', () => { + const testContent = `This rule references file.mdc and another.mdc file. +Use the .mdc extension for all rule files.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + kiroProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify file extension transformations + expect(transformedContent).toContain('file.md'); + expect(transformedContent).toContain('another.md'); + expect(transformedContent).toContain('.md extension'); + expect(transformedContent).not.toContain('.mdc'); + }); + + it('should handle case variations', () => { + const testContent = `CURSOR, Cursor, cursor should all be transformed.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + kiroProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify case transformations + // Due to regex order, the case-insensitive rule runs first: + // CURSOR -> Kiro (because it starts with 'C'), Cursor -> Kiro, cursor -> kiro + expect(transformedContent).toContain('Kiro'); + expect(transformedContent).toContain('kiro'); + expect(transformedContent).not.toContain('CURSOR'); + expect(transformedContent).not.toContain('Cursor'); + expect(transformedContent).not.toContain('cursor'); + }); + + it('should create target directory if it does not exist', () => { + const testContent = 'Test content'; + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(false); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'nested/path/test-target.md', + kiroProfile + ); + + expect(result).toBe(true); + expect(mockMkdirSync).toHaveBeenCalledWith('nested/path', { + recursive: true + }); + }); + + it('should handle file system errors gracefully', () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + kiroProfile + ); + + expect(result).toBe(false); + expect(mockConsoleError).toHaveBeenCalledWith( + 'Error converting rule file: File not found' + ); + }); + + it('should handle write errors gracefully', () => { + mockReadFileSync.mockReturnValue('Test content'); + mockWriteFileSync.mockImplementation(() => { + throw new Error('Write permission denied'); + }); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + kiroProfile + ); + + expect(result).toBe(false); + expect(mockConsoleError).toHaveBeenCalledWith( + 'Error converting rule file: Write permission denied' + ); + }); + + it('should verify profile configuration', () => { + expect(kiroProfile.profileName).toBe('kiro'); + expect(kiroProfile.displayName).toBe('Kiro'); + expect(kiroProfile.profileDir).toBe('.kiro'); + expect(kiroProfile.mcpConfig).toBe(true); + expect(kiroProfile.mcpConfigName).toBe('settings/mcp.json'); + expect(kiroProfile.mcpConfigPath).toBe('.kiro/settings/mcp.json'); + expect(kiroProfile.includeDefaultRules).toBe(true); + expect(kiroProfile.fileMap).toEqual({ + 'rules/cursor_rules.mdc': 'kiro_rules.md', + 'rules/dev_workflow.mdc': 'dev_workflow.md', + 'rules/self_improve.mdc': 'self_improve.md', + 'rules/taskmaster.mdc': 'taskmaster.md' + }); + }); +}); diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index c93f957c..4e2fbcee 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -19,6 +19,7 @@ describe('Rule Transformer - General', () => { 'codex', 'cursor', 'gemini', + 'kiro', 'opencode', 'roo', 'trae', @@ -212,6 +213,11 @@ describe('Rule Transformer - General', () => { mcpConfigName: 'settings.json', expectedPath: '.gemini/settings.json' }, + kiro: { + mcpConfig: true, + mcpConfigName: 'settings/mcp.json', + expectedPath: '.kiro/settings/mcp.json' + }, opencode: { mcpConfig: true, mcpConfigName: 'opencode.json', From 0451ebcc32cd7e9d395b015aaa8602c4734157e1 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Fri, 18 Jul 2025 01:03:41 +0300 Subject: [PATCH 16/23] refactor: remove unused resource and resource template initialization (#1002) * refactor: remove unused resource and resource template initialization * chore: implement requested changes --- .changeset/puny-friends-give.md | 5 +++++ mcp-server/src/index.js | 4 ---- 2 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 .changeset/puny-friends-give.md diff --git a/.changeset/puny-friends-give.md b/.changeset/puny-friends-give.md new file mode 100644 index 00000000..5ae15339 --- /dev/null +++ b/.changeset/puny-friends-give.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Fix MCP server error when retrieving tools and resources diff --git a/mcp-server/src/index.js b/mcp-server/src/index.js index 4ebefe7c..81f91ade 100644 --- a/mcp-server/src/index.js +++ b/mcp-server/src/index.js @@ -32,10 +32,6 @@ class TaskMasterMCPServer { this.server = new FastMCP(this.options); this.initialized = false; - this.server.addResource({}); - - this.server.addResourceTemplate({}); - // Bind methods this.init = this.init.bind(this); this.start = this.start.bind(this); From fd005c4c5481ffac58b11f01a448fa5b29056b8d Mon Sep 17 00:00:00 2001 From: Parthy <52548018+mm-parthy@users.noreply.github.com> Date: Fri, 18 Jul 2025 19:05:04 +0200 Subject: [PATCH 17/23] fix(core): Implement Boundary-First Tag Resolution (#943) * refactor(context): Standardize tag and projectRoot handling across all task tools This commit unifies context management by adopting a boundary-first resolution strategy. All task-scoped tools now resolve `tag` and `projectRoot` at their entry point and forward these values to the underlying direct functions. This approach centralizes context logic, ensuring consistent behavior and enhanced flexibility in multi-tag environments. * fix(tag): Clean up tag handling in task functions and sync process This commit refines the handling of the `tag` parameter across multiple functions, ensuring consistent context management. The `tag` is now passed more efficiently in `listTasksDirect`, `setTaskStatusDirect`, and `syncTasksToReadme`, improving clarity and reducing redundancy. Additionally, a TODO comment has been added in `sync-readme.js` to address future tag support enhancements. * feat(tag): Implement Boundary-First Tag Resolution for consistent tag handling This commit introduces Boundary-First Tag Resolution in the task manager, ensuring consistent and deterministic tag handling across CLI and MCP. This change resolves potential race conditions and improves the reliability of tag-specific operations. Additionally, the `expandTask` function has been updated to use the resolved tag when writing JSON, enhancing data integrity during task updates. * chore(biome): formatting * fix(expand-task): Update writeJSON call to use tag instead of resolvedTag * fix(commands): Enhance complexity report path resolution and task initialization `resolveComplexityReportPath` function to streamline output path generation based on tag context and user-defined output. - Improved clarity and maintainability of command handling by centralizing path resolution logic. * Fix: unknown currentTag * fix(task-manager): Update generateTaskFiles calls to include tag and projectRoot parameters This commit modifies the `moveTask` and `updateSubtaskById` functions to pass the `tag` and `projectRoot` parameters to the `generateTaskFiles` function. This ensures that task files are generated with the correct context when requested, enhancing consistency in task management operations. * fix(commands): Refactor tag handling and complexity report path resolution This commit updates the `registerCommands` function to utilize `taskMaster.getCurrentTag()` for consistent tag retrieval across command actions. It also enhances the initialization of `TaskMaster` by passing the tag directly, improving clarity and maintainability. The complexity report path resolution is streamlined to ensure correct file naming based on the current tag context. * fix(task-master): Update complexity report path expectations in tests This commit modifies the `initTaskMaster` test to expect a valid string for the complexity report path, ensuring it matches the expected file naming convention. This change enhances test reliability by verifying the correct output format when the path is generated. * fix(set-task-status): Enhance logging and tag resolution in task status updates This commit improves the logging output in the `registerSetTaskStatusTool` function to include the tag context when setting task statuses. It also updates the tag handling by resolving the tag using the `resolveTag` utility, ensuring that the correct tag is used when updating task statuses. Additionally, the `setTaskStatus` function is modified to remove the tag parameter from the `readJSON` and `writeJSON` calls, streamlining the data handling process. * fix(commands, expand-task, task-manager): Add complexity report option and enhance path handling This commit introduces a new `--complexity-report` option in the `registerCommands` function, allowing users to specify a custom path for the complexity report. The `expandTask` function is updated to accept the `complexityReportPath` from the context, ensuring it is utilized correctly during task expansion. Additionally, the `setTaskStatus` function now includes the `tag` parameter in the `readJSON` and `writeJSON` calls, improving task status updates with proper context. The `initTaskMaster` function is also modified to create parent directories for output paths, enhancing file handling robustness. * fix(expand-task): Add complexityReportPath to context for task expansion tests This commit updates the test for the `expandTask` function by adding the `complexityReportPath` to the context object. This change ensures that the complexity report path is correctly utilized in the test, aligning with recent enhancements to complexity report handling in the task manager. * chore: implement suggested changes * fix(parse-prd): Clarify tag parameter description for task organization Updated the documentation for the `tag` parameter in the `parse-prd.js` file to provide a clearer context on its purpose for organizing tasks into separate task lists. * Fix Inconsistent tag resolution pattern. * fix: Enhance complexity report path handling with tag support This commit updates various functions to incorporate the `tag` parameter when resolving complexity report paths. The `expandTaskDirect`, `resolveComplexityReportPath`, and related tools now utilize the current tag context, improving consistency in task management. Additionally, the complexity report path is now correctly passed through the context in the `expand-task` and `set-task-status` tools, ensuring accurate report retrieval based on the active tag. * Updated the JSDoc for the `tag` parameter in the `show-task.js` file. * Remove redundant comment on tag parameter in readJSON call * Remove unused import for getTagAwareFilePath * Add missed complexityReportPath to args for task expansion * fix(tests): Enhance research tests with tag-aware functionality This commit updates the `research.test.js` file to improve the testing of the `performResearch` function by incorporating tag-aware functionality. Key changes include mocking the `findProjectRoot` to return a valid path, enhancing the `ContextGatherer` and `FuzzyTaskSearch` mocks, and adding comprehensive tests for tag parameter handling in various scenarios. The tests now cover passing different tag values, ensuring correct behavior when tags are provided, undefined, or null, and validating the integration of tags in task discovery and context gathering processes. * Remove unused import for * fix: Refactor complexity report path handling and improve argument destructuring This commit enhances the `expandTaskDirect` function by improving the destructuring of arguments for better readability. It also updates the `analyze.js` and `analyze-task-complexity.js` files to utilize the new `resolveComplexityReportOutputPath` function, ensuring tag-aware resolution of output paths. Additionally, logging has been added to provide clarity on the report path being used. * test: Add complexity report tag isolation tests and improve path handling This commit introduces a new test file for complexity report tag isolation, ensuring that different tags maintain separate complexity reports. It enhances the existing tests in `analyze-task-complexity.test.js` by updating expectations to use `expect.stringContaining` for file paths, improving robustness against path changes. The new tests cover various scenarios, including path resolution and report generation for both master and feature tags, ensuring no cross-tag contamination occurs. * Update scripts/modules/task-manager/list-tasks.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update scripts/modules/task-manager/list-tasks.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * test(complexity-report): Fix tag slugification in filename expectations - Update mocks to use slugifyTagForFilePath for cross-platform compatibility - Replace raw tag values with slugified versions in expected filenames - Fix test expecting 'feature/user-auth-v2' to expect 'feature-user-auth-v2' - Align test with actual filename generation logic that sanitizes special chars --------- Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .changeset/cool-glasses-invite.md | 5 + .../core/direct-functions/add-dependency.js | 9 +- .../src/core/direct-functions/add-task.js | 10 +- .../analyze-task-complexity.js | 11 +- .../core/direct-functions/clear-subtasks.js | 3 +- .../core/direct-functions/expand-all-tasks.js | 8 +- .../src/core/direct-functions/expand-task.js | 14 +- .../core/direct-functions/fix-dependencies.js | 5 +- .../direct-functions/generate-task-files.js | 14 +- .../src/core/direct-functions/list-tasks.js | 12 +- .../src/core/direct-functions/move-task.js | 6 +- .../src/core/direct-functions/next-task.js | 7 +- .../src/core/direct-functions/parse-prd.js | 11 +- .../direct-functions/remove-dependency.js | 9 +- .../core/direct-functions/remove-subtask.js | 10 +- .../src/core/direct-functions/remove-task.js | 5 +- .../src/core/direct-functions/research.js | 11 +- .../core/direct-functions/set-task-status.js | 23 +- .../src/core/direct-functions/show-task.js | 5 +- .../direct-functions/update-subtask-by-id.js | 4 +- .../direct-functions/update-task-by-id.js | 5 +- .../src/core/direct-functions/update-tasks.js | 6 + .../direct-functions/validate-dependencies.js | 7 +- mcp-server/src/core/utils/path-utils.js | 7 +- mcp-server/src/tools/add-dependency.js | 13 +- mcp-server/src/tools/add-subtask.js | 11 +- mcp-server/src/tools/add-task.js | 10 +- mcp-server/src/tools/analyze.js | 23 +- mcp-server/src/tools/clear-subtasks.js | 9 +- mcp-server/src/tools/complexity-report.js | 7 +- mcp-server/src/tools/expand-all.js | 11 +- mcp-server/src/tools/expand-task.js | 19 +- mcp-server/src/tools/fix-dependencies.js | 9 +- mcp-server/src/tools/generate.js | 11 +- mcp-server/src/tools/get-task.js | 14 +- mcp-server/src/tools/get-tasks.js | 17 +- mcp-server/src/tools/move-task.js | 14 +- mcp-server/src/tools/next-task.js | 16 +- mcp-server/src/tools/parse-prd.js | 16 +- mcp-server/src/tools/remove-dependency.js | 12 +- mcp-server/src/tools/remove-subtask.js | 11 +- mcp-server/src/tools/remove-task.js | 8 +- mcp-server/src/tools/research.js | 12 +- mcp-server/src/tools/set-task-status.js | 17 +- mcp-server/src/tools/update-subtask.js | 12 +- mcp-server/src/tools/update-task.js | 11 +- mcp-server/src/tools/update.js | 8 +- mcp-server/src/tools/validate-dependencies.js | 12 +- scripts/modules/commands.js | 305 +++-- scripts/modules/dependency-manager.js | 4 + scripts/modules/sync-readme.js | 6 +- scripts/modules/task-manager/add-subtask.js | 9 +- scripts/modules/task-manager/add-task.js | 18 +- .../task-manager/analyze-task-complexity.js | 24 +- .../modules/task-manager/clear-subtasks.js | 2 + .../modules/task-manager/expand-all-tasks.js | 13 +- scripts/modules/task-manager/expand-task.js | 21 +- .../task-manager/generate-task-files.js | 33 +- scripts/modules/task-manager/list-tasks.js | 9 +- scripts/modules/task-manager/move-task.js | 41 +- scripts/modules/task-manager/parse-prd.js | 2 +- .../modules/task-manager/remove-subtask.js | 7 +- scripts/modules/task-manager/remove-task.js | 27 +- scripts/modules/task-manager/research.js | 15 +- .../modules/task-manager/set-task-status.js | 39 +- .../task-manager/update-subtask-by-id.js | 19 +- .../modules/task-manager/update-task-by-id.js | 15 +- scripts/modules/task-manager/update-tasks.js | 14 +- scripts/modules/ui.js | 13 +- scripts/modules/utils.js | 1 + scripts/modules/utils/contextGatherer.js | 14 +- src/task-master.js | 93 +- src/utils/path-utils.js | 22 +- tests/e2e/run_e2e.sh | 2 +- .../modules/task-manager/add-task.test.js | 30 +- .../analyze-task-complexity.test.js | 6 +- .../task-manager/clear-subtasks.test.js | 40 +- .../complexity-report-tag-isolation.test.js | 1124 +++++++++++++++++ .../task-manager/expand-all-tasks.test.js | 27 +- .../modules/task-manager/expand-task.test.js | 4 +- .../task-manager/generate-task-files.test.js | 6 +- .../modules/task-manager/list-tasks.test.js | 103 +- .../modules/task-manager/move-task.test.js | 94 ++ .../modules/task-manager/parse-prd.test.js | 43 +- .../task-manager/remove-subtask.test.js | 193 +-- .../modules/task-manager/remove-task.test.js | 134 ++ .../modules/task-manager/research.test.js | 663 ++++++++++ .../task-manager/set-task-status.test.js | 27 +- .../task-manager/update-subtask-by-id.test.js | 201 +++ .../task-manager/update-task-by-id.test.js | 121 ++ .../modules/task-manager/update-tasks.test.js | 4 +- .../unit/task-manager/clear-subtasks.test.js | 53 + tests/unit/task-manager/move-task.test.js | 54 + tests/unit/task-manager/tag-boundary.test.js | 278 ++++ tests/unit/task-master.test.js | 6 +- 95 files changed, 3899 insertions(+), 590 deletions(-) create mode 100644 .changeset/cool-glasses-invite.md create mode 100644 tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js create mode 100644 tests/unit/scripts/modules/task-manager/move-task.test.js create mode 100644 tests/unit/scripts/modules/task-manager/remove-task.test.js create mode 100644 tests/unit/scripts/modules/task-manager/research.test.js create mode 100644 tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js create mode 100644 tests/unit/scripts/modules/task-manager/update-task-by-id.test.js create mode 100644 tests/unit/task-manager/clear-subtasks.test.js create mode 100644 tests/unit/task-manager/move-task.test.js create mode 100644 tests/unit/task-manager/tag-boundary.test.js diff --git a/.changeset/cool-glasses-invite.md b/.changeset/cool-glasses-invite.md new file mode 100644 index 00000000..68eced60 --- /dev/null +++ b/.changeset/cool-glasses-invite.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Implement Boundary-First Tag Resolution to ensure consistent and deterministic tag handling across CLI and MCP, resolving potential race conditions. diff --git a/mcp-server/src/core/direct-functions/add-dependency.js b/mcp-server/src/core/direct-functions/add-dependency.js index b88eb4c6..70525e4d 100644 --- a/mcp-server/src/core/direct-functions/add-dependency.js +++ b/mcp-server/src/core/direct-functions/add-dependency.js @@ -16,12 +16,14 @@ import { * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. * @param {string|number} args.id - Task ID to add dependency to * @param {string|number} args.dependsOn - Task ID that will become a dependency + * @param {string} args.tag - Tag for the task (optional) + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) * @param {Object} log - Logger object * @returns {Promise} - Result object with success status and data/error information */ export async function addDependencyDirect(args, log) { // Destructure expected args - const { tasksJsonPath, id, dependsOn } = args; + const { tasksJsonPath, id, dependsOn, tag, projectRoot } = args; try { log.info(`Adding dependency with args: ${JSON.stringify(args)}`); @@ -76,8 +78,11 @@ export async function addDependencyDirect(args, log) { // Enable silent mode to prevent console logs from interfering with JSON response enableSilentMode(); + // Create context object + const context = { projectRoot, tag }; + // Call the core function using the provided path - await addDependency(tasksPath, taskId, dependencyId); + await addDependency(tasksPath, taskId, dependencyId, context); // Restore normal logging disableSilentMode(); diff --git a/mcp-server/src/core/direct-functions/add-task.js b/mcp-server/src/core/direct-functions/add-task.js index 476fd062..fc29e8a9 100644 --- a/mcp-server/src/core/direct-functions/add-task.js +++ b/mcp-server/src/core/direct-functions/add-task.js @@ -24,6 +24,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {string} [args.tasksJsonPath] - Path to the tasks.json file (resolved by tool) * @param {boolean} [args.research=false] - Whether to use research capabilities for task creation * @param {string} [args.projectRoot] - Project root path + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object * @param {Object} context - Additional context (session) * @returns {Promise} - Result object { success: boolean, data?: any, error?: { code: string, message: string } } @@ -36,7 +37,8 @@ export async function addTaskDirect(args, log, context = {}) { dependencies, priority, research, - projectRoot + projectRoot, + tag } = args; const { session } = context; // Destructure session from context @@ -121,7 +123,8 @@ export async function addTaskDirect(args, log, context = {}) { mcpLog, projectRoot, commandName: 'add-task', - outputType: 'mcp' + outputType: 'mcp', + tag }, 'json', // outputFormat manualTaskData, // Pass the manual task data @@ -147,7 +150,8 @@ export async function addTaskDirect(args, log, context = {}) { mcpLog, projectRoot, commandName: 'add-task', - outputType: 'mcp' + outputType: 'mcp', + tag }, 'json', // outputFormat null, // manualTaskData is null for AI creation diff --git a/mcp-server/src/core/direct-functions/analyze-task-complexity.js b/mcp-server/src/core/direct-functions/analyze-task-complexity.js index 8a5cfa60..c2500eef 100644 --- a/mcp-server/src/core/direct-functions/analyze-task-complexity.js +++ b/mcp-server/src/core/direct-functions/analyze-task-complexity.js @@ -22,6 +22,7 @@ import { createLogWrapper } from '../../tools/utils.js'; // Import the new utili * @param {number} [args.from] - Starting task ID in a range to analyze * @param {number} [args.to] - Ending task ID in a range to analyze * @param {string} [args.projectRoot] - Project root path. + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object * @param {Object} [context={}] - Context object containing session data * @param {Object} [context.session] - MCP session object @@ -37,7 +38,8 @@ export async function analyzeTaskComplexityDirect(args, log, context = {}) { projectRoot, ids, from, - to + to, + tag } = args; const logWrapper = createLogWrapper(log); @@ -91,7 +93,8 @@ export async function analyzeTaskComplexityDirect(args, log, context = {}) { projectRoot: projectRoot, // Pass projectRoot here id: ids, // Pass the ids parameter to the core function as 'id' from: from, // Pass from parameter - to: to // Pass to parameter + to: to, // Pass to parameter + tag // forward tag }; // --- End Initial Checks --- @@ -112,7 +115,9 @@ export async function analyzeTaskComplexityDirect(args, log, context = {}) { session, mcpLog: logWrapper, commandName: 'analyze-complexity', - outputType: 'mcp' + outputType: 'mcp', + projectRoot, + tag }); report = coreResult.report; } catch (error) { diff --git a/mcp-server/src/core/direct-functions/clear-subtasks.js b/mcp-server/src/core/direct-functions/clear-subtasks.js index 7aabb807..0fbb9546 100644 --- a/mcp-server/src/core/direct-functions/clear-subtasks.js +++ b/mcp-server/src/core/direct-functions/clear-subtasks.js @@ -18,6 +18,7 @@ import path from 'path'; * @param {string} [args.id] - Task IDs (comma-separated) to clear subtasks from * @param {boolean} [args.all] - Clear subtasks from all tasks * @param {string} [args.tag] - Tag context to operate on (defaults to current active tag) + * @param {string} [args.projectRoot] - Project root path (for MCP/env fallback) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ @@ -80,7 +81,7 @@ export async function clearSubtasksDirect(args, log) { }; } - const currentTag = data.tag || 'master'; + const currentTag = data.tag || tag; const tasks = data.tasks; // If all is specified, get all task IDs diff --git a/mcp-server/src/core/direct-functions/expand-all-tasks.js b/mcp-server/src/core/direct-functions/expand-all-tasks.js index 4d1a8a74..531b847e 100644 --- a/mcp-server/src/core/direct-functions/expand-all-tasks.js +++ b/mcp-server/src/core/direct-functions/expand-all-tasks.js @@ -18,6 +18,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {string} [args.prompt] - Additional context to guide subtask generation * @param {boolean} [args.force] - Force regeneration of subtasks for tasks that already have them * @param {string} [args.projectRoot] - Project root path. + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object from FastMCP * @param {Object} context - Context object containing session * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} @@ -25,7 +26,8 @@ import { createLogWrapper } from '../../tools/utils.js'; export async function expandAllTasksDirect(args, log, context = {}) { const { session } = context; // Extract session // Destructure expected args, including projectRoot - const { tasksJsonPath, num, research, prompt, force, projectRoot } = args; + const { tasksJsonPath, num, research, prompt, force, projectRoot, tag } = + args; // Create logger wrapper using the utility const mcpLog = createLogWrapper(log); @@ -44,7 +46,7 @@ export async function expandAllTasksDirect(args, log, context = {}) { enableSilentMode(); // Enable silent mode for the core function call try { log.info( - `Calling core expandAllTasks with args: ${JSON.stringify({ num, research, prompt, force, projectRoot })}` + `Calling core expandAllTasks with args: ${JSON.stringify({ num, research, prompt, force, projectRoot, tag })}` ); // Parse parameters (ensure correct types) @@ -60,7 +62,7 @@ export async function expandAllTasksDirect(args, log, context = {}) { useResearch, additionalContext, forceFlag, - { session, mcpLog, projectRoot }, + { session, mcpLog, projectRoot, tag }, 'json' ); diff --git a/mcp-server/src/core/direct-functions/expand-task.js b/mcp-server/src/core/direct-functions/expand-task.js index d231a95c..ccf51057 100644 --- a/mcp-server/src/core/direct-functions/expand-task.js +++ b/mcp-server/src/core/direct-functions/expand-task.js @@ -35,8 +35,17 @@ import { createLogWrapper } from '../../tools/utils.js'; export async function expandTaskDirect(args, log, context = {}) { const { session } = context; // Extract session // Destructure expected args, including projectRoot - const { tasksJsonPath, id, num, research, prompt, force, projectRoot, tag } = - args; + const { + tasksJsonPath, + id, + num, + research, + prompt, + force, + projectRoot, + tag, + complexityReportPath + } = args; // Log session root data for debugging log.info( @@ -192,6 +201,7 @@ export async function expandTaskDirect(args, log, context = {}) { useResearch, additionalContext, { + complexityReportPath, mcpLog, session, projectRoot, diff --git a/mcp-server/src/core/direct-functions/fix-dependencies.js b/mcp-server/src/core/direct-functions/fix-dependencies.js index 7bfceddf..5f7b61d5 100644 --- a/mcp-server/src/core/direct-functions/fix-dependencies.js +++ b/mcp-server/src/core/direct-functions/fix-dependencies.js @@ -53,10 +53,9 @@ export async function fixDependenciesDirect(args, log) { // Enable silent mode to prevent console logs from interfering with JSON response enableSilentMode(); + const options = { projectRoot, tag }; // Call the original command function using the provided path and proper context - await fixDependenciesCommand(tasksPath, { - context: { projectRoot, tag } - }); + await fixDependenciesCommand(tasksPath, options); // Restore normal logging disableSilentMode(); diff --git a/mcp-server/src/core/direct-functions/generate-task-files.js b/mcp-server/src/core/direct-functions/generate-task-files.js index e9b61dcc..f42a602a 100644 --- a/mcp-server/src/core/direct-functions/generate-task-files.js +++ b/mcp-server/src/core/direct-functions/generate-task-files.js @@ -13,12 +13,16 @@ import { * Direct function wrapper for generateTaskFiles with error handling. * * @param {Object} args - Command arguments containing tasksJsonPath and outputDir. + * @param {string} args.tasksJsonPath - Path to the tasks.json file. + * @param {string} args.outputDir - Path to the output directory. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object. * @returns {Promise} - Result object with success status and data/error information. */ export async function generateTaskFilesDirect(args, log) { // Destructure expected args - const { tasksJsonPath, outputDir } = args; + const { tasksJsonPath, outputDir, projectRoot, tag } = args; try { log.info(`Generating task files with args: ${JSON.stringify(args)}`); @@ -51,8 +55,12 @@ export async function generateTaskFilesDirect(args, log) { // Enable silent mode to prevent logs from being written to stdout enableSilentMode(); - // The function is synchronous despite being awaited elsewhere - generateTaskFiles(tasksPath, resolvedOutputDir); + // Pass projectRoot and tag so the core respects context + generateTaskFiles(tasksPath, resolvedOutputDir, { + projectRoot, + tag, + mcpLog: log + }); // Restore normal logging after task generation disableSilentMode(); diff --git a/mcp-server/src/core/direct-functions/list-tasks.js b/mcp-server/src/core/direct-functions/list-tasks.js index 36ccc01b..9511f43b 100644 --- a/mcp-server/src/core/direct-functions/list-tasks.js +++ b/mcp-server/src/core/direct-functions/list-tasks.js @@ -13,12 +13,19 @@ import { * Direct function wrapper for listTasks with error handling and caching. * * @param {Object} args - Command arguments (now expecting tasksJsonPath explicitly). + * @param {string} args.tasksJsonPath - Path to the tasks.json file. + * @param {string} args.reportPath - Path to the report file. + * @param {string} args.status - Status of the task. + * @param {boolean} args.withSubtasks - Whether to include subtasks. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object. * @returns {Promise} - Task list result { success: boolean, data?: any, error?: { code: string, message: string } }. */ export async function listTasksDirect(args, log, context = {}) { // Destructure the explicit tasksJsonPath from args - const { tasksJsonPath, reportPath, status, withSubtasks, projectRoot } = args; + const { tasksJsonPath, reportPath, status, withSubtasks, projectRoot, tag } = + args; const { session } = context; if (!tasksJsonPath) { @@ -52,8 +59,7 @@ export async function listTasksDirect(args, log, context = {}) { reportPath, withSubtasksFilter, 'json', - null, // tag - { projectRoot, session } // context + { projectRoot, session, tag } ); if (!resultData || !resultData.tasks) { diff --git a/mcp-server/src/core/direct-functions/move-task.js b/mcp-server/src/core/direct-functions/move-task.js index 9cc06d61..7042a051 100644 --- a/mcp-server/src/core/direct-functions/move-task.js +++ b/mcp-server/src/core/direct-functions/move-task.js @@ -17,12 +17,14 @@ import { * @param {string} args.destinationId - ID of the destination (e.g., '7' or '7.3' or '7,8,9') * @param {string} args.file - Alternative path to the tasks.json file * @param {string} args.projectRoot - Project root directory + * @param {string} args.tag - Tag for the task (optional) * @param {boolean} args.generateFiles - Whether to regenerate task files after moving (default: true) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: Object}>} */ export async function moveTaskDirect(args, log, context = {}) { const { session } = context; + const { projectRoot, tag } = args; // Validate required parameters if (!args.sourceId) { @@ -73,8 +75,8 @@ export async function moveTaskDirect(args, log, context = {}) { args.destinationId, generateFiles, { - projectRoot: args.projectRoot, - tag: args.tag + projectRoot, + tag } ); diff --git a/mcp-server/src/core/direct-functions/next-task.js b/mcp-server/src/core/direct-functions/next-task.js index be77525b..be3f08d9 100644 --- a/mcp-server/src/core/direct-functions/next-task.js +++ b/mcp-server/src/core/direct-functions/next-task.js @@ -18,12 +18,15 @@ import { * * @param {Object} args - Command arguments * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. + * @param {string} args.reportPath - Path to the report file. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise} - Next task result { success: boolean, data?: any, error?: { code: string, message: string } } */ export async function nextTaskDirect(args, log, context = {}) { // Destructure expected args - const { tasksJsonPath, reportPath, projectRoot } = args; + const { tasksJsonPath, reportPath, projectRoot, tag } = args; const { session } = context; if (!tasksJsonPath) { @@ -46,7 +49,7 @@ export async function nextTaskDirect(args, log, context = {}) { log.info(`Finding next task from ${tasksJsonPath}`); // Read tasks data using the provided path - const data = readJSON(tasksJsonPath, projectRoot); + const data = readJSON(tasksJsonPath, projectRoot, tag); if (!data || !data.tasks) { disableSilentMode(); // Disable before return return { diff --git a/mcp-server/src/core/direct-functions/parse-prd.js b/mcp-server/src/core/direct-functions/parse-prd.js index 7c269547..75a3337b 100644 --- a/mcp-server/src/core/direct-functions/parse-prd.js +++ b/mcp-server/src/core/direct-functions/parse-prd.js @@ -20,6 +20,13 @@ import { TASKMASTER_TASKS_FILE } from '../../../../src/constants/paths.js'; * Direct function wrapper for parsing PRD documents and generating tasks. * * @param {Object} args - Command arguments containing projectRoot, input, output, numTasks options. + * @param {string} args.input - Path to the input PRD file. + * @param {string} args.output - Path to the output directory. + * @param {string} args.numTasks - Number of tasks to generate. + * @param {boolean} args.force - Whether to force parsing. + * @param {boolean} args.append - Whether to append to the output file. + * @param {boolean} args.research - Whether to use research mode. + * @param {string} args.tag - Tag context for organizing tasks into separate task lists. * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. @@ -34,7 +41,8 @@ export async function parsePRDDirect(args, log, context = {}) { force, append, research, - projectRoot + projectRoot, + tag } = args; // Create the standard logger wrapper @@ -152,6 +160,7 @@ export async function parsePRDDirect(args, log, context = {}) { session, mcpLog: logWrapper, projectRoot, + tag, force, append, research, diff --git a/mcp-server/src/core/direct-functions/remove-dependency.js b/mcp-server/src/core/direct-functions/remove-dependency.js index 9726da13..d5d3d2e4 100644 --- a/mcp-server/src/core/direct-functions/remove-dependency.js +++ b/mcp-server/src/core/direct-functions/remove-dependency.js @@ -14,12 +14,14 @@ import { * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. * @param {string|number} args.id - Task ID to remove dependency from * @param {string|number} args.dependsOn - Task ID to remove as a dependency + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function removeDependencyDirect(args, log) { // Destructure expected args - const { tasksJsonPath, id, dependsOn } = args; + const { tasksJsonPath, id, dependsOn, projectRoot, tag } = args; try { log.info(`Removing dependency with args: ${JSON.stringify(args)}`); @@ -75,7 +77,10 @@ export async function removeDependencyDirect(args, log) { enableSilentMode(); // Call the core function using the provided tasksPath - await removeDependency(tasksPath, taskId, dependencyId); + await removeDependency(tasksPath, taskId, dependencyId, { + projectRoot, + tag + }); // Restore normal logging disableSilentMode(); diff --git a/mcp-server/src/core/direct-functions/remove-subtask.js b/mcp-server/src/core/direct-functions/remove-subtask.js index c71c8a51..3b2f16cd 100644 --- a/mcp-server/src/core/direct-functions/remove-subtask.js +++ b/mcp-server/src/core/direct-functions/remove-subtask.js @@ -15,12 +15,14 @@ import { * @param {string} args.id - Subtask ID in format "parentId.subtaskId" (required) * @param {boolean} [args.convert] - Whether to convert the subtask to a standalone task * @param {boolean} [args.skipGenerate] - Skip regenerating task files + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function removeSubtaskDirect(args, log) { // Destructure expected args - const { tasksJsonPath, id, convert, skipGenerate } = args; + const { tasksJsonPath, id, convert, skipGenerate, projectRoot, tag } = args; try { // Enable silent mode to prevent console logs from interfering with JSON response enableSilentMode(); @@ -82,7 +84,11 @@ export async function removeSubtaskDirect(args, log) { tasksPath, id, convertToTask, - generateFiles + generateFiles, + { + projectRoot, + tag + } ); // Restore normal logging diff --git a/mcp-server/src/core/direct-functions/remove-task.js b/mcp-server/src/core/direct-functions/remove-task.js index 33842249..63639454 100644 --- a/mcp-server/src/core/direct-functions/remove-task.js +++ b/mcp-server/src/core/direct-functions/remove-task.js @@ -20,7 +20,8 @@ import { * @param {Object} args - Command arguments * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. * @param {string} args.id - The ID(s) of the task(s) or subtask(s) to remove (comma-separated for multiple). - * @param {string} [args.tag] - Tag context to operate on (defaults to current active tag). + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise} - Remove task result { success: boolean, data?: any, error?: { code: string, message: string } } */ @@ -117,7 +118,7 @@ export async function removeTaskDirect(args, log, context = {}) { removedTasks: result.removedTasks, message: result.message, tasksPath: tasksJsonPath, - tag: data.tag || tag || 'master' + tag } }; } finally { diff --git a/mcp-server/src/core/direct-functions/research.js b/mcp-server/src/core/direct-functions/research.js index e6feee29..6b90c124 100644 --- a/mcp-server/src/core/direct-functions/research.js +++ b/mcp-server/src/core/direct-functions/research.js @@ -24,6 +24,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {string} [args.saveTo] - Automatically save to task/subtask ID (e.g., "15" or "15.2") * @param {boolean} [args.saveToFile=false] - Save research results to .taskmaster/docs/research/ directory * @param {string} [args.projectRoot] - Project root path + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object * @param {Object} context - Additional context (session) * @returns {Promise} - Result object { success: boolean, data?: any, error?: { code: string, message: string } } @@ -39,7 +40,8 @@ export async function researchDirect(args, log, context = {}) { detailLevel = 'medium', saveTo, saveToFile = false, - projectRoot + projectRoot, + tag } = args; const { session } = context; // Destructure session from context @@ -111,6 +113,7 @@ export async function researchDirect(args, log, context = {}) { includeProjectTree, detailLevel, projectRoot, + tag, saveToFile }; @@ -169,7 +172,8 @@ ${result.result}`; mcpLog, commandName: 'research-save', outputType: 'mcp', - projectRoot + projectRoot, + tag }, 'json' ); @@ -200,7 +204,8 @@ ${result.result}`; mcpLog, commandName: 'research-save', outputType: 'mcp', - projectRoot + projectRoot, + tag }, 'json', true // appendMode = true diff --git a/mcp-server/src/core/direct-functions/set-task-status.js b/mcp-server/src/core/direct-functions/set-task-status.js index aacd94fc..08dca8a7 100644 --- a/mcp-server/src/core/direct-functions/set-task-status.js +++ b/mcp-server/src/core/direct-functions/set-task-status.js @@ -14,6 +14,11 @@ import { nextTaskDirect } from './next-task.js'; * Direct function wrapper for setTaskStatus with error handling. * * @param {Object} args - Command arguments containing id, status, tasksJsonPath, and projectRoot. + * @param {string} args.id - The ID of the task to update. + * @param {string} args.status - The new status to set for the task. + * @param {string} args.tasksJsonPath - Path to the tasks.json file. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object. * @param {Object} context - Additional context (session) * @returns {Promise} - Result object with success status and data/error information. @@ -70,17 +75,12 @@ export async function setTaskStatusDirect(args, log, context = {}) { enableSilentMode(); // Enable silent mode before calling core function try { // Call the core function - await setTaskStatus( - tasksPath, - taskId, - newStatus, - { - mcpLog: log, - projectRoot, - session - }, + await setTaskStatus(tasksPath, taskId, newStatus, { + mcpLog: log, + projectRoot, + session, tag - ); + }); log.info(`Successfully set task ${taskId} status to ${newStatus}`); @@ -103,7 +103,8 @@ export async function setTaskStatusDirect(args, log, context = {}) { { tasksJsonPath: tasksJsonPath, reportPath: complexityReportPath, - projectRoot: projectRoot + projectRoot: projectRoot, + tag }, log, { session } diff --git a/mcp-server/src/core/direct-functions/show-task.js b/mcp-server/src/core/direct-functions/show-task.js index e1ea6b0c..9e168615 100644 --- a/mcp-server/src/core/direct-functions/show-task.js +++ b/mcp-server/src/core/direct-functions/show-task.js @@ -19,6 +19,7 @@ import { findTasksPath } from '../utils/path-utils.js'; * @param {string} args.reportPath - Explicit path to the complexity report file. * @param {string} [args.status] - Optional status to filter subtasks by. * @param {string} args.projectRoot - Absolute path to the project root directory (already normalized by tool). + * @param {string} [args.tag] - Tag for the task * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. @@ -26,7 +27,7 @@ import { findTasksPath } from '../utils/path-utils.js'; export async function showTaskDirect(args, log) { // This function doesn't need session context since it only reads data // Destructure projectRoot and other args. projectRoot is assumed normalized. - const { id, file, reportPath, status, projectRoot } = args; + const { id, file, reportPath, status, projectRoot, tag } = args; log.info( `Showing task direct function. ID: ${id}, File: ${file}, Status Filter: ${status}, ProjectRoot: ${projectRoot}` @@ -55,7 +56,7 @@ export async function showTaskDirect(args, log) { // --- Rest of the function remains the same, using tasksJsonPath --- try { - const tasksData = readJSON(tasksJsonPath, projectRoot); + const tasksData = readJSON(tasksJsonPath, projectRoot, tag); if (!tasksData || !tasksData.tasks) { return { success: false, diff --git a/mcp-server/src/core/direct-functions/update-subtask-by-id.js b/mcp-server/src/core/direct-functions/update-subtask-by-id.js index c1310294..fd9faa99 100644 --- a/mcp-server/src/core/direct-functions/update-subtask-by-id.js +++ b/mcp-server/src/core/direct-functions/update-subtask-by-id.js @@ -20,6 +20,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {string} args.prompt - Information to append to the subtask. * @param {boolean} [args.research] - Whether to use research role. * @param {string} [args.projectRoot] - Project root path. + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. @@ -27,7 +28,7 @@ import { createLogWrapper } from '../../tools/utils.js'; export async function updateSubtaskByIdDirect(args, log, context = {}) { const { session } = context; // Destructure expected args, including projectRoot - const { tasksJsonPath, id, prompt, research, projectRoot } = args; + const { tasksJsonPath, id, prompt, research, projectRoot, tag } = args; const logWrapper = createLogWrapper(log); @@ -112,6 +113,7 @@ export async function updateSubtaskByIdDirect(args, log, context = {}) { mcpLog: logWrapper, session, projectRoot, + tag, commandName: 'update-subtask', outputType: 'mcp' }, diff --git a/mcp-server/src/core/direct-functions/update-task-by-id.js b/mcp-server/src/core/direct-functions/update-task-by-id.js index 5eead3ea..b7b5570d 100644 --- a/mcp-server/src/core/direct-functions/update-task-by-id.js +++ b/mcp-server/src/core/direct-functions/update-task-by-id.js @@ -21,6 +21,7 @@ import { createLogWrapper } from '../../tools/utils.js'; * @param {boolean} [args.research] - Whether to use research role. * @param {boolean} [args.append] - Whether to append timestamped information instead of full update. * @param {string} [args.projectRoot] - Project root path. + * @param {string} [args.tag] - Tag for the task (optional) * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. @@ -28,7 +29,8 @@ import { createLogWrapper } from '../../tools/utils.js'; export async function updateTaskByIdDirect(args, log, context = {}) { const { session } = context; // Destructure expected args, including projectRoot - const { tasksJsonPath, id, prompt, research, append, projectRoot } = args; + const { tasksJsonPath, id, prompt, research, append, projectRoot, tag } = + args; const logWrapper = createLogWrapper(log); @@ -116,6 +118,7 @@ export async function updateTaskByIdDirect(args, log, context = {}) { mcpLog: logWrapper, session, projectRoot, + tag, commandName: 'update-task', outputType: 'mcp' }, diff --git a/mcp-server/src/core/direct-functions/update-tasks.js b/mcp-server/src/core/direct-functions/update-tasks.js index 36d1ef4e..ac05bfa0 100644 --- a/mcp-server/src/core/direct-functions/update-tasks.js +++ b/mcp-server/src/core/direct-functions/update-tasks.js @@ -15,6 +15,12 @@ import { * Direct function wrapper for updating tasks based on new context. * * @param {Object} args - Command arguments containing projectRoot, from, prompt, research options. + * @param {string} args.from - The ID of the task to update. + * @param {string} args.prompt - The prompt to update the task with. + * @param {boolean} args.research - Whether to use research mode. + * @param {string} args.tasksJsonPath - Path to the tasks.json file. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. diff --git a/mcp-server/src/core/direct-functions/validate-dependencies.js b/mcp-server/src/core/direct-functions/validate-dependencies.js index a99aa47f..4ab6f1d7 100644 --- a/mcp-server/src/core/direct-functions/validate-dependencies.js +++ b/mcp-server/src/core/direct-functions/validate-dependencies.js @@ -13,12 +13,14 @@ import fs from 'fs'; * Validate dependencies in tasks.json * @param {Object} args - Function arguments * @param {string} args.tasksJsonPath - Explicit path to the tasks.json file. + * @param {string} args.projectRoot - Project root path (for MCP/env fallback) + * @param {string} args.tag - Tag for the task (optional) * @param {Object} log - Logger object * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function validateDependenciesDirect(args, log) { // Destructure the explicit tasksJsonPath - const { tasksJsonPath } = args; + const { tasksJsonPath, projectRoot, tag } = args; if (!tasksJsonPath) { log.error('validateDependenciesDirect called without tasksJsonPath'); @@ -51,8 +53,9 @@ export async function validateDependenciesDirect(args, log) { // Enable silent mode to prevent console logs from interfering with JSON response enableSilentMode(); + const options = { projectRoot, tag }; // Call the original command function using the provided tasksPath - await validateDependenciesCommand(tasksPath); + await validateDependenciesCommand(tasksPath, options); // Restore normal logging disableSilentMode(); diff --git a/mcp-server/src/core/utils/path-utils.js b/mcp-server/src/core/utils/path-utils.js index be4c8462..9aa5ef6d 100644 --- a/mcp-server/src/core/utils/path-utils.js +++ b/mcp-server/src/core/utils/path-utils.js @@ -121,6 +121,7 @@ export function resolveComplexityReportPath(args, log = silentLogger) { // Get explicit path from args.complexityReport if provided const explicitPath = args?.complexityReport; const rawProjectRoot = args?.projectRoot; + const tag = args?.tag; // If explicit path is provided and absolute, use it directly if (explicitPath && path.isAbsolute(explicitPath)) { @@ -139,7 +140,11 @@ export function resolveComplexityReportPath(args, log = silentLogger) { // Use core findComplexityReportPath with explicit path and normalized projectRoot context if (projectRoot) { - return coreFindComplexityReportPath(explicitPath, { projectRoot }, log); + return coreFindComplexityReportPath( + explicitPath, + { projectRoot, tag }, + log + ); } // Fallback to core function without projectRoot context diff --git a/mcp-server/src/tools/add-dependency.js b/mcp-server/src/tools/add-dependency.js index 7b3a6bf4..d922a3ef 100644 --- a/mcp-server/src/tools/add-dependency.js +++ b/mcp-server/src/tools/add-dependency.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { addDependencyDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the addDependency tool with the MCP server @@ -33,14 +34,18 @@ export function registerAddDependencyTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info( `Adding dependency for task ${args.id} to depend on ${args.dependsOn}` ); - + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); let tasksJsonPath; try { tasksJsonPath = findTasksPath( @@ -61,7 +66,9 @@ export function registerAddDependencyTool(server) { tasksJsonPath: tasksJsonPath, // Pass other relevant args id: args.id, - dependsOn: args.dependsOn + dependsOn: args.dependsOn, + projectRoot: args.projectRoot, + tag: resolvedTag }, log // Remove context object diff --git a/mcp-server/src/tools/add-subtask.js b/mcp-server/src/tools/add-subtask.js index 8d1fda44..62ddffe8 100644 --- a/mcp-server/src/tools/add-subtask.js +++ b/mcp-server/src/tools/add-subtask.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { addSubtaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the addSubtask tool with the MCP server @@ -52,17 +53,21 @@ export function registerAddSubtaskTool(server) { .describe( 'Absolute path to the tasks file (default: tasks/tasks.json)' ), - tag: z.string().optional().describe('Tag context to operate on'), skipGenerate: z .boolean() .optional() .describe('Skip regenerating task files'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info(`Adding subtask with args: ${JSON.stringify(args)}`); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) @@ -91,7 +96,7 @@ export function registerAddSubtaskTool(server) { dependencies: args.dependencies, skipGenerate: args.skipGenerate, projectRoot: args.projectRoot, - tag: args.tag + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/add-task.js b/mcp-server/src/tools/add-task.js index 56e9e6c4..3ad43a94 100644 --- a/mcp-server/src/tools/add-task.js +++ b/mcp-server/src/tools/add-task.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { addTaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the addTask tool with the MCP server @@ -58,6 +59,7 @@ export function registerAddTaskTool(server) { projectRoot: z .string() .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on'), research: z .boolean() .optional() @@ -67,6 +69,11 @@ export function registerAddTaskTool(server) { try { log.info(`Starting add-task with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -93,7 +100,8 @@ export function registerAddTaskTool(server) { dependencies: args.dependencies, priority: args.priority, research: args.research, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/analyze.js b/mcp-server/src/tools/analyze.js index ff39e049..871c90da 100644 --- a/mcp-server/src/tools/analyze.js +++ b/mcp-server/src/tools/analyze.js @@ -13,7 +13,9 @@ import { } from './utils.js'; import { analyzeTaskComplexityDirect } from '../core/task-master-core.js'; // Assuming core functions are exported via task-master-core.js import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; import { COMPLEXITY_REPORT_FILE } from '../../../src/constants/paths.js'; +import { resolveComplexityReportOutputPath } from '../../../src/utils/path-utils.js'; /** * Register the analyze_project_complexity tool @@ -70,15 +72,22 @@ export function registerAnalyzeProjectComplexityTool(server) { .describe('Ending task ID in a range to analyze.'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { const toolName = 'analyze_project_complexity'; // Define tool name for logging + try { log.info( `Executing ${toolName} tool with args: ${JSON.stringify(args)}` ); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + let tasksJsonPath; try { tasksJsonPath = findTasksPath( @@ -93,9 +102,14 @@ export function registerAnalyzeProjectComplexityTool(server) { ); } - const outputPath = args.output - ? path.resolve(args.projectRoot, args.output) - : path.resolve(args.projectRoot, COMPLEXITY_REPORT_FILE); + const outputPath = resolveComplexityReportOutputPath( + args.output, + { + projectRoot: args.projectRoot, + tag: resolvedTag + }, + log + ); log.info(`${toolName}: Report output path: ${outputPath}`); @@ -123,6 +137,7 @@ export function registerAnalyzeProjectComplexityTool(server) { threshold: args.threshold, research: args.research, projectRoot: args.projectRoot, + tag: resolvedTag, ids: args.ids, from: args.from, to: args.to diff --git a/mcp-server/src/tools/clear-subtasks.js b/mcp-server/src/tools/clear-subtasks.js index 4bff2bcc..6a0d0bff 100644 --- a/mcp-server/src/tools/clear-subtasks.js +++ b/mcp-server/src/tools/clear-subtasks.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { clearSubtasksDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the clearSubtasks tool with the MCP server @@ -46,6 +47,11 @@ export function registerClearSubtasksTool(server) { try { log.info(`Clearing subtasks with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -65,8 +71,9 @@ export function registerClearSubtasksTool(server) { tasksJsonPath: tasksJsonPath, id: args.id, all: args.all, + projectRoot: args.projectRoot, - tag: args.tag || 'master' + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/complexity-report.js b/mcp-server/src/tools/complexity-report.js index 626bc815..0dfa401a 100644 --- a/mcp-server/src/tools/complexity-report.js +++ b/mcp-server/src/tools/complexity-report.js @@ -12,6 +12,7 @@ import { import { complexityReportDirect } from '../core/task-master-core.js'; import { COMPLEXITY_REPORT_FILE } from '../../../src/constants/paths.js'; import { findComplexityReportPath } from '../core/utils/path-utils.js'; +import { getCurrentTag } from '../../../scripts/modules/utils.js'; /** * Register the complexityReport tool with the MCP server @@ -38,12 +39,16 @@ export function registerComplexityReportTool(server) { `Getting complexity report with args: ${JSON.stringify(args)}` ); + const resolvedTag = getCurrentTag(args.projectRoot); + const pathArgs = { projectRoot: args.projectRoot, - complexityReport: args.file + complexityReport: args.file, + tag: resolvedTag }; const reportPath = findComplexityReportPath(pathArgs, log); + log.info('Reading complexity report from path: ', reportPath); if (!reportPath) { return createErrorResponse( diff --git a/mcp-server/src/tools/expand-all.js b/mcp-server/src/tools/expand-all.js index 4fa07a26..08823016 100644 --- a/mcp-server/src/tools/expand-all.js +++ b/mcp-server/src/tools/expand-all.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { expandAllTasksDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the expandAll tool with the MCP server @@ -57,7 +58,8 @@ export function registerExpandAllTool(server) { .optional() .describe( 'Absolute path to the project root directory (derived from session if possible)' - ) + ), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { @@ -65,6 +67,10 @@ export function registerExpandAllTool(server) { `Tool expand_all execution started with args: ${JSON.stringify(args)}` ); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); let tasksJsonPath; try { tasksJsonPath = findTasksPath( @@ -86,7 +92,8 @@ export function registerExpandAllTool(server) { research: args.research, prompt: args.prompt, force: args.force, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/expand-task.js b/mcp-server/src/tools/expand-task.js index 43d393cc..b6cd5b1f 100644 --- a/mcp-server/src/tools/expand-task.js +++ b/mcp-server/src/tools/expand-task.js @@ -10,7 +10,11 @@ import { withNormalizedProjectRoot } from './utils.js'; import { expandTaskDirect } from '../core/task-master-core.js'; -import { findTasksPath } from '../core/utils/path-utils.js'; +import { + findTasksPath, + findComplexityReportPath +} from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the expand-task tool with the MCP server @@ -51,7 +55,10 @@ export function registerExpandTaskTool(server) { execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info(`Starting expand-task with args: ${JSON.stringify(args)}`); - + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -66,6 +73,11 @@ export function registerExpandTaskTool(server) { ); } + const complexityReportPath = findComplexityReportPath( + { ...args, tag: resolvedTag }, + log + ); + const result = await expandTaskDirect( { tasksJsonPath: tasksJsonPath, @@ -74,8 +86,9 @@ export function registerExpandTaskTool(server) { research: args.research, prompt: args.prompt, force: args.force, + complexityReportPath, projectRoot: args.projectRoot, - tag: args.tag || 'master' + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/fix-dependencies.js b/mcp-server/src/tools/fix-dependencies.js index c46d18bc..92586355 100644 --- a/mcp-server/src/tools/fix-dependencies.js +++ b/mcp-server/src/tools/fix-dependencies.js @@ -11,7 +11,7 @@ import { } from './utils.js'; import { fixDependenciesDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; - +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the fixDependencies tool with the MCP server * @param {Object} server - FastMCP server instance @@ -31,6 +31,11 @@ export function registerFixDependenciesTool(server) { try { log.info(`Fixing dependencies with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -49,7 +54,7 @@ export function registerFixDependenciesTool(server) { { tasksJsonPath: tasksJsonPath, projectRoot: args.projectRoot, - tag: args.tag + tag: resolvedTag }, log ); diff --git a/mcp-server/src/tools/generate.js b/mcp-server/src/tools/generate.js index 766e7892..aab34355 100644 --- a/mcp-server/src/tools/generate.js +++ b/mcp-server/src/tools/generate.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { generateTaskFilesDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; import path from 'path'; /** @@ -30,12 +31,17 @@ export function registerGenerateTool(server) { .describe('Output directory (default: same directory as tasks file)'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info(`Generating task files with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -58,7 +64,8 @@ export function registerGenerateTool(server) { { tasksJsonPath: tasksJsonPath, outputDir: outputDir, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/get-task.js b/mcp-server/src/tools/get-task.js index 620e714e..7313e8ac 100644 --- a/mcp-server/src/tools/get-task.js +++ b/mcp-server/src/tools/get-task.js @@ -14,6 +14,7 @@ import { findTasksPath, findComplexityReportPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Custom processor function that removes allTasks from the response @@ -67,7 +68,8 @@ export function registerShowTaskTool(server) { .string() .describe( 'Absolute path to the project root directory (Optional, usually from session)' - ) + ), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { const { id, file, status, projectRoot } = args; @@ -76,6 +78,10 @@ export function registerShowTaskTool(server) { log.info( `Getting task details for ID: ${id}${status ? ` (filtering subtasks by status: ${status})` : ''} in root: ${projectRoot}` ); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Resolve the path to tasks.json using the NORMALIZED projectRoot from args let tasksJsonPath; @@ -99,7 +105,8 @@ export function registerShowTaskTool(server) { complexityReportPath = findComplexityReportPath( { projectRoot: projectRoot, - complexityReport: args.complexityReport + complexityReport: args.complexityReport, + tag: resolvedTag }, log ); @@ -113,7 +120,8 @@ export function registerShowTaskTool(server) { // Pass other relevant args id: id, status: status, - projectRoot: projectRoot + projectRoot: projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/get-tasks.js b/mcp-server/src/tools/get-tasks.js index 240f2ab2..8e8e3642 100644 --- a/mcp-server/src/tools/get-tasks.js +++ b/mcp-server/src/tools/get-tasks.js @@ -15,6 +15,8 @@ import { resolveComplexityReportPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; + /** * Register the getTasks tool with the MCP server * @param {Object} server - FastMCP server instance @@ -51,12 +53,17 @@ export function registerListTasksTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info(`Getting tasks with filters: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Resolve the path to tasks.json using new path utilities let tasksJsonPath; try { @@ -71,7 +78,10 @@ export function registerListTasksTool(server) { // Resolve the path to complexity report let complexityReportPath; try { - complexityReportPath = resolveComplexityReportPath(args, session); + complexityReportPath = resolveComplexityReportPath( + { ...args, tag: resolvedTag }, + session + ); } catch (error) { log.error(`Error finding complexity report: ${error.message}`); // This is optional, so we don't fail the operation @@ -84,7 +94,8 @@ export function registerListTasksTool(server) { status: args.status, withSubtasks: args.withSubtasks, reportPath: complexityReportPath, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/move-task.js b/mcp-server/src/tools/move-task.js index ded04ba3..36f5d166 100644 --- a/mcp-server/src/tools/move-task.js +++ b/mcp-server/src/tools/move-task.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { moveTaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the moveTask tool with the MCP server @@ -36,10 +37,15 @@ export function registerMoveTaskTool(server) { .string() .describe( 'Root directory of the project (typically derived from session)' - ) + ), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Find tasks.json path if not provided let tasksJsonPath = args.file; @@ -79,7 +85,8 @@ export function registerMoveTaskTool(server) { sourceId: fromId, destinationId: toId, tasksJsonPath, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } @@ -115,7 +122,8 @@ export function registerMoveTaskTool(server) { sourceId: args.from, destinationId: args.to, tasksJsonPath, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/next-task.js b/mcp-server/src/tools/next-task.js index b21ad968..b5453b08 100644 --- a/mcp-server/src/tools/next-task.js +++ b/mcp-server/src/tools/next-task.js @@ -14,6 +14,7 @@ import { resolveTasksPath, resolveComplexityReportPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the nextTask tool with the MCP server @@ -34,11 +35,16 @@ export function registerNextTaskTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { log.info(`Finding next task with args: ${JSON.stringify(args)}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Resolve the path to tasks.json using new path utilities let tasksJsonPath; @@ -54,7 +60,10 @@ export function registerNextTaskTool(server) { // Resolve the path to complexity report (optional) let complexityReportPath; try { - complexityReportPath = resolveComplexityReportPath(args, session); + complexityReportPath = resolveComplexityReportPath( + { ...args, tag: resolvedTag }, + session + ); } catch (error) { log.error(`Error finding complexity report: ${error.message}`); // This is optional, so we don't fail the operation @@ -65,7 +74,8 @@ export function registerNextTaskTool(server) { { tasksJsonPath: tasksJsonPath, reportPath: complexityReportPath, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/parse-prd.js b/mcp-server/src/tools/parse-prd.js index db11f4c0..6161d8f1 100644 --- a/mcp-server/src/tools/parse-prd.js +++ b/mcp-server/src/tools/parse-prd.js @@ -15,6 +15,7 @@ import { TASKMASTER_DOCS_DIR, TASKMASTER_TASKS_FILE } from '../../../src/constants/paths.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the parse_prd tool @@ -24,6 +25,7 @@ export function registerParsePRDTool(server) { server.addTool({ name: 'parse_prd', description: `Parse a Product Requirements Document (PRD) text file to automatically generate initial tasks. Reinitializing the project is not necessary to run this tool. It is recommended to run parse-prd after initializing the project and creating/importing a prd.txt file in the project root's ${TASKMASTER_DOCS_DIR} directory.`, + parameters: z.object({ input: z .string() @@ -33,6 +35,7 @@ export function registerParsePRDTool(server) { projectRoot: z .string() .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on'), output: z .string() .optional() @@ -63,7 +66,18 @@ export function registerParsePRDTool(server) { }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { - const result = await parsePRDDirect(args, log, { session }); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + const result = await parsePRDDirect( + { + ...args, + tag: resolvedTag + }, + log, + { session } + ); return handleApiResult( result, log, diff --git a/mcp-server/src/tools/remove-dependency.js b/mcp-server/src/tools/remove-dependency.js index 63fc767c..84c57462 100644 --- a/mcp-server/src/tools/remove-dependency.js +++ b/mcp-server/src/tools/remove-dependency.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { removeDependencyDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the removeDependency tool with the MCP server @@ -31,10 +32,15 @@ export function registerRemoveDependencyTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info( `Removing dependency for task ${args.id} from ${args.dependsOn} with args: ${JSON.stringify(args)}` ); @@ -57,7 +63,9 @@ export function registerRemoveDependencyTool(server) { { tasksJsonPath: tasksJsonPath, id: args.id, - dependsOn: args.dependsOn + dependsOn: args.dependsOn, + projectRoot: args.projectRoot, + tag: resolvedTag }, log ); diff --git a/mcp-server/src/tools/remove-subtask.js b/mcp-server/src/tools/remove-subtask.js index 4c3461bc..ae83e650 100644 --- a/mcp-server/src/tools/remove-subtask.js +++ b/mcp-server/src/tools/remove-subtask.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { removeSubtaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the removeSubtask tool with the MCP server @@ -44,10 +45,15 @@ export function registerRemoveSubtaskTool(server) { .describe('Skip regenerating task files'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info(`Removing subtask with args: ${JSON.stringify(args)}`); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) @@ -70,7 +76,8 @@ export function registerRemoveSubtaskTool(server) { id: args.id, convert: args.convert, skipGenerate: args.skipGenerate, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/remove-task.js b/mcp-server/src/tools/remove-task.js index c2b1c60c..93b2e8f6 100644 --- a/mcp-server/src/tools/remove-task.js +++ b/mcp-server/src/tools/remove-task.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { removeTaskDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the remove-task tool with the MCP server @@ -45,6 +46,11 @@ export function registerRemoveTaskTool(server) { try { log.info(`Removing task(s) with ID(s): ${args.id}`); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -66,7 +72,7 @@ export function registerRemoveTaskTool(server) { tasksJsonPath: tasksJsonPath, id: args.id, projectRoot: args.projectRoot, - tag: args.tag + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/research.js b/mcp-server/src/tools/research.js index 4e54b077..1fb61be9 100644 --- a/mcp-server/src/tools/research.js +++ b/mcp-server/src/tools/research.js @@ -10,6 +10,7 @@ import { withNormalizedProjectRoot } from './utils.js'; import { researchDirect } from '../core/task-master-core.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the research tool with the MCP server @@ -19,6 +20,7 @@ export function registerResearchTool(server) { server.addTool({ name: 'research', description: 'Perform AI-powered research queries with project context', + parameters: z.object({ query: z.string().describe('Research query/prompt (required)'), taskIds: z @@ -61,10 +63,15 @@ export function registerResearchTool(server) { ), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info( `Starting research with query: "${args.query.substring(0, 100)}${args.query.length > 100 ? '...' : ''}"` ); @@ -80,7 +87,8 @@ export function registerResearchTool(server) { detailLevel: args.detailLevel || 'medium', saveTo: args.saveTo, saveToFile: args.saveToFile || false, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/set-task-status.js b/mcp-server/src/tools/set-task-status.js index ad6edb9b..ee293fed 100644 --- a/mcp-server/src/tools/set-task-status.js +++ b/mcp-server/src/tools/set-task-status.js @@ -18,6 +18,7 @@ import { findComplexityReportPath } from '../core/utils/path-utils.js'; import { TASK_STATUS_OPTIONS } from '../../../src/constants/task-status.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the setTaskStatus tool with the MCP server @@ -52,8 +53,15 @@ export function registerSetTaskStatusTool(server) { }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { - log.info(`Setting status of task(s) ${args.id} to: ${args.status}`); - + log.info( + `Setting status of task(s) ${args.id} to: ${args.status} ${ + args.tag ? `in tag: ${args.tag}` : 'in current tag' + }` + ); + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) let tasksJsonPath; try { @@ -73,7 +81,8 @@ export function registerSetTaskStatusTool(server) { complexityReportPath = findComplexityReportPath( { projectRoot: args.projectRoot, - complexityReport: args.complexityReport + complexityReport: args.complexityReport, + tag: resolvedTag }, log ); @@ -88,7 +97,7 @@ export function registerSetTaskStatusTool(server) { status: args.status, complexityReportPath, projectRoot: args.projectRoot, - tag: args.tag + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/update-subtask.js b/mcp-server/src/tools/update-subtask.js index 867bf9e5..2624f4d1 100644 --- a/mcp-server/src/tools/update-subtask.js +++ b/mcp-server/src/tools/update-subtask.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { updateSubtaskByIdDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the update-subtask tool with the MCP server @@ -35,11 +36,17 @@ export function registerUpdateSubtaskTool(server) { file: z.string().optional().describe('Absolute path to the tasks file'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { const toolName = 'update_subtask'; + try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info(`Updating subtask with args: ${JSON.stringify(args)}`); let tasksJsonPath; @@ -61,7 +68,8 @@ export function registerUpdateSubtaskTool(server) { id: args.id, prompt: args.prompt, research: args.research, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/update-task.js b/mcp-server/src/tools/update-task.js index a45476eb..2fb1feb7 100644 --- a/mcp-server/src/tools/update-task.js +++ b/mcp-server/src/tools/update-task.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { updateTaskByIdDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the update-task tool with the MCP server @@ -43,11 +44,16 @@ export function registerUpdateTaskTool(server) { file: z.string().optional().describe('Absolute path to the tasks file'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { const toolName = 'update_task'; try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info( `Executing ${toolName} tool with args: ${JSON.stringify(args)}` ); @@ -74,7 +80,8 @@ export function registerUpdateTaskTool(server) { prompt: args.prompt, research: args.research, append: args.append, - projectRoot: args.projectRoot + projectRoot: args.projectRoot, + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/update.js b/mcp-server/src/tools/update.js index 475eb338..f81a3755 100644 --- a/mcp-server/src/tools/update.js +++ b/mcp-server/src/tools/update.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { updateTasksDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the update tool with the MCP server @@ -50,6 +51,11 @@ export function registerUpdateTool(server) { const toolName = 'update'; const { from, prompt, research, file, projectRoot, tag } = args; + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); + try { log.info( `Executing ${toolName} tool with normalized root: ${projectRoot}` @@ -73,7 +79,7 @@ export function registerUpdateTool(server) { prompt: prompt, research: research, projectRoot: projectRoot, - tag: tag + tag: resolvedTag }, log, { session } diff --git a/mcp-server/src/tools/validate-dependencies.js b/mcp-server/src/tools/validate-dependencies.js index 10a9f638..4b96c12b 100644 --- a/mcp-server/src/tools/validate-dependencies.js +++ b/mcp-server/src/tools/validate-dependencies.js @@ -11,6 +11,7 @@ import { } from './utils.js'; import { validateDependenciesDirect } from '../core/task-master-core.js'; import { findTasksPath } from '../core/utils/path-utils.js'; +import { resolveTag } from '../../../scripts/modules/utils.js'; /** * Register the validateDependencies tool with the MCP server @@ -25,10 +26,15 @@ export function registerValidateDependenciesTool(server) { file: z.string().optional().describe('Absolute path to the tasks file'), projectRoot: z .string() - .describe('The directory of the project. Must be an absolute path.') + .describe('The directory of the project. Must be an absolute path.'), + tag: z.string().optional().describe('Tag context to operate on') }), execute: withNormalizedProjectRoot(async (args, { log, session }) => { try { + const resolvedTag = resolveTag({ + projectRoot: args.projectRoot, + tag: args.tag + }); log.info(`Validating dependencies with args: ${JSON.stringify(args)}`); // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) @@ -47,7 +53,9 @@ export function registerValidateDependenciesTool(server) { const result = await validateDependenciesDirect( { - tasksJsonPath: tasksJsonPath + tasksJsonPath: tasksJsonPath, + projectRoot: args.projectRoot, + tag: resolvedTag }, log ); diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index f68d4706..d5f0e55e 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -826,7 +826,8 @@ function registerCommands(programInstance) { let taskMaster; try { const initOptions = { - prdPath: file || options.input || true + prdPath: file || options.input || true, + tag: options.tag }; // Only include tasksPath if output is explicitly specified if (options.output) { @@ -852,8 +853,7 @@ function registerCommands(programInstance) { const useAppend = append; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -966,7 +966,8 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const fromId = parseInt(options.from, 10); // Validation happens here @@ -976,8 +977,7 @@ function registerCommands(programInstance) { const tasksPath = taskMaster.getTasksPath(); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -1066,13 +1066,13 @@ function registerCommands(programInstance) { try { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const tasksPath = taskMaster.getTasksPath(); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -1238,13 +1238,13 @@ function registerCommands(programInstance) { try { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const tasksPath = taskMaster.getTasksPath(); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -1404,11 +1404,12 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const outputDir = options.output; - const tag = options.tag; + const tag = taskMaster.getCurrentTag(); console.log( chalk.blue(`Generating task files from: ${taskMaster.getTasksPath()}`) @@ -1444,12 +1445,12 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const taskId = options.id; const status = options.status; - const tag = options.tag; if (!taskId || !status) { console.error(chalk.red('Error: Both --id and --status are required')); @@ -1465,11 +1466,9 @@ function registerCommands(programInstance) { process.exit(1); } + const tag = taskMaster.getCurrentTag(); - // Resolve tag using standard pattern and show current tag context - const resolvedTag = - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; - displayCurrentTagIndicator(resolvedTag); + displayCurrentTagIndicator(tag); console.log( chalk.blue(`Setting status of task(s) ${taskId} to: ${status}`) @@ -1501,7 +1500,8 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const initOptions = { - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }; // Only pass complexityReportPath if user provided a custom path @@ -1513,9 +1513,7 @@ function registerCommands(programInstance) { const statusFilter = options.status; const withSubtasks = options.withSubtasks || false; - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; - + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -1535,8 +1533,7 @@ function registerCommands(programInstance) { taskMaster.getComplexityReportPath(), withSubtasks, 'text', - tag, - { projectRoot: taskMaster.getProjectRoot() } + { projectRoot: taskMaster.getProjectRoot(), tag } ); }); @@ -1565,18 +1562,29 @@ function registerCommands(programInstance) { 'Path to the tasks file (relative to project root)', TASKMASTER_TASKS_FILE // Allow file override ) // Allow file override + .option( + '-cr, --complexity-report ', + 'Path to the report file', + COMPLEXITY_REPORT_FILE + ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); - const tag = options.tag; + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + + if (options.complexityReport) { + initOptions.complexityReportPath = options.complexityReport; + } + + const taskMaster = initTaskMaster(initOptions); + + const tag = taskMaster.getCurrentTag(); // Show current tag context - displayCurrentTagIndicator( - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); if (options.all) { // --- Handle expand --all --- @@ -1589,7 +1597,11 @@ function registerCommands(programInstance) { options.research, // Pass research flag options.prompt, // Pass additional context options.force, // Pass force flag - { projectRoot: taskMaster.getProjectRoot(), tag } // Pass context with projectRoot and tag + { + projectRoot: taskMaster.getProjectRoot(), + tag, + complexityReportPath: taskMaster.getComplexityReportPath() + } // Pass context with projectRoot and tag // outputFormat defaults to 'text' in expandAllTasks for CLI ); } catch (error) { @@ -1616,7 +1628,11 @@ function registerCommands(programInstance) { options.num, options.research, options.prompt, - { projectRoot: taskMaster.getProjectRoot(), tag }, // Pass context with projectRoot and tag + { + projectRoot: taskMaster.getProjectRoot(), + tag, + complexityReportPath: taskMaster.getComplexityReportPath() + }, // Pass context with projectRoot and tag options.force // Pass the force flag down ); // expandTask logs its own success/failure for single task @@ -1669,34 +1685,28 @@ function registerCommands(programInstance) { .action(async (options) => { // Initialize TaskMaster const initOptions = { - tasksPath: options.file || true // Tasks file is required to analyze + tasksPath: options.file || true, // Tasks file is required to analyze + tag: options.tag }; // Only include complexityReportPath if output is explicitly specified if (options.output) { initOptions.complexityReportPath = options.output; } + const taskMaster = initTaskMaster(initOptions); - const tag = options.tag; const modelOverride = options.model; const thresholdScore = parseFloat(options.threshold); const useResearch = options.research || false; // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const targetTag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(targetTag); - // Tag-aware output file naming: master -> task-complexity-report.json, other tags -> task-complexity-report_tagname.json - const baseOutputPath = - taskMaster.getComplexityReportPath() || - path.join(taskMaster.getProjectRoot(), COMPLEXITY_REPORT_FILE); - const outputPath = - options.output === COMPLEXITY_REPORT_FILE && targetTag !== 'master' - ? baseOutputPath.replace('.json', `_${targetTag}.json`) - : options.output || baseOutputPath; + // Use user's explicit output path if provided, otherwise use tag-aware default + const outputPath = taskMaster.getComplexityReportPath(); console.log( chalk.blue( @@ -1777,9 +1787,12 @@ function registerCommands(programInstance) { .option('--tag ', 'Specify tag context for task operations') .action(async (prompt, options) => { // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + + const taskMaster = initTaskMaster(initOptions); // Parameter validation if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) { @@ -1879,8 +1892,7 @@ function registerCommands(programInstance) { } } - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2113,17 +2125,17 @@ ${result.result} .action(async (options) => { const taskIds = options.id; const all = options.all; - const tag = options.tag; // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); + const tag = taskMaster.getCurrentTag(); + // Show current tag context - displayCurrentTagIndicator( - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); if (!taskIds && !all) { console.error( @@ -2219,15 +2231,16 @@ ${result.result} // Correctly determine projectRoot // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const projectRoot = taskMaster.getProjectRoot(); + const tag = taskMaster.getCurrentTag(); + // Show current tag context - displayCurrentTagIndicator( - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); let manualTaskData = null; if (isManualCreation) { @@ -2263,7 +2276,7 @@ ${result.result} const context = { projectRoot, - tag: options.tag, + tag, commandName: 'add-task', outputType: 'cli' }; @@ -2309,22 +2322,36 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tag = options.tag; + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + + if (options.report && options.report !== COMPLEXITY_REPORT_FILE) { + initOptions.complexityReportPath = options.report; + } // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag, + complexityReportPath: options.report || false }); + const tag = taskMaster.getCurrentTag(); + + const context = { + projectRoot: taskMaster.getProjectRoot(), + tag + }; + // Show current tag context - displayCurrentTagIndicator( - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); await displayNextTask( taskMaster.getTasksPath(), taskMaster.getComplexityReportPath(), - { projectRoot: taskMaster.getProjectRoot(), tag } + context ); }); @@ -2364,12 +2391,10 @@ ${result.result} const idArg = taskId || options.id; const statusFilter = options.status; - const tag = options.tag; + const tag = taskMaster.getCurrentTag(); // Show current tag context - displayCurrentTagIndicator( - tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' - ); + displayCurrentTagIndicator(tag); if (!idArg) { console.error(chalk.red('Error: Please provide a task ID')); @@ -2398,8 +2423,7 @@ ${result.result} taskIds[0], taskMaster.getComplexityReportPath(), statusFilter, - tag, - { projectRoot: taskMaster.getProjectRoot() } + { projectRoot: taskMaster.getProjectRoot(), tag } ); } }); @@ -2417,17 +2441,19 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const taskMaster = initTaskMaster(initOptions); const taskId = options.id; const dependencyId = options.dependsOn; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2472,17 +2498,19 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const taskMaster = initTaskMaster(initOptions); const taskId = options.id; const dependencyId = options.dependsOn; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2527,14 +2555,16 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const taskMaster = initTaskMaster(initOptions); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2555,14 +2585,16 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { + const initOptions = { + tasksPath: options.file || true, + tag: options.tag + }; + // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true - }); + const taskMaster = initTaskMaster(initOptions); // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2583,26 +2615,21 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - // Initialize TaskMaster - const taskMaster = initTaskMaster({ - complexityReportPath: options.file || true - }); + const initOptions = { + tag: options.tag + }; - // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + if (options.file && options.file !== COMPLEXITY_REPORT_FILE) { + initOptions.complexityReportPath = options.file; + } + + // Initialize TaskMaster + const taskMaster = initTaskMaster(initOptions); // Show current tag context - displayCurrentTagIndicator(targetTag); + displayCurrentTagIndicator(taskMaster.getCurrentTag()); - // Tag-aware report file naming: master -> task-complexity-report.json, other tags -> task-complexity-report_tagname.json - const baseReportPath = taskMaster.getComplexityReportPath(); - const reportPath = - options.file === COMPLEXITY_REPORT_FILE && targetTag !== 'master' - ? baseReportPath.replace('.json', `_${targetTag}.json`) - : baseReportPath; - - await displayComplexityReport(reportPath); + await displayComplexityReport(taskMaster.getComplexityReportPath()); }); // add-subtask command @@ -2632,7 +2659,8 @@ ${result.result} .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const parentId = options.parent; @@ -2640,8 +2668,7 @@ ${result.result} const generateFiles = !options.skipGenerate; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -2816,13 +2843,14 @@ ${result.result} .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const subtaskIds = options.id; const convertToTask = options.convert || false; const generateFiles = !options.skipGenerate; - const tag = options.tag; + const tag = taskMaster.getCurrentTag(); if (!subtaskIds) { console.error( @@ -3117,14 +3145,14 @@ ${result.result} .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const taskIdsString = options.id; // Resolve tag using standard pattern - const tag = - options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; + const tag = taskMaster.getCurrentTag(); // Show current tag context displayCurrentTagIndicator(tag); @@ -3768,12 +3796,13 @@ Examples: .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const sourceId = options.from; const destinationId = options.to; - const tag = options.tag; + const tag = taskMaster.getCurrentTag(); if (!sourceId || !destinationId) { console.error( @@ -4201,15 +4230,19 @@ Examples: '-s, --status ', 'Show only tasks matching this status (e.g., pending, done)' ) + .option('-t, --tag ', 'Tag to use for the task list (default: master)') .action(async (options) => { // Initialize TaskMaster const taskMaster = initTaskMaster({ - tasksPath: options.file || true + tasksPath: options.file || true, + tag: options.tag }); const withSubtasks = options.withSubtasks || false; const status = options.status || null; + const tag = taskMaster.getCurrentTag(); + console.log( chalk.blue( `📝 Syncing tasks to README.md${withSubtasks ? ' (with subtasks)' : ''}${status ? ` (status: ${status})` : ''}...` @@ -4219,7 +4252,8 @@ Examples: const success = await syncTasksToReadme(taskMaster.getProjectRoot(), { withSubtasks, status, - tasksPath: taskMaster.getTasksPath() + tasksPath: taskMaster.getTasksPath(), + tag }); if (!success) { @@ -4941,6 +4975,33 @@ async function runCLI(argv = process.argv) { } } +/** + * Resolve the final complexity-report path. + * Rules: + * 1. If caller passes --output, always respect it. + * 2. If no explicit output AND tag === 'master' → default report file + * 3. If no explicit output AND tag !== 'master' → append _.json + * + * @param {string|undefined} outputOpt --output value from CLI (may be undefined) + * @param {string} targetTag resolved tag (defaults to 'master') + * @param {string} projectRoot absolute project root + * @returns {string} absolute path for the report + */ +export function resolveComplexityReportPath({ + projectRoot, + tag = 'master', + output // may be undefined +}) { + // 1. user knows best + if (output) { + return path.isAbsolute(output) ? output : path.join(projectRoot, output); + } + + // 2. default naming + const base = path.join(projectRoot, COMPLEXITY_REPORT_FILE); + return tag !== 'master' ? base.replace('.json', `_${tag}.json`) : base; +} + export { registerCommands, setupCLI, diff --git a/scripts/modules/dependency-manager.js b/scripts/modules/dependency-manager.js index b2f005ff..4f43f894 100644 --- a/scripts/modules/dependency-manager.js +++ b/scripts/modules/dependency-manager.js @@ -27,6 +27,8 @@ import { generateTaskFiles } from './task-manager.js'; * @param {number|string} taskId - ID of the task to add dependency to * @param {number|string} dependencyId - ID of the task to add as dependency * @param {Object} context - Context object containing projectRoot and tag information + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task */ async function addDependency(tasksPath, taskId, dependencyId, context = {}) { log('info', `Adding dependency ${dependencyId} to task ${taskId}...`); @@ -214,6 +216,8 @@ async function addDependency(tasksPath, taskId, dependencyId, context = {}) { * @param {number|string} taskId - ID of the task to remove dependency from * @param {number|string} dependencyId - ID of the task to remove as dependency * @param {Object} context - Context object containing projectRoot and tag information + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task */ async function removeDependency(tasksPath, taskId, dependencyId, context = {}) { log('info', `Removing dependency ${dependencyId} from task ${taskId}...`); diff --git a/scripts/modules/sync-readme.js b/scripts/modules/sync-readme.js index a13083ca..619954d3 100644 --- a/scripts/modules/sync-readme.js +++ b/scripts/modules/sync-readme.js @@ -91,11 +91,12 @@ function createEndMarker() { * @param {string} options.status - Filter by status (e.g., 'pending', 'done') * @param {string} options.tasksPath - Custom path to tasks.json * @returns {boolean} - True if sync was successful, false otherwise + * TODO: Add tag support - this is not currently supported how we want to handle this - Parthy */ export async function syncTasksToReadme(projectRoot = null, options = {}) { try { const actualProjectRoot = projectRoot || findProjectRoot() || '.'; - const { withSubtasks = false, status, tasksPath } = options; + const { withSubtasks = false, status, tasksPath, tag } = options; // Get current tasks using the list-tasks functionality with markdown-readme format const tasksOutput = await listTasks( @@ -104,7 +105,8 @@ export async function syncTasksToReadme(projectRoot = null, options = {}) { status, null, withSubtasks, - 'markdown-readme' + 'markdown-readme', + { projectRoot, tag } ); if (!tasksOutput) { diff --git a/scripts/modules/task-manager/add-subtask.js b/scripts/modules/task-manager/add-subtask.js index b48a8dc9..be12c853 100644 --- a/scripts/modules/task-manager/add-subtask.js +++ b/scripts/modules/task-manager/add-subtask.js @@ -12,6 +12,8 @@ import generateTaskFiles from './generate-task-files.js'; * @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 + * @param {string} context.projectRoot - Project root path + * @param {string} context.tag - Tag for the task * @returns {Object} The newly created or converted subtask */ async function addSubtask( @@ -22,13 +24,12 @@ async function addSubtask( generateFiles = true, context = {} ) { + const { projectRoot, tag } = context; try { log('info', `Adding subtask to parent task ${parentId}...`); - const currentTag = - context.tag || getCurrentTag(context.projectRoot) || 'master'; // Read the existing tasks with proper context - const data = readJSON(tasksPath, context.projectRoot, currentTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error(`Invalid or missing tasks file at ${tasksPath}`); } @@ -139,7 +140,7 @@ async function addSubtask( } // Write the updated tasks back to the file with proper context - writeJSON(tasksPath, data, context.projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); // Generate task files if requested if (generateFiles) { diff --git a/scripts/modules/task-manager/add-task.js b/scripts/modules/task-manager/add-task.js index 13db6968..324dc39e 100644 --- a/scripts/modules/task-manager/add-task.js +++ b/scripts/modules/task-manager/add-task.js @@ -22,8 +22,7 @@ import { truncate, ensureTagMetadata, performCompleteTagMigration, - markMigrationForNotice, - getCurrentTag + markMigrationForNotice } from '../utils.js'; import { generateObjectService } from '../ai-services-unified.js'; import { getDefaultPriority } from '../config-manager.js'; @@ -93,7 +92,7 @@ function getAllTasks(rawData) { * @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) + * @param {string} [context.tag] - Tag for the task (optional) * @returns {Promise} An object containing newTaskId and telemetryData */ async function addTask( @@ -104,10 +103,10 @@ async function addTask( context = {}, outputFormat = 'text', // Default to text for CLI manualTaskData = null, - useResearch = false, - tag = null + useResearch = false ) { - const { session, mcpLog, projectRoot, commandName, outputType } = context; + const { session, mcpLog, projectRoot, commandName, outputType, tag } = + context; const isMCP = !!mcpLog; // Create a consistent logFn object regardless of context @@ -224,7 +223,7 @@ async function addTask( try { // Read the existing tasks - IMPORTANT: Read the raw data without tag resolution - let rawData = readJSON(tasksPath, projectRoot); // No tag parameter + let rawData = readJSON(tasksPath, projectRoot, tag); // No tag parameter // Handle the case where readJSON returns resolved data with _rawTaggedData if (rawData && rawData._rawTaggedData) { @@ -279,8 +278,7 @@ async function addTask( } // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = - tag || context.tag || getCurrentTag(projectRoot) || 'master'; + const targetTag = tag; // Ensure the target tag exists if (!rawData[targetTag]) { @@ -389,7 +387,7 @@ async function addTask( report(`Generating task data with AI with prompt:\n${prompt}`, 'info'); // --- Use the new ContextGatherer --- - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const gatherResult = await contextGatherer.gather({ semanticQuery: prompt, dependencyTasks: numericDependencies, diff --git a/scripts/modules/task-manager/analyze-task-complexity.js b/scripts/modules/task-manager/analyze-task-complexity.js index df5c65c4..35ceddd8 100644 --- a/scripts/modules/task-manager/analyze-task-complexity.js +++ b/scripts/modules/task-manager/analyze-task-complexity.js @@ -19,6 +19,7 @@ import { COMPLEXITY_REPORT_FILE, LEGACY_TASKS_FILE } from '../../../src/constants/paths.js'; +import { resolveComplexityReportOutputPath } from '../../../src/utils/path-utils.js'; import { ContextGatherer } from '../utils/contextGatherer.js'; import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; import { flattenTasksWithSubtasks } from '../utils.js'; @@ -71,6 +72,7 @@ Do not include any explanatory text, markdown formatting, or code block markers * @param {string|number} [options.threshold] - Complexity threshold * @param {boolean} [options.research] - Use research role * @param {string} [options.projectRoot] - Project root path (for MCP/env fallback). + * @param {string} [options.tag] - Tag for the task * @param {string} [options.id] - Comma-separated list of task IDs to analyze specifically * @param {number} [options.from] - Starting task ID in a range to analyze * @param {number} [options.to] - Ending task ID in a range to analyze @@ -84,7 +86,6 @@ Do not include any explanatory text, markdown formatting, or code block markers async function analyzeTaskComplexity(options, context = {}) { const { session, mcpLog } = context; const tasksPath = options.file || LEGACY_TASKS_FILE; - const outputPath = options.output || COMPLEXITY_REPORT_FILE; const thresholdScore = parseFloat(options.threshold || '5'); const useResearch = options.research || false; const projectRoot = options.projectRoot; @@ -109,6 +110,13 @@ async function analyzeTaskComplexity(options, context = {}) { } }; + // Resolve output path using tag-aware resolution + const outputPath = resolveComplexityReportOutputPath( + options.output, + { projectRoot, tag }, + reportLog + ); + if (outputFormat === 'text') { console.log( chalk.blue( @@ -220,7 +228,7 @@ async function analyzeTaskComplexity(options, context = {}) { let gatheredContext = ''; if (originalData && originalData.tasks.length > 0) { try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(originalData.tasks); const fuzzySearch = new FuzzyTaskSearch( allTasksFlat, @@ -535,7 +543,7 @@ async function analyzeTaskComplexity(options, context = {}) { } } - // Merge with existing report + // Merge with existing report - only keep entries from the current tag let finalComplexityAnalysis = []; if (existingReport && Array.isArray(existingReport.complexityAnalysis)) { @@ -544,10 +552,14 @@ async function analyzeTaskComplexity(options, context = {}) { complexityAnalysis.map((item) => item.taskId) ); - // Keep existing entries that weren't in this analysis run + // Keep existing entries that weren't in this analysis run AND belong to the current tag + // We determine tag membership by checking if the task ID exists in the current tag's tasks + const currentTagTaskIds = new Set(tasksData.tasks.map((t) => t.id)); const existingEntriesNotAnalyzed = existingReport.complexityAnalysis.filter( - (item) => !analyzedTaskIds.has(item.taskId) + (item) => + !analyzedTaskIds.has(item.taskId) && + currentTagTaskIds.has(item.taskId) // Only keep entries for tasks in current tag ); // Combine with new analysis @@ -557,7 +569,7 @@ async function analyzeTaskComplexity(options, context = {}) { ]; reportLog( - `Merged ${complexityAnalysis.length} new analyses with ${existingEntriesNotAnalyzed.length} existing entries`, + `Merged ${complexityAnalysis.length} new analyses with ${existingEntriesNotAnalyzed.length} existing entries from current tag`, 'info' ); } else { diff --git a/scripts/modules/task-manager/clear-subtasks.js b/scripts/modules/task-manager/clear-subtasks.js index 760b5581..db726123 100644 --- a/scripts/modules/task-manager/clear-subtasks.js +++ b/scripts/modules/task-manager/clear-subtasks.js @@ -11,6 +11,8 @@ import { displayBanner } from '../ui.js'; * @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 + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task */ function clearSubtasks(tasksPath, taskIds, context = {}) { const { projectRoot, tag } = context; diff --git a/scripts/modules/task-manager/expand-all-tasks.js b/scripts/modules/task-manager/expand-all-tasks.js index 8782fd44..8e5a2255 100644 --- a/scripts/modules/task-manager/expand-all-tasks.js +++ b/scripts/modules/task-manager/expand-all-tasks.js @@ -20,6 +20,8 @@ import boxen from 'boxen'; * @param {Object} context - Context object containing session and mcpLog. * @param {Object} [context.session] - Session object from MCP. * @param {Object} [context.mcpLog] - MCP logger object. + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). MCP calls should use 'json'. * @returns {Promise<{success: boolean, expandedCount: number, failedCount: number, skippedCount: number, tasksToExpand: number, telemetryData: Array}>} - Result summary. */ @@ -32,12 +34,7 @@ async function expandAllTasks( context = {}, outputFormat = 'text' // Assume text default for CLI ) { - const { - session, - mcpLog, - projectRoot: providedProjectRoot, - tag: contextTag - } = context; + const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context; const isMCPCall = !!mcpLog; // Determine if called from MCP const projectRoot = providedProjectRoot || findProjectRoot(); @@ -79,7 +76,7 @@ async function expandAllTasks( try { logger.info(`Reading tasks from ${tasksPath}`); - const data = readJSON(tasksPath, projectRoot, contextTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error(`Invalid tasks data in ${tasksPath}`); } @@ -129,7 +126,7 @@ async function expandAllTasks( numSubtasks, useResearch, additionalContext, - { ...context, projectRoot, tag: data.tag || contextTag }, // Pass the whole context object with projectRoot and resolved tag + { ...context, projectRoot, tag: data.tag || tag }, // Pass the whole context object with projectRoot and resolved tag force ); expandedCount++; diff --git a/scripts/modules/task-manager/expand-task.js b/scripts/modules/task-manager/expand-task.js index c471afa6..f8ba362d 100644 --- a/scripts/modules/task-manager/expand-task.js +++ b/scripts/modules/task-manager/expand-task.js @@ -290,6 +290,8 @@ function parseSubtasksFromText( * @param {Object} context - Context object containing session and mcpLog. * @param {Object} [context.session] - Session object from MCP. * @param {Object} [context.mcpLog] - MCP logger object. + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task * @param {boolean} [force=false] - If true, replace existing subtasks; otherwise, append. * @returns {Promise} The updated parent task object with new subtasks. * @throws {Error} If task not found, AI service fails, or parsing fails. @@ -303,7 +305,13 @@ async function expandTask( context = {}, force = false ) { - const { session, mcpLog, projectRoot: contextProjectRoot, tag } = context; + const { + session, + mcpLog, + projectRoot: contextProjectRoot, + tag, + complexityReportPath + } = context; const outputFormat = mcpLog ? 'json' : 'text'; // Determine projectRoot: Use from context if available, otherwise derive from tasksPath @@ -350,7 +358,7 @@ async function expandTask( // --- Context Gathering --- let gatheredContext = ''; try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(data.tasks); const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'expand-task'); const searchQuery = `${task.title} ${task.description}`; @@ -379,17 +387,10 @@ async function expandTask( // --- Complexity Report Integration --- let finalSubtaskCount; let complexityReasoningContext = ''; - - // Use tag-aware complexity report path - const complexityReportPath = getTagAwareFilePath( - COMPLEXITY_REPORT_FILE, - tag, - projectRoot - ); let taskAnalysis = null; logger.info( - `Looking for complexity report at: ${complexityReportPath}${tag && tag !== 'master' ? ` (tag-specific for '${tag}')` : ''}` + `Looking for complexity report at: ${complexityReportPath}${tag !== 'master' ? ` (tag-specific for '${tag}')` : ''}` ); try { diff --git a/scripts/modules/task-manager/generate-task-files.js b/scripts/modules/task-manager/generate-task-files.js index d5dba0d6..581e9ec7 100644 --- a/scripts/modules/task-manager/generate-task-files.js +++ b/scripts/modules/task-manager/generate-task-files.js @@ -12,16 +12,20 @@ import { getDebugFlag } from '../config-manager.js'; * @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, projectRoot, tag) + * @param {string} [options.projectRoot] - Project root path + * @param {string} [options.tag] - Tag for the task + * @param {Object} [options.mcpLog] - MCP logger object * @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode */ function generateTaskFiles(tasksPath, outputDir, options = {}) { try { const isMcpMode = !!options?.mcpLog; + const { projectRoot, tag } = options; // 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); + const resolvedData = readJSON(tasksPath, projectRoot, tag); if (!resolvedData) { throw new Error(`Could not read or parse tasks file: ${tasksPath}`); } @@ -29,13 +33,10 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { 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]; + const tagData = rawData[tag]; if (!tagData || !tagData.tasks) { - throw new Error( - `Tag '${targetTag}' not found or has no tasks in the data.` - ); + throw new Error(`Tag '${tag}' not found or has no tasks in the data.`); } const tasksForGeneration = tagData.tasks; @@ -46,15 +47,15 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { log( 'info', - `Preparing to regenerate ${tasksForGeneration.length} task files for tag '${targetTag}'` + `Preparing to regenerate ${tasksForGeneration.length} task files for tag '${tag}'` ); // 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 + projectRoot, + tag // Provide the current tag context for the operation ); const allTasksInTag = tagData.tasks; @@ -66,14 +67,14 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { const files = fs.readdirSync(outputDir); // 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$`); + const taggedFilePattern = new RegExp(`^task_(\\d+)_${tag}\\.txt$`); const orphanedFiles = files.filter((file) => { let match = null; let fileTaskId = null; // Check if file belongs to current tag - if (targetTag === 'master') { + if (tag === 'master') { match = file.match(masterFilePattern); if (match) { fileTaskId = parseInt(match[1], 10); @@ -94,7 +95,7 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { if (orphanedFiles.length > 0) { log( 'info', - `Found ${orphanedFiles.length} orphaned task files to remove for tag '${targetTag}'` + `Found ${orphanedFiles.length} orphaned task files to remove for tag '${tag}'` ); orphanedFiles.forEach((file) => { const filePath = path.join(outputDir, file); @@ -108,13 +109,13 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { } // Generate task files for the target tag - log('info', `Generating individual task files for tag '${targetTag}'...`); + log('info', `Generating individual task files for tag '${tag}'...`); tasksForGeneration.forEach((task) => { // Tag-aware file naming: master -> task_001.txt, other tags -> task_001_tagname.txt const taskFileName = - targetTag === 'master' + tag === 'master' ? `task_${task.id.toString().padStart(3, '0')}.txt` - : `task_${task.id.toString().padStart(3, '0')}_${targetTag}.txt`; + : `task_${task.id.toString().padStart(3, '0')}_${tag}.txt`; const taskPath = path.join(outputDir, taskFileName); @@ -174,7 +175,7 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { log( 'success', - `All ${tasksForGeneration.length} tasks for tag '${targetTag}' have been generated into '${outputDir}'.` + `All ${tasksForGeneration.length} tasks for tag '${tag}' have been generated into '${outputDir}'.` ); if (isMcpMode) { diff --git a/scripts/modules/task-manager/list-tasks.js b/scripts/modules/task-manager/list-tasks.js index e790fa62..718569dd 100644 --- a/scripts/modules/task-manager/list-tasks.js +++ b/scripts/modules/task-manager/list-tasks.js @@ -26,8 +26,9 @@ import { * @param {string} reportPath - Path to the complexity report * @param {boolean} withSubtasks - Whether to show subtasks * @param {string} outputFormat - Output format (text or json) - * @param {string} tag - Optional tag to override current tag resolution - * @param {Object} context - Optional context object containing projectRoot and other options + * @param {Object} context - Context object (required) + * @param {string} context.projectRoot - Project root path + * @param {string} context.tag - Tag for the task * @returns {Object} - Task list result for json format */ function listTasks( @@ -36,18 +37,18 @@ function listTasks( reportPath = null, withSubtasks = false, outputFormat = 'text', - tag = null, context = {} ) { + const { projectRoot, tag } = context; try { // 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}`); } // Add complexity scores to tasks if report exists + // `reportPath` is already tag-aware (resolved at the CLI boundary). const complexityReport = readComplexityReport(reportPath); // Apply complexity scores to tasks if (complexityReport && complexityReport.complexityAnalysis) { diff --git a/scripts/modules/task-manager/move-task.js b/scripts/modules/task-manager/move-task.js index 19538330..fc82112f 100644 --- a/scripts/modules/task-manager/move-task.js +++ b/scripts/modules/task-manager/move-task.js @@ -1,11 +1,5 @@ import path from 'path'; -import { - log, - readJSON, - writeJSON, - getCurrentTag, - setTasksForTag -} from '../utils.js'; +import { log, readJSON, writeJSON, setTasksForTag } from '../utils.js'; import { isTaskDependentOn } from '../task-manager.js'; import generateTaskFiles from './generate-task-files.js'; @@ -27,6 +21,7 @@ async function moveTask( generateFiles = false, options = {} ) { + const { projectRoot, tag } = options; // 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()); @@ -53,7 +48,10 @@ async function moveTask( // Generate files once at the end if requested if (generateFiles) { - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + await generateTaskFiles(tasksPath, path.dirname(tasksPath), { + tag: tag, + projectRoot: projectRoot + }); } return { @@ -64,7 +62,7 @@ async function moveTask( // Single move logic // Read the raw data without tag resolution to preserve tagged structure - let rawData = readJSON(tasksPath, options.projectRoot); // No tag parameter + let rawData = readJSON(tasksPath, projectRoot, tag); // Handle the case where readJSON returns resolved data with _rawTaggedData if (rawData && rawData._rawTaggedData) { @@ -72,27 +70,19 @@ async function moveTask( rawData = rawData._rawTaggedData; } - // Determine the current tag - const currentTag = - options.tag || getCurrentTag(options.projectRoot) || 'master'; - // Ensure the tag exists in the raw data - if ( - !rawData || - !rawData[currentTag] || - !Array.isArray(rawData[currentTag].tasks) - ) { + if (!rawData || !rawData[tag] || !Array.isArray(rawData[tag].tasks)) { throw new Error( - `Invalid tasks file or tag "${currentTag}" not found at ${tasksPath}` + `Invalid tasks file or tag "${tag}" not found at ${tasksPath}` ); } // Get the tasks for the current tag - const tasks = rawData[currentTag].tasks; + const tasks = rawData[tag].tasks; log( 'info', - `Moving task/subtask ${sourceId} to ${destinationId} (tag: ${currentTag})` + `Moving task/subtask ${sourceId} to ${destinationId} (tag: ${tag})` ); // Parse source and destination IDs @@ -116,14 +106,17 @@ async function moveTask( } // Update the data structure with the modified tasks - rawData[currentTag].tasks = tasks; + rawData[tag].tasks = tasks; // Always write the data object, never the _rawTaggedData directly // The writeJSON function will filter out _rawTaggedData automatically - writeJSON(tasksPath, rawData, options.projectRoot, currentTag); + writeJSON(tasksPath, rawData, options.projectRoot, tag); if (generateFiles) { - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + await generateTaskFiles(tasksPath, path.dirname(tasksPath), { + tag: tag, + projectRoot: projectRoot + }); } return result; diff --git a/scripts/modules/task-manager/parse-prd.js b/scripts/modules/task-manager/parse-prd.js index 46d8e1ee..dcaf567c 100644 --- a/scripts/modules/task-manager/parse-prd.js +++ b/scripts/modules/task-manager/parse-prd.js @@ -76,7 +76,7 @@ async function parsePRD(prdPath, tasksPath, numTasks, options = {}) { 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 targetTag = tag; const logFn = mcpLog ? mcpLog diff --git a/scripts/modules/task-manager/remove-subtask.js b/scripts/modules/task-manager/remove-subtask.js index 596326df..7a9639af 100644 --- a/scripts/modules/task-manager/remove-subtask.js +++ b/scripts/modules/task-manager/remove-subtask.js @@ -9,6 +9,8 @@ import generateTaskFiles from './generate-task-files.js'; * @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 + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task * @returns {Object|null} The removed subtask if convertToTask is true, otherwise null */ async function removeSubtask( @@ -18,11 +20,12 @@ async function removeSubtask( generateFiles = true, context = {} ) { + const { projectRoot, tag } = context; try { log('info', `Removing subtask ${subtaskId}...`); // Read the existing tasks with proper context - const data = readJSON(tasksPath, context.projectRoot, context.tag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error(`Invalid or missing tasks file at ${tasksPath}`); } @@ -103,7 +106,7 @@ async function removeSubtask( } // Write the updated tasks back to the file with proper context - writeJSON(tasksPath, data, context.projectRoot, context.tag); + writeJSON(tasksPath, data, projectRoot, tag); // Generate task files if requested if (generateFiles) { diff --git a/scripts/modules/task-manager/remove-task.js b/scripts/modules/task-manager/remove-task.js index 0406482e..ecce2d64 100644 --- a/scripts/modules/task-manager/remove-task.js +++ b/scripts/modules/task-manager/remove-task.js @@ -9,6 +9,8 @@ import taskExists from './task-exists.js'; * @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 + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task * @returns {Object} Result object with success status, messages, and removed task info */ async function removeTask(tasksPath, taskIds, context = {}) { @@ -32,7 +34,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { try { // Read the tasks file ONCE before the loop, preserving the full tagged structure - const rawData = readJSON(tasksPath, projectRoot); // Read raw data + const rawData = readJSON(tasksPath, projectRoot, tag); // Read raw data if (!rawData) { throw new Error(`Could not read tasks file at ${tasksPath}`); } @@ -40,19 +42,18 @@ async function removeTask(tasksPath, taskIds, context = {}) { // 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.`); + if (!fullTaggedData[tag] || !fullTaggedData[tag].tasks) { + throw new Error(`Tag '${tag}' not found or has no tasks.`); } - const tasks = fullTaggedData[currentTag].tasks; // Work with tasks from the correct tag + const tasks = fullTaggedData[tag].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(tasks, taskId)) { - const errorMsg = `Task with ID ${taskId} in tag '${currentTag}' not found or already removed.`; + const errorMsg = `Task with ID ${taskId} in tag '${tag}' 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 @@ -94,7 +95,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { parentTask.subtasks.splice(subtaskIndex, 1); results.messages.push( - `Successfully removed subtask ${taskId} from tag '${currentTag}'` + `Successfully removed subtask ${taskId} from tag '${tag}'` ); } // Handle main task removal @@ -102,9 +103,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { const taskIdNum = parseInt(taskId, 10); const taskIndex = tasks.findIndex((t) => t.id === taskIdNum); if (taskIndex === -1) { - throw new Error( - `Task with ID ${taskId} not found in tag '${currentTag}'` - ); + throw new Error(`Task with ID ${taskId} not found in tag '${tag}'`); } // Store the task info before removal @@ -116,7 +115,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { tasks.splice(taskIndex, 1); results.messages.push( - `Successfully removed task ${taskId} from tag '${currentTag}'` + `Successfully removed task ${taskId} from tag '${tag}'` ); } } catch (innerError) { @@ -139,7 +138,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { ); // Update the tasks in the current tag of the full data structure - fullTaggedData[currentTag].tasks = tasks; + fullTaggedData[tag].tasks = tasks; // Remove dependencies from all tags for (const tagName in fullTaggedData) { @@ -171,7 +170,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { } // Save the updated raw data structure - writeJSON(tasksPath, fullTaggedData, projectRoot, currentTag); + writeJSON(tasksPath, fullTaggedData, projectRoot, tag); // Delete task files AFTER saving tasks.json for (const taskIdNum of tasksToDeleteFiles) { @@ -196,7 +195,7 @@ async function removeTask(tasksPath, taskIds, context = {}) { try { await generateTaskFiles(tasksPath, path.dirname(tasksPath), { projectRoot, - tag: currentTag + tag }); results.messages.push('Task files regenerated successfully.'); } catch (genError) { diff --git a/scripts/modules/task-manager/research.js b/scripts/modules/task-manager/research.js index 9ab1adff..c84f7ff8 100644 --- a/scripts/modules/task-manager/research.js +++ b/scripts/modules/task-manager/research.js @@ -35,6 +35,7 @@ import { * @param {boolean} [options.includeProjectTree] - Include project file tree * @param {string} [options.detailLevel] - Detail level: 'low', 'medium', 'high' * @param {string} [options.projectRoot] - Project root directory + * @param {string} [options.tag] - Tag for the task * @param {boolean} [options.saveToFile] - Whether to save results to file (MCP mode) * @param {Object} [context] - Execution context * @param {Object} [context.session] - MCP session object @@ -59,6 +60,7 @@ async function performResearch( includeProjectTree = false, detailLevel = 'medium', projectRoot: providedProjectRoot, + tag, saveToFile = false } = options; @@ -101,7 +103,7 @@ async function performResearch( try { // Initialize context gatherer - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); // Auto-discover relevant tasks using fuzzy search to supplement provided tasks let finalTaskIds = [...taskIds]; // Start with explicitly provided tasks @@ -114,7 +116,7 @@ async function performResearch( 'tasks', 'tasks.json' ); - const tasksData = await readJSON(tasksPath, projectRoot); + const tasksData = await readJSON(tasksPath, projectRoot, tag); if (tasksData && tasksData.tasks && tasksData.tasks.length > 0) { // Flatten tasks to include subtasks for fuzzy search @@ -769,10 +771,7 @@ async function handleSaveToTask( return; } - // Validate ID exists - use tag from context - const { getCurrentTag } = await import('../utils.js'); - const tag = context.tag || getCurrentTag(projectRoot) || 'master'; - const data = readJSON(tasksPath, projectRoot, tag); + const data = readJSON(tasksPath, projectRoot, context.tag); if (!data || !data.tasks) { console.log(chalk.red('❌ No valid tasks found.')); return; @@ -806,7 +805,7 @@ async function handleSaveToTask( trimmedTaskId, conversationThread, false, // useResearch = false for simple append - { ...context, tag }, + context, 'text' ); @@ -833,7 +832,7 @@ async function handleSaveToTask( taskIdNum, conversationThread, false, // useResearch = false for simple append - { ...context, tag }, + context, 'text', true // appendMode = true ); diff --git a/scripts/modules/task-manager/set-task-status.js b/scripts/modules/task-manager/set-task-status.js index 218aad3d..18c18ced 100644 --- a/scripts/modules/task-manager/set-task-status.js +++ b/scripts/modules/task-manager/set-task-status.js @@ -7,7 +7,6 @@ import { readJSON, writeJSON, findTaskById, - getCurrentTag, ensureTagMetadata } from '../utils.js'; import { displayBanner } from '../ui.js'; @@ -26,16 +25,13 @@ import { * @param {string} taskIdInput - Task ID(s) to update * @param {string} newStatus - New status * @param {Object} options - Additional options (mcpLog for MCP mode, projectRoot for tag resolution) - * @param {string} tag - Optional tag to override current tag resolution + * @param {string} [options.projectRoot] - Project root path + * @param {string} [options.tag] - Optional tag to override current tag resolution + * @param {string} [options.mcpLog] - MCP logger object * @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode */ -async function setTaskStatus( - tasksPath, - taskIdInput, - newStatus, - options = {}, - tag = null -) { +async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) { + const { projectRoot, tag } = options; try { if (!isValidTaskStatus(newStatus)) { throw new Error( @@ -59,7 +55,7 @@ async function setTaskStatus( log('info', `Reading tasks from ${tasksPath}...`); // Read the raw data without tag resolution to preserve tagged structure - let rawData = readJSON(tasksPath, options.projectRoot); // No tag parameter + let rawData = readJSON(tasksPath, projectRoot, tag); // No tag parameter // Handle the case where readJSON returns resolved data with _rawTaggedData if (rawData && rawData._rawTaggedData) { @@ -67,24 +63,17 @@ async function setTaskStatus( 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) - ) { + if (!rawData || !rawData[tag] || !Array.isArray(rawData[tag].tasks)) { throw new Error( - `Invalid tasks file or tag "${currentTag}" not found at ${tasksPath}` + `Invalid tasks file or tag "${tag}" not found at ${tasksPath}` ); } // Get the tasks for the current tag const data = { - tasks: rawData[currentTag].tasks, - tag: currentTag, + tasks: rawData[tag].tasks, + tag, _rawTaggedData: rawData }; @@ -123,16 +112,16 @@ async function setTaskStatus( } // Update the raw data structure with the modified tasks - rawData[currentTag].tasks = data.tasks; + rawData[tag].tasks = data.tasks; // Ensure the tag has proper metadata - ensureTagMetadata(rawData[currentTag], { - description: `Tasks for ${currentTag} context` + ensureTagMetadata(rawData[tag], { + description: `Tasks for ${tag} context` }); // Write the updated raw data back to the file // The writeJSON function will automatically filter out _rawTaggedData - writeJSON(tasksPath, rawData, options.projectRoot, currentTag); + writeJSON(tasksPath, rawData, projectRoot, tag); // Validate dependencies after status update log('info', 'Validating dependencies after status update...'); diff --git a/scripts/modules/task-manager/update-subtask-by-id.js b/scripts/modules/task-manager/update-subtask-by-id.js index ee12a81d..41efb01f 100644 --- a/scripts/modules/task-manager/update-subtask-by-id.js +++ b/scripts/modules/task-manager/update-subtask-by-id.js @@ -17,8 +17,7 @@ import { truncate, isSilentMode, findProjectRoot, - flattenTasksWithSubtasks, - getCurrentTag + flattenTasksWithSubtasks } from '../utils.js'; import { generateTextService } from '../ai-services-unified.js'; import { getDebugFlag } from '../config-manager.js'; @@ -37,6 +36,7 @@ import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; * @param {Object} [context.session] - Session object from MCP server. * @param {Object} [context.mcpLog] - MCP logger object. * @param {string} [context.projectRoot] - Project root path (needed for AI service key resolution). + * @param {string} [context.tag] - Tag for the task * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). Automatically 'json' if mcpLog is present. * @returns {Promise} - The updated subtask or null if update failed. */ @@ -92,10 +92,7 @@ async function updateSubtaskById( throw new Error('Could not determine project root directory'); } - // Determine the tag to use - const currentTag = tag || getCurrentTag(projectRoot) || 'master'; - - const data = readJSON(tasksPath, projectRoot, currentTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error( `No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.` @@ -142,7 +139,7 @@ async function updateSubtaskById( // --- Context Gathering --- let gatheredContext = ''; try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(data.tasks); const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-subtask'); const searchQuery = `${parentTask.title} ${subtask.title} ${prompt}`; @@ -331,13 +328,17 @@ async function updateSubtaskById( if (outputFormat === 'text' && getDebugFlag(session)) { console.log('>>> DEBUG: About to call writeJSON with updated data...'); } - writeJSON(tasksPath, data, projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); if (outputFormat === 'text' && getDebugFlag(session)) { console.log('>>> DEBUG: writeJSON call completed.'); } report('success', `Successfully updated subtask ${subtaskId}`); - // await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + // Updated function call to make sure if uncommented it will generate the task files for the updated subtask based on the tag + // await generateTaskFiles(tasksPath, path.dirname(tasksPath), { + // tag: tag, + // projectRoot: projectRoot + // }); if (outputFormat === 'text') { if (loadingIndicator) { diff --git a/scripts/modules/task-manager/update-task-by-id.js b/scripts/modules/task-manager/update-task-by-id.js index 19603897..f5c90fa7 100644 --- a/scripts/modules/task-manager/update-task-by-id.js +++ b/scripts/modules/task-manager/update-task-by-id.js @@ -12,8 +12,7 @@ import { truncate, isSilentMode, flattenTasksWithSubtasks, - findProjectRoot, - getCurrentTag + findProjectRoot } from '../utils.js'; import { @@ -262,6 +261,7 @@ function parseUpdatedTaskFromText(text, expectedTaskId, logFn, isMCP) { * @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} [context.tag] - Tag for the task * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). * @param {boolean} [appendMode=false] - If true, append to details instead of full update. * @returns {Promise} - The updated task or null if update failed. @@ -320,11 +320,8 @@ async function updateTaskById( throw new Error('Could not determine project root directory'); } - // Determine the tag to use - const currentTag = tag || getCurrentTag(projectRoot) || 'master'; - // --- Task Loading and Status Check (Keep existing) --- - const data = readJSON(tasksPath, projectRoot, currentTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) throw new Error(`No valid tasks found in ${tasksPath}.`); const taskIndex = data.tasks.findIndex((task) => task.id === taskId); @@ -364,7 +361,7 @@ async function updateTaskById( // --- Context Gathering --- let gatheredContext = ''; try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(data.tasks); const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-task'); const searchQuery = `${taskToUpdate.title} ${taskToUpdate.description} ${prompt}`; @@ -559,7 +556,7 @@ async function updateTaskById( // Write the updated task back to file data.tasks[taskIndex] = taskToUpdate; - writeJSON(tasksPath, data, projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); report('success', `Successfully appended to task ${taskId}`); // Display success message for CLI @@ -704,7 +701,7 @@ async function updateTaskById( // --- End Update Task Data --- // --- Write File and Generate (Unchanged) --- - writeJSON(tasksPath, data, projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); report('success', `Successfully updated task ${taskId}`); // await generateTaskFiles(tasksPath, path.dirname(tasksPath)); // --- End Write File --- diff --git a/scripts/modules/task-manager/update-tasks.js b/scripts/modules/task-manager/update-tasks.js index 43b854b2..726872ee 100644 --- a/scripts/modules/task-manager/update-tasks.js +++ b/scripts/modules/task-manager/update-tasks.js @@ -9,8 +9,7 @@ import { readJSON, writeJSON, truncate, - isSilentMode, - getCurrentTag + isSilentMode } from '../utils.js'; import { @@ -234,8 +233,8 @@ function parseUpdatedTasksFromText(text, expectedCount, logFn, isMCP) { * @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.tag] - Tag for the task * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). - * @param {string} [tag=null] - Tag associated with the tasks. */ async function updateTasks( tasksPath, @@ -269,11 +268,8 @@ async function updateTasks( throw new Error('Could not determine project root directory'); } - // Determine the current tag - prioritize explicit tag, then context.tag, then current tag - const currentTag = tag || getCurrentTag(projectRoot) || 'master'; - // --- Task Loading/Filtering (Updated to pass projectRoot and tag) --- - const data = readJSON(tasksPath, projectRoot, currentTag); + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) throw new Error(`No valid tasks found in ${tasksPath}`); const tasksToUpdate = data.tasks.filter( @@ -292,7 +288,7 @@ async function updateTasks( // --- Context Gathering --- let gatheredContext = ''; try { - const contextGatherer = new ContextGatherer(projectRoot); + const contextGatherer = new ContextGatherer(projectRoot, tag); const allTasksFlat = flattenTasksWithSubtasks(data.tasks); const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update'); const searchResults = fuzzySearch.findRelevantTasks(prompt, { @@ -478,7 +474,7 @@ async function updateTasks( ); // Fix: Pass projectRoot and currentTag to writeJSON - writeJSON(tasksPath, data, projectRoot, currentTag); + writeJSON(tasksPath, data, projectRoot, tag); if (isMCP) logFn.info( `Successfully updated ${actualUpdateCount} tasks in ${tasksPath}` diff --git a/scripts/modules/ui.js b/scripts/modules/ui.js index 7a6737ba..ad417ec1 100644 --- a/scripts/modules/ui.js +++ b/scripts/modules/ui.js @@ -1197,18 +1197,18 @@ async function displayNextTask( * @param {string|number} taskId - The ID of the task to display * @param {string} complexityReportPath - Path to the complexity report file * @param {string} [statusFilter] - Optional status to filter subtasks by - * @param {string} tag - Optional tag to override current tag resolution + * @param {object} context - Context object containing projectRoot and tag + * @param {string} context.projectRoot - Project root path + * @param {string} context.tag - Tag for the task */ async function displayTaskById( tasksPath, taskId, complexityReportPath = null, statusFilter = null, - tag = null, context = {} ) { - // Extract projectRoot from context - const projectRoot = context.projectRoot || null; + const { projectRoot, tag } = context; // Read the tasks file with proper projectRoot for tag resolution const data = readJSON(tasksPath, projectRoot, tag); @@ -2251,7 +2251,9 @@ function displayAiUsageSummary(telemetryData, outputType = 'cli') { * @param {Array} taskIds - Array of task IDs to display * @param {string} complexityReportPath - Path to complexity report * @param {string} statusFilter - Optional status filter for subtasks - * @param {Object} context - Optional context object containing projectRoot and tag + * @param {Object} context - Context object containing projectRoot and tag + * @param {string} [context.projectRoot] - Project root path + * @param {string} [context.tag] - Tag for the task */ async function displayMultipleTasksSummary( tasksPath, @@ -2602,7 +2604,6 @@ async function displayMultipleTasksSummary( choice.trim(), complexityReportPath, statusFilter, - tag, context ); } diff --git a/scripts/modules/utils.js b/scripts/modules/utils.js index 1f99ea07..e8a92dd9 100644 --- a/scripts/modules/utils.js +++ b/scripts/modules/utils.js @@ -1190,6 +1190,7 @@ function aggregateTelemetry(telemetryArray, overallCommandName) { } /** + * @deprecated Use TaskMaster.getCurrentTag() instead * Gets the current tag from state.json or falls back to defaultTag from config * @param {string} projectRoot - The project root directory (required) * @returns {string} The current tag name diff --git a/scripts/modules/utils/contextGatherer.js b/scripts/modules/utils/contextGatherer.js index 1a826dec..6848f067 100644 --- a/scripts/modules/utils/contextGatherer.js +++ b/scripts/modules/utils/contextGatherer.js @@ -21,7 +21,7 @@ const { encode } = pkg; * Context Gatherer class for collecting and formatting context from various sources */ export class ContextGatherer { - constructor(projectRoot) { + constructor(projectRoot, tag) { this.projectRoot = projectRoot; this.tasksPath = path.join( projectRoot, @@ -29,12 +29,13 @@ export class ContextGatherer { 'tasks', 'tasks.json' ); + this.tag = tag; this.allTasks = this._loadAllTasks(); } _loadAllTasks() { try { - const data = readJSON(this.tasksPath, this.projectRoot); + const data = readJSON(this.tasksPath, this.projectRoot, this.tag); const tasks = data?.tasks || []; return tasks; } catch (error) { @@ -958,10 +959,15 @@ export class ContextGatherer { /** * Factory function to create a context gatherer instance * @param {string} projectRoot - Project root directory + * @param {string} tag - Tag for the task * @returns {ContextGatherer} Context gatherer instance + * @throws {Error} If tag is not provided */ -export function createContextGatherer(projectRoot) { - return new ContextGatherer(projectRoot); +export function createContextGatherer(projectRoot, tag) { + if (!tag) { + throw new Error('Tag is required'); + } + return new ContextGatherer(projectRoot, tag); } export default ContextGatherer; diff --git a/src/task-master.js b/src/task-master.js index 3e92a4ca..51be8140 100644 --- a/src/task-master.js +++ b/src/task-master.js @@ -14,7 +14,8 @@ import { TASKMASTER_DOCS_DIR, TASKMASTER_REPORTS_DIR, TASKMASTER_CONFIG_FILE, - LEGACY_CONFIG_FILE + LEGACY_CONFIG_FILE, + COMPLEXITY_REPORT_FILE } from './constants/paths.js'; /** @@ -23,13 +24,16 @@ import { */ export class TaskMaster { #paths; + #tag; /** * The constructor is intended to be used only by the initTaskMaster factory function. * @param {object} paths - A pre-resolved object of all application paths. + * @param {string|undefined} tag - The current tag. */ - constructor(paths) { + constructor(paths, tag) { this.#paths = Object.freeze({ ...paths }); + this.#tag = tag; } /** @@ -64,7 +68,19 @@ export class TaskMaster { * @returns {string|null} The absolute path to the complexity report. */ getComplexityReportPath() { - return this.#paths.complexityReportPath; + if (this.#paths.complexityReportPath) { + return this.#paths.complexityReportPath; + } + + const complexityReportFile = + this.getCurrentTag() !== 'master' + ? COMPLEXITY_REPORT_FILE.replace( + '.json', + `_${this.getCurrentTag()}.json` + ) + : COMPLEXITY_REPORT_FILE; + + return path.join(this.#paths.projectRoot, complexityReportFile); } /** @@ -87,6 +103,45 @@ export class TaskMaster { getAllPaths() { return this.#paths; } + + /** + * Gets the current tag from state.json or falls back to defaultTag from config + * @returns {string} The current tag name + */ + getCurrentTag() { + if (this.#tag) { + return this.#tag; + } + + try { + // Try to read current tag from state.json using fs directly + if (fs.existsSync(this.#paths.statePath)) { + const rawState = fs.readFileSync(this.#paths.statePath, 'utf8'); + const stateData = JSON.parse(rawState); + if (stateData && stateData.currentTag) { + return stateData.currentTag; + } + } + } catch (error) { + // Ignore errors, fall back to default + } + + // Fall back to defaultTag from config using fs directly + try { + if (fs.existsSync(this.#paths.configPath)) { + const rawConfig = fs.readFileSync(this.#paths.configPath, 'utf8'); + const configData = JSON.parse(rawConfig); + if (configData && configData.global && configData.global.defaultTag) { + return configData.global.defaultTag; + } + } + } catch (error) { + // Ignore errors, use hardcoded default + } + + // Final fallback + return 'master'; + } } /** @@ -100,6 +155,7 @@ export class TaskMaster { * @param {string} [overrides.complexityReportPath] * @param {string} [overrides.configPath] * @param {string} [overrides.statePath] + * @param {string} [overrides.tag] * @returns {TaskMaster} An initialized TaskMaster instance. */ export function initTaskMaster(overrides = {}) { @@ -123,17 +179,33 @@ export function initTaskMaster(overrides = {}) { pathType, override, defaultPaths = [], - basePath = null + basePath = null, + createParentDirs = false ) => { if (typeof override === 'string') { const resolvedPath = path.isAbsolute(override) ? override : path.resolve(basePath || process.cwd(), override); - if (!fs.existsSync(resolvedPath)) { - throw new Error( - `${pathType} override path does not exist: ${resolvedPath}` - ); + if (createParentDirs) { + // For output paths, create parent directory if it doesn't exist + const parentDir = path.dirname(resolvedPath); + if (!fs.existsSync(parentDir)) { + try { + fs.mkdirSync(parentDir, { recursive: true }); + } catch (error) { + throw new Error( + `Could not create directory for ${pathType}: ${parentDir}. Error: ${error.message}` + ); + } + } + } else { + // Original validation logic + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `${pathType} override path does not exist: ${resolvedPath}` + ); + } } return resolvedPath; } @@ -289,9 +361,10 @@ export function initTaskMaster(overrides = {}) { 'task-complexity-report.json', 'complexity-report.json' ], - paths.projectRoot + paths.projectRoot, + true // Enable parent directory creation for output paths ); } - return new TaskMaster(paths); + return new TaskMaster(paths, overrides.tag); } diff --git a/src/utils/path-utils.js b/src/utils/path-utils.js index d764a235..a50f9481 100644 --- a/src/utils/path-utils.js +++ b/src/utils/path-utils.js @@ -271,7 +271,12 @@ export function findComplexityReportPath( '' // Project root ]; - const fileNames = ['task-complexity-report.json', 'complexity-report.json']; + const fileNames = ['task-complexity', 'complexity-report'].map((fileName) => { + if (args?.tag && args?.tag !== 'master') { + return `${fileName}_${args.tag}.json`; + } + return `${fileName}.json`; + }); for (const location of locations) { for (const fileName of fileNames) { @@ -353,6 +358,7 @@ export function resolveComplexityReportOutputPath( log = null ) { const logger = getLoggerOrDefault(log); + const tag = args?.tag; // 1. If explicit path is provided, use it if (explicitPath) { @@ -369,13 +375,19 @@ export function resolveComplexityReportOutputPath( // 2. Try to get project root from args (MCP) or find it const rawProjectRoot = args?.projectRoot || findProjectRoot() || process.cwd(); - - // 3. Normalize project root to prevent double .taskmaster paths const projectRoot = normalizeProjectRoot(rawProjectRoot); + // 3. Use tag-aware filename + let filename = 'task-complexity-report.json'; + if (tag && tag !== 'master') { + filename = `task-complexity-report_${tag}.json`; + } + // 4. Use new .taskmaster structure by default - const defaultPath = path.join(projectRoot, COMPLEXITY_REPORT_FILE); - logger.info?.(`Using default complexity report output path: ${defaultPath}`); + const defaultPath = path.join(projectRoot, '.taskmaster/reports', filename); + logger.info?.( + `Using tag-aware complexity report output path: ${defaultPath}` + ); // Ensure the directory exists const outputDir = path.dirname(defaultPath); diff --git a/tests/e2e/run_e2e.sh b/tests/e2e/run_e2e.sh index 854273ab..59eb0bc3 100755 --- a/tests/e2e/run_e2e.sh +++ b/tests/e2e/run_e2e.sh @@ -368,7 +368,7 @@ log_step() { log_success "Formatted complexity report saved to complexity_report_formatted.log" log_step "Expanding Task 1 (assuming it exists)" - cmd_output_expand1=$(task-master expand --id=1 2>&1) + cmd_output_expand1=$(task-master expand --id=1 --cr complexity_results.json 2>&1) exit_status_expand1=$? echo "$cmd_output_expand1" extract_and_sum_cost "$cmd_output_expand1" diff --git a/tests/unit/scripts/modules/task-manager/add-task.test.js b/tests/unit/scripts/modules/task-manager/add-task.test.js index 43897e66..33ee076d 100644 --- a/tests/unit/scripts/modules/task-manager/add-task.test.js +++ b/tests/unit/scripts/modules/task-manager/add-task.test.js @@ -237,7 +237,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -253,7 +254,8 @@ describe('addTask', () => { // Assert expect(readJSON).toHaveBeenCalledWith( 'tasks/tasks.json', - '/mock/project/root' + '/mock/project/root', + 'master' ); expect(generateObjectService).toHaveBeenCalledWith(expect.any(Object)); expect(writeJSON).toHaveBeenCalledWith( @@ -288,7 +290,8 @@ describe('addTask', () => { const validDependencies = [1, 2]; // These exist in sampleTasks const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -325,7 +328,8 @@ describe('addTask', () => { const invalidDependencies = [999]; // Non-existent task ID const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -367,7 +371,8 @@ describe('addTask', () => { const priority = 'high'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -396,7 +401,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -433,7 +439,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act @@ -457,7 +464,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act & Assert @@ -474,7 +482,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act & Assert @@ -491,7 +500,8 @@ describe('addTask', () => { const prompt = 'Create a new authentication system'; const context = { mcpLog: createMcpLogMock(), - projectRoot: '/mock/project/root' + projectRoot: '/mock/project/root', + tag: 'master' }; // Act & Assert diff --git a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js index a5e64935..37916aee 100644 --- a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js +++ b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js @@ -305,7 +305,7 @@ describe('analyzeTaskComplexity', () => { ); expect(generateTextService).toHaveBeenCalledWith(expect.any(Object)); expect(mockWriteFileSync).toHaveBeenCalledWith( - 'scripts/task-complexity-report.json', + expect.stringContaining('task-complexity-report.json'), expect.stringContaining('"thresholdScore": 5'), 'utf8' ); @@ -362,7 +362,7 @@ describe('analyzeTaskComplexity', () => { }); expect(mockWriteFileSync).toHaveBeenCalledWith( - 'scripts/task-complexity-report.json', + expect.stringContaining('task-complexity-report.json'), expect.stringContaining('"thresholdScore": 7'), 'utf8' ); @@ -390,7 +390,7 @@ describe('analyzeTaskComplexity', () => { }); expect(mockWriteFileSync).toHaveBeenCalledWith( - 'scripts/task-complexity-report.json', + expect.stringContaining('task-complexity-report.json'), expect.stringContaining('"thresholdScore": 8'), 'utf8' ); diff --git a/tests/unit/scripts/modules/task-manager/clear-subtasks.test.js b/tests/unit/scripts/modules/task-manager/clear-subtasks.test.js index cf869f42..c8c6b9b1 100644 --- a/tests/unit/scripts/modules/task-manager/clear-subtasks.test.js +++ b/tests/unit/scripts/modules/task-manager/clear-subtasks.test.js @@ -103,6 +103,9 @@ describe('clearSubtasks', () => { jest.clearAllMocks(); mockExit.mockClear(); readJSON.mockImplementation((tasksPath, projectRoot, tag) => { + // Ensure tag contract is honoured + expect(tag).toBeDefined(); + expect(tag).toBe('master'); // Create a deep copy to avoid mutation issues between tests const sampleTasksCopy = JSON.parse(JSON.stringify(sampleTasks)); // Return the data for the 'master' tag, which is what the tests use @@ -121,12 +124,13 @@ describe('clearSubtasks', () => { // Arrange const taskId = '3'; const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskId); + clearSubtasks(tasksPath, taskId, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -142,7 +146,7 @@ describe('clearSubtasks', () => { }) }), undefined, - undefined + 'master' ); }); @@ -150,12 +154,13 @@ describe('clearSubtasks', () => { // Arrange const taskIds = '3,4'; const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskIds); + clearSubtasks(tasksPath, taskIds, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -169,7 +174,7 @@ describe('clearSubtasks', () => { }) }), undefined, - undefined + 'master' ); }); @@ -177,12 +182,13 @@ describe('clearSubtasks', () => { // Arrange const taskId = '1'; // Task 1 already has no subtasks const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskId); + clearSubtasks(tasksPath, taskId, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Should not write the file if no changes were made expect(writeJSON).not.toHaveBeenCalled(); expect(generateTaskFiles).not.toHaveBeenCalled(); @@ -192,12 +198,13 @@ describe('clearSubtasks', () => { // Arrange const taskId = '99'; // Non-existent task const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskId); + clearSubtasks(tasksPath, taskId, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(log).toHaveBeenCalledWith('error', 'Task 99 not found'); // Should not write the file if no changes were made expect(writeJSON).not.toHaveBeenCalled(); @@ -208,12 +215,13 @@ describe('clearSubtasks', () => { // Arrange const taskIds = '3,99'; // Mix of valid and invalid IDs const tasksPath = 'tasks/tasks.json'; + const context = { tag: 'master' }; // Act - clearSubtasks(tasksPath, taskIds); + clearSubtasks(tasksPath, taskIds, context); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(log).toHaveBeenCalledWith('error', 'Task 99 not found'); // Since task 3 has subtasks that should be cleared, writeJSON should be called expect(writeJSON).toHaveBeenCalledWith( @@ -232,7 +240,7 @@ describe('clearSubtasks', () => { }) }), undefined, - undefined + 'master' ); }); @@ -244,7 +252,7 @@ describe('clearSubtasks', () => { // Act & Assert expect(() => { - clearSubtasks('tasks/tasks.json', '3'); + clearSubtasks('tasks/tasks.json', '3', { tag: 'master' }); }).toThrow('File read failed'); }); @@ -254,7 +262,7 @@ describe('clearSubtasks', () => { // Act & Assert expect(() => { - clearSubtasks('tasks/tasks.json', '3'); + clearSubtasks('tasks/tasks.json', '3', { tag: 'master' }); }).toThrow('process.exit called'); expect(log).toHaveBeenCalledWith('error', 'No valid tasks found.'); @@ -283,7 +291,7 @@ describe('clearSubtasks', () => { // Act & Assert expect(() => { - clearSubtasks('tasks/tasks.json', '3'); + clearSubtasks('tasks/tasks.json', '3', { tag: 'master' }); }).toThrow('File write failed'); }); }); diff --git a/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js b/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js new file mode 100644 index 00000000..93698e51 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js @@ -0,0 +1,1124 @@ +/** + * Tests for complexity report tag isolation functionality + * Verifies that different tags maintain separate complexity reports + */ + +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; + +// Mock the dependencies +jest.unstable_mockModule('../../../../../src/utils/path-utils.js', () => ({ + resolveComplexityReportOutputPath: jest.fn(), + findComplexityReportPath: jest.fn(), + findConfigPath: jest.fn(), + findPRDPath: jest.fn(() => '/mock/project/root/.taskmaster/docs/PRD.md'), + findTasksPath: jest.fn( + () => '/mock/project/root/.taskmaster/tasks/tasks.json' + ), + findProjectRoot: jest.fn(() => '/mock/project/root'), + normalizeProjectRoot: jest.fn((root) => root) +})); + +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + isSilentMode: jest.fn(() => false), + enableSilentMode: jest.fn(), + disableSilentMode: jest.fn(), + flattenTasksWithSubtasks: jest.fn((tasks) => tasks), + getTagAwareFilePath: jest.fn((basePath, tag, projectRoot) => { + if (tag && tag !== 'master') { + const dir = path.dirname(basePath); + const ext = path.extname(basePath); + const name = path.basename(basePath, ext); + return path.join(projectRoot || '.', dir, `${name}_${tag}${ext}`); + } + return path.join(projectRoot || '.', basePath); + }), + findTaskById: jest.fn((tasks, taskId) => { + if (!tasks || !Array.isArray(tasks)) { + return { task: null, originalSubtaskCount: null, originalSubtasks: null }; + } + const id = parseInt(taskId, 10); + const task = tasks.find((t) => t.id === id); + return task + ? { task, originalSubtaskCount: null, originalSubtasks: null } + : { task: null, originalSubtaskCount: null, originalSubtasks: null }; + }), + taskExists: jest.fn((tasks, taskId) => { + if (!tasks || !Array.isArray(tasks)) return false; + const id = parseInt(taskId, 10); + return tasks.some((t) => t.id === id); + }), + formatTaskId: jest.fn((id) => `Task ${id}`), + findCycles: jest.fn(() => []), + truncate: jest.fn((text) => text), + addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })), + aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {}), + ensureTagMetadata: jest.fn((tagObj) => tagObj), + getCurrentTag: jest.fn(() => 'master'), + markMigrationForNotice: jest.fn(), + performCompleteTagMigration: jest.fn(), + setTasksForTag: jest.fn(), + getTasksForTag: jest.fn((data, tag) => data[tag]?.tasks || []), + findProjectRoot: jest.fn(() => '/mock/project/root'), + readComplexityReport: jest.fn(), + findTaskInComplexityReport: jest.fn(), + resolveEnvVariable: jest.fn((varName) => `mock_${varName}`), + isEmpty: jest.fn(() => false), + normalizeProjectRoot: jest.fn((root) => root), + slugifyTagForFilePath: jest.fn((tagName) => { + if (!tagName || typeof tagName !== 'string') { + return 'unknown-tag'; + } + return tagName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + }), + createTagAwareFilePath: jest.fn((basePath, tag, projectRoot) => { + if (tag && tag !== 'master') { + const dir = path.dirname(basePath); + const ext = path.extname(basePath); + const name = path.basename(basePath, ext); + // Use the slugified tag + const slugifiedTag = tag.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + return path.join( + projectRoot || '.', + dir, + `${name}_${slugifiedTag}${ext}` + ); + } + return path.join(projectRoot || '.', basePath); + }), + CONFIG: { + defaultSubtasks: 3 + } +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/ai-services-unified.js', + () => ({ + generateTextService: jest.fn().mockImplementation((params) => { + const commandName = params?.commandName || 'default'; + + if (commandName === 'analyze-complexity') { + // Check if this is for a specific tag test by looking at the prompt + const isFeatureTag = + params?.prompt?.includes('feature') || params?.role === 'feature'; + const isMasterTag = + params?.prompt?.includes('master') || params?.role === 'master'; + + let taskTitle = 'Test Task'; + if (isFeatureTag) { + taskTitle = 'Feature Task 1'; + } else if (isMasterTag) { + taskTitle = 'Master Task 1'; + } + + return Promise.resolve({ + mainResult: JSON.stringify([ + { + taskId: 1, + taskTitle: taskTitle, + complexityScore: 7, + recommendedSubtasks: 4, + expansionPrompt: 'Break down this task', + reasoning: 'This task is moderately complex' + }, + { + taskId: 2, + taskTitle: 'Task 2', + complexityScore: 5, + recommendedSubtasks: 3, + expansionPrompt: 'Break down this task with a focus on task 2.', + reasoning: + 'Automatically added due to missing analysis in AI response.' + } + ]), + telemetryData: { + timestamp: new Date().toISOString(), + commandName: 'analyze-complexity', + modelUsed: 'claude-3-5-sonnet', + providerName: 'anthropic', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + totalCost: 0.012414, + currency: 'USD' + } + }); + } else { + // Default for expand-task and others + return Promise.resolve({ + mainResult: JSON.stringify({ + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'First subtask', + dependencies: [], + details: 'Implementation details', + status: 'pending', + testStrategy: 'Test strategy' + } + ] + }), + telemetryData: { + timestamp: new Date().toISOString(), + commandName: commandName || 'expand-task', + modelUsed: 'claude-3-5-sonnet', + providerName: 'anthropic', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + totalCost: 0.012414, + currency: 'USD' + } + }); + } + }), + generateObjectService: jest.fn().mockResolvedValue({ + mainResult: { + object: { + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'First subtask', + dependencies: [], + details: 'Implementation details', + status: 'pending', + testStrategy: 'Test strategy' + } + ] + } + }, + telemetryData: { + timestamp: new Date().toISOString(), + commandName: 'expand-task', + modelUsed: 'claude-3-5-sonnet', + providerName: 'anthropic', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + totalCost: 0.012414, + currency: 'USD' + } + }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/config-manager.js', + () => ({ + // Core config access + getConfig: jest.fn(() => ({ + models: { main: { provider: 'anthropic', modelId: 'claude-3-5-sonnet' } }, + global: { projectName: 'Test Project' } + })), + writeConfig: jest.fn(() => true), + ConfigurationError: class extends Error {}, + isConfigFilePresent: jest.fn(() => true), + + // Validation + validateProvider: jest.fn(() => true), + validateProviderModelCombination: jest.fn(() => true), + VALIDATED_PROVIDERS: ['anthropic', 'openai', 'perplexity'], + CUSTOM_PROVIDERS: { OLLAMA: 'ollama', BEDROCK: 'bedrock' }, + ALL_PROVIDERS: ['anthropic', 'openai', 'perplexity', 'ollama', 'bedrock'], + MODEL_MAP: { + anthropic: [ + { + id: 'claude-3-5-sonnet', + cost_per_1m_tokens: { input: 3, output: 15 } + } + ], + openai: [{ id: 'gpt-4', cost_per_1m_tokens: { input: 30, output: 60 } }] + }, + getAvailableModels: jest.fn(() => [ + { + id: 'claude-3-5-sonnet', + name: 'Claude 3.5 Sonnet', + provider: 'anthropic' + }, + { id: 'gpt-4', name: 'GPT-4', provider: 'openai' } + ]), + + // Role-specific getters + getMainProvider: jest.fn(() => 'anthropic'), + getMainModelId: jest.fn(() => 'claude-3-5-sonnet'), + getMainMaxTokens: jest.fn(() => 4000), + getMainTemperature: jest.fn(() => 0.7), + getResearchProvider: jest.fn(() => 'perplexity'), + getResearchModelId: jest.fn(() => 'sonar-pro'), + getResearchMaxTokens: jest.fn(() => 8700), + getResearchTemperature: jest.fn(() => 0.1), + getFallbackProvider: jest.fn(() => 'anthropic'), + getFallbackModelId: jest.fn(() => 'claude-3-5-sonnet'), + getFallbackMaxTokens: jest.fn(() => 4000), + getFallbackTemperature: jest.fn(() => 0.7), + getBaseUrlForRole: jest.fn(() => undefined), + + // Global setting getters + getLogLevel: jest.fn(() => 'info'), + getDebugFlag: jest.fn(() => false), + getDefaultNumTasks: jest.fn(() => 10), + getDefaultSubtasks: jest.fn(() => 5), + getDefaultPriority: jest.fn(() => 'medium'), + getProjectName: jest.fn(() => 'Test Project'), + getOllamaBaseURL: jest.fn(() => 'http://localhost:11434/api'), + getAzureBaseURL: jest.fn(() => undefined), + getBedrockBaseURL: jest.fn(() => undefined), + getParametersForRole: jest.fn(() => ({ + maxTokens: 4000, + temperature: 0.7 + })), + getUserId: jest.fn(() => '1234567890'), + + // API Key Checkers + isApiKeySet: jest.fn(() => true), + getMcpApiKeyStatus: jest.fn(() => true), + + // Additional functions + getAllProviders: jest.fn(() => ['anthropic', 'openai', 'perplexity']), + getVertexProjectId: jest.fn(() => undefined), + getVertexLocation: jest.fn(() => undefined) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/prompt-manager.js', + () => ({ + getPromptManager: jest.fn().mockReturnValue({ + loadPrompt: jest.fn().mockResolvedValue({ + systemPrompt: 'Mocked system prompt', + userPrompt: 'Mocked user prompt' + }) + }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/contextGatherer.js', + () => { + class MockContextGatherer { + constructor(projectRoot, tag) { + this.projectRoot = projectRoot; + this.tag = tag; + this.allTasks = []; + } + + async gather(options = {}) { + return { + context: 'Mock context gathered', + analysisData: null, + contextSections: 1, + finalTaskIds: options.tasks || [] + }; + } + } + + return { + default: MockContextGatherer, + ContextGatherer: MockContextGatherer, + createContextGatherer: jest.fn( + (projectRoot, tag) => new MockContextGatherer(projectRoot, tag) + ) + }; + } +); + +jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), + stopLoadingIndicator: jest.fn(), + displayAiUsageSummary: jest.fn(), + displayBanner: jest.fn(), + getStatusWithColor: jest.fn((status) => status), + succeedLoadingIndicator: jest.fn(), + failLoadingIndicator: jest.fn(), + warnLoadingIndicator: jest.fn(), + infoLoadingIndicator: jest.fn(), + displayContextAnalysis: jest.fn(), + createProgressBar: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + update: jest.fn() + })), + displayTable: jest.fn(), + displayBox: jest.fn(), + displaySuccess: jest.fn(), + displayError: jest.fn(), + displayWarning: jest.fn(), + displayInfo: jest.fn(), + displayTaskDetails: jest.fn(), + displayTaskList: jest.fn(), + displayComplexityReport: jest.fn(), + displayNextTask: jest.fn(), + displayDependencyStatus: jest.fn(), + displayMigrationNotice: jest.fn(), + formatDependenciesWithStatus: jest.fn((deps) => deps), + formatTaskId: jest.fn((id) => `Task ${id}`), + formatPriority: jest.fn((priority) => priority), + formatDuration: jest.fn((duration) => duration), + formatDate: jest.fn((date) => date), + formatComplexityScore: jest.fn((score) => score), + formatTelemetryData: jest.fn((data) => data), + formatContextSummary: jest.fn((context) => context), + formatTagName: jest.fn((tag) => tag), + formatFilePath: jest.fn((path) => path), + getComplexityWithColor: jest.fn((complexity) => complexity), + getPriorityWithColor: jest.fn((priority) => priority), + getTagWithColor: jest.fn((tag) => tag), + getDependencyWithColor: jest.fn((dep) => dep), + getTelemetryWithColor: jest.fn((data) => data), + getContextWithColor: jest.fn((context) => context) +})); + +// Mock fs module +const mockWriteFileSync = jest.fn(); +const mockExistsSync = jest.fn(); +const mockReadFileSync = jest.fn(); +const mockMkdirSync = jest.fn(); + +jest.unstable_mockModule('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync + }, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync +})); + +// Import the mocked modules +const { resolveComplexityReportOutputPath, findComplexityReportPath } = + await import('../../../../../src/utils/path-utils.js'); + +const { readJSON, writeJSON, getTagAwareFilePath } = await import( + '../../../../../scripts/modules/utils.js' +); + +const { generateTextService } = await import( + '../../../../../scripts/modules/ai-services-unified.js' +); + +// Import the modules under test +const { default: analyzeTaskComplexity } = await import( + '../../../../../scripts/modules/task-manager/analyze-task-complexity.js' +); + +const { default: expandTask } = await import( + '../../../../../scripts/modules/task-manager/expand-task.js' +); + +describe('Complexity Report Tag Isolation', () => { + const projectRoot = '/mock/project/root'; + const sampleTasks = { + tasks: [ + { + id: 1, + title: 'Task 1', + description: 'First task', + status: 'pending' + }, + { + id: 2, + title: 'Task 2', + description: 'Second task', + status: 'pending' + } + ] + }; + + const sampleComplexityReport = { + meta: { + generatedAt: new Date().toISOString(), + tasksAnalyzed: 2, + totalTasks: 2, + analysisCount: 2, + thresholdScore: 5, + projectName: 'Test Project', + usedResearch: false + }, + complexityAnalysis: [ + { + taskId: 1, + taskTitle: 'Task 1', + complexityScore: 7, + recommendedSubtasks: 4, + expansionPrompt: 'Break down this task', + reasoning: 'This task is moderately complex' + }, + { + taskId: 2, + taskTitle: 'Task 2', + complexityScore: 5, + recommendedSubtasks: 3, + expansionPrompt: 'Break down this task', + reasoning: 'This task is moderately complex' + } + ] + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock implementations + readJSON.mockReturnValue(sampleTasks); + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockImplementation(() => {}); + + // Mock resolveComplexityReportOutputPath to return tag-aware paths + resolveComplexityReportOutputPath.mockImplementation( + (explicitPath, args) => { + const tag = args?.tag; + if (explicitPath) { + return explicitPath; + } + + let filename = 'task-complexity-report.json'; + if (tag && tag !== 'master') { + // Use slugified tag for cross-platform compatibility + const slugifiedTag = tag + .replace(/[^a-zA-Z0-9_-]/g, '-') + .toLowerCase(); + filename = `task-complexity-report_${slugifiedTag}.json`; + } + + return path.join(projectRoot, '.taskmaster/reports', filename); + } + ); + + // Mock findComplexityReportPath to return tag-aware paths + findComplexityReportPath.mockImplementation((explicitPath, args) => { + const tag = args?.tag; + if (explicitPath) { + return explicitPath; + } + + let filename = 'task-complexity-report.json'; + if (tag && tag !== 'master') { + filename = `task-complexity-report_${tag}.json`; + } + + return path.join(projectRoot, '.taskmaster/reports', filename); + }); + }); + + describe('Path Resolution Tag Isolation', () => { + test('should resolve master tag to default filename', () => { + const result = resolveComplexityReportOutputPath(null, { + tag: 'master', + projectRoot + }); + expect(result).toBe( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + + test('should resolve non-master tag to tag-specific filename', () => { + const result = resolveComplexityReportOutputPath(null, { + tag: 'feature-auth', + projectRoot + }); + expect(result).toBe( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ) + ); + }); + + test('should resolve undefined tag to default filename', () => { + const result = resolveComplexityReportOutputPath(null, { projectRoot }); + expect(result).toBe( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + + test('should respect explicit path over tag-aware resolution', () => { + const explicitPath = '/custom/path/report.json'; + const result = resolveComplexityReportOutputPath(explicitPath, { + tag: 'feature-auth', + projectRoot + }); + expect(result).toBe(explicitPath); + }); + }); + + describe('Analysis Generation Tag Isolation', () => { + test('should generate master tag report to default location', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'master' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: 'master', + projectRoot + }), + expect.any(Function) + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ), + expect.any(String), + 'utf8' + ); + }); + + test('should generate feature tag report to tag-specific location', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: 'feature-auth', + projectRoot + }), + expect.any(Function) + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ), + expect.any(String), + 'utf8' + ); + }); + + test('should not overwrite master report when analyzing feature tag', async () => { + // First, analyze master tag + const masterOptions = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'master' + }; + + await analyzeTaskComplexity(masterOptions, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Clear mocks to verify separate calls + jest.clearAllMocks(); + readJSON.mockReturnValue(sampleTasks); + + // Then, analyze feature tag + const featureOptions = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(featureOptions, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Verify that the feature tag analysis wrote to its own file + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ), + expect.any(String), + 'utf8' + ); + + // Verify that it did NOT write to the master file + expect(mockWriteFileSync).not.toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ), + expect.any(String), + 'utf8' + ); + }); + }); + + describe('Report Reading Tag Isolation', () => { + test('should read master tag report from default location', async () => { + // Mock existing master report + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'master' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(mockExistsSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + + test('should read feature tag report from tag-specific location', async () => { + // Mock existing feature tag report + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report_feature-auth.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(mockExistsSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ) + ); + }); + + test('should not read master report when working with feature tag', async () => { + // Mock that feature tag report exists but master doesn't + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report_feature-auth.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Should check for feature tag report + expect(mockExistsSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ) + ); + + // Should NOT check for master report + expect(mockExistsSync).not.toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + }); + + describe('Expand Task Tag Isolation', () => { + test('should use tag-specific complexity report for expansion', async () => { + // Mock existing feature tag report + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report_feature-auth.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const tasksPath = path.join(projectRoot, 'tasks/tasks.json'); + const taskId = 1; + const numSubtasks = 3; + + await expandTask( + tasksPath, + taskId, + numSubtasks, + false, // useResearch + '', // additionalContext + { + projectRoot, + tag: 'feature-auth', + complexityReportPath: path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ), + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }, + false // force + ); + + // Should read from feature tag report + expect(readJSON).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ) + ); + }); + + test('should use master complexity report for master tag expansion', async () => { + // Mock existing master report + mockExistsSync.mockImplementation((filepath) => { + return filepath.endsWith('task-complexity-report.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); + + const tasksPath = path.join(projectRoot, 'tasks/tasks.json'); + const taskId = 1; + const numSubtasks = 3; + + await expandTask( + tasksPath, + taskId, + numSubtasks, + false, // useResearch + '', // additionalContext + { + projectRoot, + tag: 'master', + complexityReportPath: path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ), + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }, + false // force + ); + + // Should read from master report + expect(readJSON).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ) + ); + }); + }); + + describe('Cross-Tag Contamination Prevention', () => { + test('should maintain separate reports for different tags', async () => { + // Create different complexity reports for different tags + const masterReport = { + ...sampleComplexityReport, + complexityAnalysis: [ + { + taskId: 1, + taskTitle: 'Master Task 1', + complexityScore: 8, + recommendedSubtasks: 5, + expansionPrompt: 'Master expansion', + reasoning: 'Master task reasoning' + } + ] + }; + + const featureReport = { + ...sampleComplexityReport, + complexityAnalysis: [ + { + taskId: 1, + taskTitle: 'Feature Task 1', + complexityScore: 6, + recommendedSubtasks: 3, + expansionPrompt: 'Feature expansion', + reasoning: 'Feature task reasoning' + } + ] + }; + + // Mock file system to return different reports for different paths + mockExistsSync.mockImplementation((filepath) => { + return filepath.includes('task-complexity-report'); + }); + + mockReadFileSync.mockImplementation((filepath) => { + if (filepath.includes('task-complexity-report_feature-auth.json')) { + return JSON.stringify(featureReport); + } else if (filepath.includes('task-complexity-report.json')) { + return JSON.stringify(masterReport); + } + return '{}'; + }); + + // Analyze master tag + const masterOptions = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'master' + }; + + await analyzeTaskComplexity(masterOptions, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Verify that master report was written to master location + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report.json' + ), + expect.stringContaining('"taskTitle": "Test Task"'), + 'utf8' + ); + + // Clear mocks + jest.clearAllMocks(); + readJSON.mockReturnValue(sampleTasks); + + // Analyze feature tag + const featureOptions = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature-auth' + }; + + await analyzeTaskComplexity(featureOptions, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + // Verify that feature report was written to feature location + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-auth.json' + ), + expect.stringContaining('"taskTitle": "Test Task"'), + 'utf8' + ); + }); + }); + + describe('Edge Cases', () => { + test('should handle empty tag gracefully', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: '' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: '', + projectRoot + }), + expect.any(Function) + ); + }); + + test('should handle null tag gracefully', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: null + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: null, + projectRoot + }), + expect.any(Function) + ); + }); + + test('should handle special characters in tag names', async () => { + const options = { + file: 'tasks/tasks.json', + threshold: '5', + projectRoot, + tag: 'feature/user-auth-v2' + }; + + await analyzeTaskComplexity(options, { + projectRoot, + mcpLog: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + success: jest.fn() + } + }); + + expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + tag: 'feature/user-auth-v2', + projectRoot + }), + expect.any(Function) + ); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join( + projectRoot, + '.taskmaster/reports', + 'task-complexity-report_feature-user-auth-v2.json' + ), + expect.any(String), + 'utf8' + ); + }); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/expand-all-tasks.test.js b/tests/unit/scripts/modules/task-manager/expand-all-tasks.test.js index 1d858f05..e05b9355 100644 --- a/tests/unit/scripts/modules/task-manager/expand-all-tasks.test.js +++ b/tests/unit/scripts/modules/task-manager/expand-all-tasks.test.js @@ -198,7 +198,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -224,7 +225,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -267,7 +269,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -300,7 +303,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -326,7 +330,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ) @@ -347,7 +352,8 @@ describe('expandAllTasks', () => { false, { session: mockSession, - mcpLog: mockMcpLog + mcpLog: mockMcpLog, + tag: 'master' // No projectRoot provided, and findProjectRoot will return null }, 'json' @@ -384,7 +390,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -412,7 +419,8 @@ describe('expandAllTasks', () => { { session: mockSession, mcpLog: mockMcpLog, - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' }, 'json' ); @@ -441,7 +449,8 @@ describe('expandAllTasks', () => { '', false, { - projectRoot: mockProjectRoot + projectRoot: mockProjectRoot, + tag: 'master' // No mcpLog provided, should use CLI logger }, 'text' // CLI output format diff --git a/tests/unit/scripts/modules/task-manager/expand-task.test.js b/tests/unit/scripts/modules/task-manager/expand-task.test.js index e6521648..49fb11b0 100644 --- a/tests/unit/scripts/modules/task-manager/expand-task.test.js +++ b/tests/unit/scripts/modules/task-manager/expand-task.test.js @@ -700,7 +700,9 @@ describe('expandTask', () => { const context = { mcpLog: createMcpLogMock(), projectRoot: '/mock/project/root', - tag: 'feature-branch' + tag: 'feature-branch', + complexityReportPath: + '/mock/project/root/task-complexity-report_feature-branch.json' }; // Stub fs.existsSync to simulate complexity report exists for this tag diff --git a/tests/unit/scripts/modules/task-manager/generate-task-files.test.js b/tests/unit/scripts/modules/task-manager/generate-task-files.test.js index c3c64e49..d5e47b26 100644 --- a/tests/unit/scripts/modules/task-manager/generate-task-files.test.js +++ b/tests/unit/scripts/modules/task-manager/generate-task-files.test.js @@ -185,11 +185,12 @@ describe('generateTaskFiles', () => { const outputDir = 'tasks'; await generateTaskFiles(tasksPath, outputDir, { + tag: 'master', mcpLog: { info: jest.fn() } }); // Verify the data was read with new signature, defaulting to master - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Verify dependencies were validated with the raw tagged data expect(validateAndFixDependencies).toHaveBeenCalledWith( @@ -226,6 +227,7 @@ describe('generateTaskFiles', () => { // Call the function await generateTaskFiles('tasks/tasks.json', 'tasks', { + tag: 'master', mcpLog: { info: jest.fn() } }); @@ -271,6 +273,7 @@ describe('generateTaskFiles', () => { // Call the function await generateTaskFiles('tasks/tasks.json', 'tasks', { + tag: 'master', mcpLog: { info: jest.fn() } }); @@ -288,6 +291,7 @@ describe('generateTaskFiles', () => { // Call the function await generateTaskFiles('tasks/tasks.json', 'tasks', { + tag: 'master', mcpLog: { info: jest.fn() } }); diff --git a/tests/unit/scripts/modules/task-manager/list-tasks.test.js b/tests/unit/scripts/modules/task-manager/list-tasks.test.js index e05a14ce..483b8a1a 100644 --- a/tests/unit/scripts/modules/task-manager/list-tasks.test.js +++ b/tests/unit/scripts/modules/task-manager/list-tasks.test.js @@ -21,7 +21,8 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ tasks.find((t) => t.id === parseInt(id)) ), addComplexityToTask: jest.fn(), - readComplexityReport: jest.fn(() => null) + readComplexityReport: jest.fn(() => null), + getTagAwareFilePath: jest.fn((tag, path) => '/mock/tagged/report.json') })); jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ @@ -152,10 +153,12 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json'); + const result = listTasks(tasksPath, null, null, false, 'json', { + tag: 'master' + }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(result).toEqual( expect.objectContaining({ tasks: expect.arrayContaining([ @@ -175,10 +178,12 @@ describe('listTasks', () => { const statusFilter = 'pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Verify only pending tasks are returned expect(result.tasks).toHaveLength(1); @@ -192,7 +197,9 @@ describe('listTasks', () => { const statusFilter = 'done'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Verify only done tasks are returned @@ -206,7 +213,9 @@ describe('listTasks', () => { const statusFilter = 'review'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Verify only review tasks are returned @@ -220,7 +229,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, true, 'json'); + const result = listTasks(tasksPath, null, null, true, 'json', { + tag: 'master' + }); // Assert // Verify that the task with subtasks is included @@ -235,7 +246,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json'); + const result = listTasks(tasksPath, null, null, false, 'json', { + tag: 'master' + }); // Assert // For JSON output, subtasks should still be included in the data structure @@ -253,7 +266,9 @@ describe('listTasks', () => { const statusFilter = 'blocked'; // Status that doesn't exist in sample data // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Verify empty array is returned @@ -269,7 +284,7 @@ describe('listTasks', () => { // Act & Assert expect(() => { - listTasks(tasksPath, null, null, false, 'json'); + listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); }).toThrow('File not found'); }); @@ -278,10 +293,10 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - listTasks(tasksPath, null, null, false, 'json'); + listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Note: validateAndFixDependencies is not called by listTasks function // This test just verifies the function runs without error }); @@ -291,7 +306,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, 'pending', null, true, 'json'); + const result = listTasks(tasksPath, 'pending', null, true, 'json', { + tag: 'master' + }); // Assert // For JSON output, we don't call displayTaskList, so just verify the result structure @@ -310,7 +327,9 @@ describe('listTasks', () => { const statusFilter = 'in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert expect(result.tasks).toHaveLength(1); @@ -324,7 +343,9 @@ describe('listTasks', () => { const statusFilter = 'cancelled'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert expect(result.tasks).toHaveLength(1); @@ -337,7 +358,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json'); + const result = listTasks(tasksPath, null, null, false, 'json', { + tag: 'master' + }); // Assert expect(result).toEqual( @@ -363,10 +386,12 @@ describe('listTasks', () => { const statusFilter = 'done,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, null, null); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Should return tasks with 'done' or 'pending' status expect(result.tasks).toHaveLength(2); @@ -381,7 +406,9 @@ describe('listTasks', () => { const statusFilter = 'done,pending,in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return tasks with 'done', 'pending', or 'in-progress' status @@ -405,7 +432,9 @@ describe('listTasks', () => { const statusFilter = 'done, pending , in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should trim spaces and work correctly @@ -422,7 +451,9 @@ describe('listTasks', () => { const statusFilter = 'done,,pending,'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should ignore empty values and work with valid ones @@ -437,7 +468,9 @@ describe('listTasks', () => { const statusFilter = 'DONE,Pending,IN-PROGRESS'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should match case-insensitively @@ -454,7 +487,9 @@ describe('listTasks', () => { const statusFilter = 'blocked,deferred'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return empty array as no tasks have these statuses @@ -467,7 +502,9 @@ describe('listTasks', () => { const statusFilter = 'pending,'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should work the same as single status filter @@ -481,7 +518,9 @@ describe('listTasks', () => { const statusFilter = 'done,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return the original filter string @@ -494,7 +533,9 @@ describe('listTasks', () => { const statusFilter = 'all'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return all tasks when filter is 'all' @@ -508,7 +549,9 @@ describe('listTasks', () => { const statusFilter = 'done,nonexistent,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return only tasks with existing statuses @@ -523,7 +566,9 @@ describe('listTasks', () => { const statusFilter = 'review,cancelled'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json'); + const result = listTasks(tasksPath, statusFilter, null, false, 'json', { + tag: 'master' + }); // Assert // Should return tasks with 'review' or 'cancelled' status diff --git a/tests/unit/scripts/modules/task-manager/move-task.test.js b/tests/unit/scripts/modules/task-manager/move-task.test.js new file mode 100644 index 00000000..344d19b2 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/move-task.test.js @@ -0,0 +1,94 @@ +import { jest } from '@jest/globals'; + +// --- Mocks --- +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + setTasksForTag: jest.fn(), + truncate: jest.fn((t) => t), + isSilentMode: jest.fn(() => false) +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn().mockResolvedValue() + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager.js', + () => ({ + isTaskDependentOn: jest.fn(() => false) + }) +); + +// fs not needed since move-task uses writeJSON + +const { readJSON, writeJSON, log } = await import( + '../../../../../scripts/modules/utils.js' +); +const generateTaskFiles = ( + await import( + '../../../../../scripts/modules/task-manager/generate-task-files.js' + ) +).default; + +const { default: moveTask } = await import( + '../../../../../scripts/modules/task-manager/move-task.js' +); + +const sampleTagged = () => ({ + master: { + tasks: [ + { id: 1, title: 'A' }, + { id: 2, title: 'B', subtasks: [{ id: 1, title: 'B.1' }] } + ], + metadata: {} + }, + feature: { + tasks: [{ id: 10, title: 'X' }], + metadata: {} + } +}); + +const clone = () => JSON.parse(JSON.stringify(sampleTagged())); + +describe('moveTask (unit)', () => { + beforeEach(() => { + jest.clearAllMocks(); + readJSON.mockImplementation((path, projectRoot, tag) => { + const data = clone(); + return { ...data[tag], tag, _rawTaggedData: data }; + }); + writeJSON.mockResolvedValue(); + log.mockImplementation(() => {}); + }); + + test('moves task to new ID in same tag', async () => { + await moveTask('tasks.json', '1', '3', false, { tag: 'master' }); + expect(writeJSON).toHaveBeenCalled(); + const written = writeJSON.mock.calls[0][1]; + const ids = written.master.tasks.map((t) => t.id); + expect(ids).toEqual(expect.arrayContaining([2, 3])); + expect(ids).not.toContain(1); + }); + + test('throws when counts of source and dest mismatch', async () => { + await expect( + moveTask('tasks.json', '1,2', '3', {}, { tag: 'master' }) + ).rejects.toThrow(/Number of source IDs/); + }); + + test('batch move calls generateTaskFiles once when flag true', async () => { + await moveTask('tasks.json', '1,2', '3,4', true, { tag: 'master' }); + expect(generateTaskFiles).toHaveBeenCalledTimes(1); + }); + + test('error when tag invalid', async () => { + await expect( + moveTask('tasks.json', '1', '2', false, { tag: 'ghost' }) + ).rejects.toThrow(/tag "ghost" not found/); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/parse-prd.test.js b/tests/unit/scripts/modules/task-manager/parse-prd.test.js index f322cad7..eb40bcc3 100644 --- a/tests/unit/scripts/modules/task-manager/parse-prd.test.js +++ b/tests/unit/scripts/modules/task-manager/parse-prd.test.js @@ -233,7 +233,9 @@ describe('parsePRD', () => { }); // Call the function - const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master' + }); // Verify fs.readFileSync was called with the correct arguments expect(fs.default.readFileSync).toHaveBeenCalledWith( @@ -276,7 +278,7 @@ describe('parsePRD', () => { }); // Call the function - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }); // Verify mkdir was called expect(fs.default.mkdirSync).toHaveBeenCalledWith('tasks', { @@ -299,6 +301,7 @@ describe('parsePRD', () => { // Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit) await expect( parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master', mcpLog: { info: jest.fn(), warn: jest.fn(), @@ -319,7 +322,7 @@ describe('parsePRD', () => { }); // Call the function - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }); }); test('should overwrite tasks.json when force flag is true', async () => { @@ -331,7 +334,10 @@ describe('parsePRD', () => { }); // Call the function with force=true to allow overwrite - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { force: true }); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + force: true, + tag: 'master' + }); // Verify prompt was NOT called (confirmation happens at CLI level, not in core function) expect(promptYesNo).not.toHaveBeenCalled(); @@ -354,6 +360,7 @@ describe('parsePRD', () => { // Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit) await expect( parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master', mcpLog: { info: jest.fn(), warn: jest.fn(), @@ -383,7 +390,7 @@ describe('parsePRD', () => { // Call the function without mcpLog (CLI mode) and expect it to throw an error // In test environment, process.exit is prevented and error is thrown instead await expect( - parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3) + parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' }) ).rejects.toThrow( "Tag 'master' already contains 2 tasks. Use --force to overwrite or --append to add to existing tasks." ); @@ -411,6 +418,7 @@ describe('parsePRD', () => { // Call the function with append option const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 2, { + tag: 'master', append: true }); @@ -445,6 +453,7 @@ describe('parsePRD', () => { // Call the function with append option await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { + tag: 'master', append: true }); @@ -462,7 +471,9 @@ describe('parsePRD', () => { }); // Call the function with numTasks=0 for dynamic generation - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, { + tag: 'master' + }); // Verify generateObjectService was called expect(generateObjectService).toHaveBeenCalled(); @@ -482,7 +493,9 @@ describe('parsePRD', () => { }); // Call the function with specific numTasks - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 5); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 5, { + tag: 'master' + }); // Verify generateObjectService was called expect(generateObjectService).toHaveBeenCalled(); @@ -502,7 +515,9 @@ describe('parsePRD', () => { }); // Call the function with numTasks=0 - should not throw error - const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0); + const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, { + tag: 'master' + }); // Verify it completed successfully expect(result).toEqual({ @@ -522,7 +537,9 @@ describe('parsePRD', () => { // Call the function with negative numTasks // Note: The main parse-prd.js module doesn't validate numTasks - validation happens at CLI/MCP level - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', -5); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', -5, { + tag: 'master' + }); // Verify generateObjectService was called expect(generateObjectService).toHaveBeenCalled(); @@ -543,7 +560,9 @@ describe('parsePRD', () => { }); // Call the function with null numTasks - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', null); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', null, { + tag: 'master' + }); // Verify generateObjectService was called with dynamic prompting expect(generateObjectService).toHaveBeenCalled(); @@ -560,7 +579,9 @@ describe('parsePRD', () => { }); // Call the function with invalid numTasks (string that's not a number) - await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 'invalid'); + await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 'invalid', { + tag: 'master' + }); // Verify generateObjectService was called with dynamic prompting // Note: The main module doesn't validate - it just uses the value as-is diff --git a/tests/unit/scripts/modules/task-manager/remove-subtask.test.js b/tests/unit/scripts/modules/task-manager/remove-subtask.test.js index f82c9553..8ff6c382 100644 --- a/tests/unit/scripts/modules/task-manager/remove-subtask.test.js +++ b/tests/unit/scripts/modules/task-manager/remove-subtask.test.js @@ -19,10 +19,12 @@ const testRemoveSubtask = ( tasksPath, subtaskId, convertToTask = false, - generateFiles = true + generateFiles = true, + context = { tag: 'master' } ) => { + const { projectRoot = undefined, tag = 'master' } = context; // Read the existing tasks - const data = mockReadJSON(tasksPath); + const data = mockReadJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error(`Invalid or missing tasks file at ${tasksPath}`); } @@ -95,7 +97,7 @@ const testRemoveSubtask = ( } // Write the updated tasks back to the file - mockWriteJSON(tasksPath, data); + mockWriteJSON(tasksPath, data, projectRoot, tag); // Generate task files if requested if (generateFiles) { @@ -111,55 +113,66 @@ describe('removeSubtask function', () => { jest.clearAllMocks(); // Default mock implementations - mockReadJSON.mockImplementation(() => ({ - tasks: [ - { - id: 1, - title: 'Parent Task', - description: 'This is a parent task', - status: 'pending', - dependencies: [], - subtasks: [ - { - id: 1, - title: 'Subtask 1', - description: 'This is subtask 1', - status: 'pending', - dependencies: [], - parentTaskId: 1 - }, - { - id: 2, - title: 'Subtask 2', - description: 'This is subtask 2', - status: 'in-progress', - dependencies: [1], // Depends on subtask 1 - parentTaskId: 1 - } - ] - }, - { - id: 2, - title: 'Another Task', - description: 'This is another task', - status: 'pending', - dependencies: [1] - } - ] - })); + mockReadJSON.mockImplementation((p, root, tag) => { + expect(tag).toBeDefined(); + expect(tag).toBe('master'); + return { + tasks: [ + { + id: 1, + title: 'Parent Task', + description: 'This is a parent task', + status: 'pending', + dependencies: [], + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'This is subtask 1', + status: 'pending', + dependencies: [], + parentTaskId: 1 + }, + { + id: 2, + title: 'Subtask 2', + description: 'This is subtask 2', + status: 'in-progress', + dependencies: [1], // Depends on subtask 1 + parentTaskId: 1 + } + ] + }, + { + id: 2, + title: 'Another Task', + description: 'This is another task', + status: 'pending', + dependencies: [1] + } + ] + }; + }); // Setup success write response - mockWriteJSON.mockImplementation((path, data) => { + mockWriteJSON.mockImplementation((path, data, root, tag) => { + expect(tag).toBe('master'); return data; }); }); test('should remove a subtask from its parent task', async () => { // Execute the test version of removeSubtask to remove subtask 1.1 - testRemoveSubtask('tasks/tasks.json', '1.1', false, true); + testRemoveSubtask('tasks/tasks.json', '1.1', false, true, { + tag: 'master' + }); // Verify readJSON was called with the correct path - expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json'); + expect(mockReadJSON).toHaveBeenCalledWith( + 'tasks/tasks.json', + undefined, + 'master' + ); // Verify writeJSON was called with updated data expect(mockWriteJSON).toHaveBeenCalled(); @@ -170,7 +183,9 @@ describe('removeSubtask function', () => { test('should convert a subtask to a standalone task', async () => { // Execute the test version of removeSubtask to convert subtask 1.1 to a standalone task - const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true); + const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true, { + tag: 'master' + }); // Verify the result is the new task expect(result).toBeDefined(); @@ -187,9 +202,9 @@ describe('removeSubtask function', () => { test('should throw an error if subtask ID format is invalid', async () => { // Expect an error for invalid subtask ID format - expect(() => testRemoveSubtask('tasks/tasks.json', '1', false)).toThrow( - /Invalid subtask ID format/ - ); + expect(() => + testRemoveSubtask('tasks/tasks.json', '1', false, true, { tag: 'master' }) + ).toThrow(/Invalid subtask ID format/); // Verify writeJSON was not called expect(mockWriteJSON).not.toHaveBeenCalled(); @@ -197,9 +212,11 @@ describe('removeSubtask function', () => { test('should throw an error if parent task does not exist', async () => { // Expect an error for non-existent parent task - expect(() => testRemoveSubtask('tasks/tasks.json', '999.1', false)).toThrow( - /Parent task with ID 999 not found/ - ); + expect(() => + testRemoveSubtask('tasks/tasks.json', '999.1', false, true, { + tag: 'master' + }) + ).toThrow(/Parent task with ID 999 not found/); // Verify writeJSON was not called expect(mockWriteJSON).not.toHaveBeenCalled(); @@ -207,9 +224,11 @@ describe('removeSubtask function', () => { test('should throw an error if subtask does not exist', async () => { // Expect an error for non-existent subtask - expect(() => testRemoveSubtask('tasks/tasks.json', '1.999', false)).toThrow( - /Subtask 1.999 not found/ - ); + expect(() => + testRemoveSubtask('tasks/tasks.json', '1.999', false, true, { + tag: 'master' + }) + ).toThrow(/Subtask 1.999 not found/); // Verify writeJSON was not called expect(mockWriteJSON).not.toHaveBeenCalled(); @@ -217,45 +236,51 @@ describe('removeSubtask function', () => { test('should remove subtasks array if last subtask is removed', async () => { // Create a data object with just one subtask - mockReadJSON.mockImplementationOnce(() => ({ - tasks: [ - { - id: 1, - title: 'Parent Task', - description: 'This is a parent task', - status: 'pending', - dependencies: [], - subtasks: [ - { - id: 1, - title: 'Last Subtask', - description: 'This is the last subtask', - status: 'pending', - dependencies: [], - parentTaskId: 1 - } - ] - }, - { - id: 2, - title: 'Another Task', - description: 'This is another task', - status: 'pending', - dependencies: [1] - } - ] - })); + mockReadJSON.mockImplementationOnce((p, root, tag) => { + expect(tag).toBe('master'); + return { + tasks: [ + { + id: 1, + title: 'Parent Task', + description: 'This is a parent task', + status: 'pending', + dependencies: [], + subtasks: [ + { + id: 1, + title: 'Last Subtask', + description: 'This is the last subtask', + status: 'pending', + dependencies: [], + parentTaskId: 1 + } + ] + }, + { + id: 2, + title: 'Another Task', + description: 'This is another task', + status: 'pending', + dependencies: [1] + } + ] + }; + }); // Mock the behavior of writeJSON to capture the updated tasks data const updatedTasksData = { tasks: [] }; - mockWriteJSON.mockImplementation((path, data) => { + mockWriteJSON.mockImplementation((path, data, root, tag) => { + expect(tag).toBe('master'); // Store the data for assertions updatedTasksData.tasks = [...data.tasks]; return data; }); // Remove the last subtask - testRemoveSubtask('tasks/tasks.json', '1.1', false, true); + testRemoveSubtask('tasks/tasks.json', '1.1', false, true, { + tag: 'master' + }); // Verify writeJSON was called expect(mockWriteJSON).toHaveBeenCalled(); @@ -271,7 +296,9 @@ describe('removeSubtask function', () => { test('should not regenerate task files if generateFiles is false', async () => { // Execute the test version of removeSubtask with generateFiles = false - testRemoveSubtask('tasks/tasks.json', '1.1', false, false); + testRemoveSubtask('tasks/tasks.json', '1.1', false, false, { + tag: 'master' + }); // Verify writeJSON was called expect(mockWriteJSON).toHaveBeenCalled(); diff --git a/tests/unit/scripts/modules/task-manager/remove-task.test.js b/tests/unit/scripts/modules/task-manager/remove-task.test.js new file mode 100644 index 00000000..3b716195 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/remove-task.test.js @@ -0,0 +1,134 @@ +import { jest } from '@jest/globals'; + +// --- Mock dependencies BEFORE module import --- +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + CONFIG: { + model: 'mock-model', + maxTokens: 4000, + temperature: 0.7, + debug: false + }, + findTaskById: jest.fn(), + truncate: jest.fn((t) => t), + isSilentMode: jest.fn(() => false) +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn().mockResolvedValue() + }) +); + +// fs is used for file deletion side-effects – stub the methods we touch +jest.unstable_mockModule('fs', () => ({ + existsSync: jest.fn(() => true), + unlinkSync: jest.fn() +})); + +// path is fine to keep as real since only join/dirname used – no side effects + +// Import mocked modules +const { readJSON, writeJSON, log } = await import( + '../../../../../scripts/modules/utils.js' +); +const generateTaskFiles = ( + await import( + '../../../../../scripts/modules/task-manager/generate-task-files.js' + ) +).default; +const fs = await import('fs'); + +// Import module under test (AFTER mocks in place) +const { default: removeTask } = await import( + '../../../../../scripts/modules/task-manager/remove-task.js' +); + +// ---- Test data helpers ---- +const buildSampleTaggedTasks = () => ({ + master: { + tasks: [ + { id: 1, title: 'Task 1', status: 'pending', dependencies: [] }, + { id: 2, title: 'Task 2', status: 'pending', dependencies: [1] }, + { + id: 3, + title: 'Parent', + status: 'pending', + dependencies: [], + subtasks: [ + { id: 1, title: 'Sub 3.1', status: 'pending', dependencies: [] } + ] + } + ] + }, + other: { + tasks: [{ id: 99, title: 'Shadow', status: 'pending', dependencies: [1] }] + } +}); + +// Utility to deep clone sample each test +const getFreshData = () => JSON.parse(JSON.stringify(buildSampleTaggedTasks())); + +// ----- Tests ----- + +describe('removeTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + // readJSON returns deep copy so each test isolated + readJSON.mockImplementation(() => { + return { + ...getFreshData().master, + tag: 'master', + _rawTaggedData: getFreshData() + }; + }); + writeJSON.mockResolvedValue(); + log.mockImplementation(() => {}); + fs.unlinkSync.mockImplementation(() => {}); + }); + + test('removes a main task and cleans dependencies across tags', async () => { + const result = await removeTask('tasks/tasks.json', '1', { tag: 'master' }); + + // Expect success true + expect(result.success).toBe(true); + // writeJSON called with data where task 1 is gone in master & dependencies removed in other tags + const written = writeJSON.mock.calls[0][1]; + expect(written.master.tasks.find((t) => t.id === 1)).toBeUndefined(); + // deps removed from child tasks + const task2 = written.master.tasks.find((t) => t.id === 2); + expect(task2.dependencies).not.toContain(1); + const shadow = written.other.tasks.find((t) => t.id === 99); + expect(shadow.dependencies).not.toContain(1); + // Task file deletion attempted + expect(fs.unlinkSync).toHaveBeenCalled(); + }); + + test('removes a subtask only and leaves parent intact', async () => { + const result = await removeTask('tasks/tasks.json', '3.1', { + tag: 'master' + }); + + expect(result.success).toBe(true); + const written = writeJSON.mock.calls[0][1]; + const parent = written.master.tasks.find((t) => t.id === 3); + expect(parent.subtasks || []).toHaveLength(0); + // Ensure parent still exists + expect(parent).toBeDefined(); + // No task files should be deleted for subtasks + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + + test('handles non-existent task gracefully', async () => { + const result = await removeTask('tasks/tasks.json', '42', { + tag: 'master' + }); + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + // writeJSON not called because nothing changed + expect(writeJSON).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/research.test.js b/tests/unit/scripts/modules/task-manager/research.test.js new file mode 100644 index 00000000..28899bf3 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/research.test.js @@ -0,0 +1,663 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + findProjectRoot: jest.fn(() => '/test/project/root'), + log: jest.fn(), + readJSON: jest.fn(), + flattenTasksWithSubtasks: jest.fn(() => []), + isEmpty: jest.fn(() => false) +})); + +// Mock UI-affecting external libs to minimal no-op implementations +jest.unstable_mockModule('chalk', () => ({ + default: { + white: Object.assign( + jest.fn((text) => text), + { + bold: jest.fn((text) => text) + } + ), + cyan: Object.assign( + jest.fn((text) => text), + { + bold: jest.fn((text) => text) + } + ), + green: Object.assign( + jest.fn((text) => text), + { + bold: jest.fn((text) => text) + } + ), + yellow: jest.fn((text) => text), + red: jest.fn((text) => text), + gray: jest.fn((text) => text), + blue: Object.assign( + jest.fn((text) => text), + { + bold: jest.fn((text) => text) + } + ), + bold: jest.fn((text) => text) + } +})); + +jest.unstable_mockModule('boxen', () => ({ default: (text) => text })); + +jest.unstable_mockModule('inquirer', () => ({ + default: { prompt: jest.fn() } +})); + +jest.unstable_mockModule('cli-highlight', () => ({ + highlight: (code) => code +})); + +jest.unstable_mockModule('cli-table3', () => ({ + default: jest.fn().mockImplementation(() => ({ + push: jest.fn(), + toString: jest.fn(() => '') + })) +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/contextGatherer.js', + () => ({ + ContextGatherer: jest.fn().mockImplementation(() => ({ + gather: jest.fn().mockResolvedValue({ + context: 'Gathered context', + tokenBreakdown: { total: 500 } + }), + countTokens: jest.fn(() => 100) + })) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/fuzzyTaskSearch.js', + () => ({ + FuzzyTaskSearch: jest.fn().mockImplementation(() => ({ + findRelevantTasks: jest.fn(() => []), + getTaskIds: jest.fn(() => []) + })) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/ai-services-unified.js', + () => ({ + generateTextService: jest.fn().mockResolvedValue({ + mainResult: + 'Test research result with ```javascript\nconsole.log("test");\n```', + telemetryData: {} + }) + }) +); + +jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + displayAiUsageSummary: jest.fn(), + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), + stopLoadingIndicator: jest.fn() +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/prompt-manager.js', + () => ({ + getPromptManager: jest.fn().mockReturnValue({ + loadPrompt: jest.fn().mockResolvedValue({ + systemPrompt: 'System prompt', + userPrompt: 'User prompt' + }) + }) + }) +); + +const { performResearch } = await import( + '../../../../../scripts/modules/task-manager/research.js' +); + +// Import mocked modules for testing +const utils = await import('../../../../../scripts/modules/utils.js'); +const { ContextGatherer } = await import( + '../../../../../scripts/modules/utils/contextGatherer.js' +); +const { FuzzyTaskSearch } = await import( + '../../../../../scripts/modules/utils/fuzzyTaskSearch.js' +); +const { generateTextService } = await import( + '../../../../../scripts/modules/ai-services-unified.js' +); + +describe('performResearch project root validation', () => { + it('throws error when project root cannot be determined', async () => { + // Mock findProjectRoot to return null + utils.findProjectRoot.mockReturnValueOnce(null); + + await expect( + performResearch('Test query', {}, {}, 'json', false) + ).rejects.toThrow('Could not determine project root directory'); + }); +}); + +describe('performResearch tag-aware functionality', () => { + let mockContextGatherer; + let mockFuzzySearch; + let mockReadJSON; + let mockFlattenTasks; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Set up default mocks + utils.findProjectRoot.mockReturnValue('/test/project/root'); + utils.readJSON.mockResolvedValue({ + tasks: [ + { id: 1, title: 'Task 1', description: 'Description 1' }, + { id: 2, title: 'Task 2', description: 'Description 2' } + ] + }); + utils.flattenTasksWithSubtasks.mockReturnValue([ + { id: 1, title: 'Task 1', description: 'Description 1' }, + { id: 2, title: 'Task 2', description: 'Description 2' } + ]); + + // Set up ContextGatherer mock + mockContextGatherer = { + gather: jest.fn().mockResolvedValue({ + context: 'Gathered context', + tokenBreakdown: { total: 500 } + }), + countTokens: jest.fn(() => 100) + }; + ContextGatherer.mockImplementation(() => mockContextGatherer); + + // Set up FuzzyTaskSearch mock + mockFuzzySearch = { + findRelevantTasks: jest.fn(() => [ + { id: 1, title: 'Task 1', description: 'Description 1' } + ]), + getTaskIds: jest.fn(() => ['1']) + }; + FuzzyTaskSearch.mockImplementation(() => mockFuzzySearch); + + // Store references for easier access + mockReadJSON = utils.readJSON; + mockFlattenTasks = utils.flattenTasksWithSubtasks; + }); + + describe('tag parameter passing to ContextGatherer', () => { + it('passes tag parameter to ContextGatherer constructor', async () => { + const testTag = 'feature-branch'; + + await performResearch('Test query', { tag: testTag }, {}, 'json', false); + + expect(ContextGatherer).toHaveBeenCalledWith( + '/test/project/root', + testTag + ); + }); + + it('passes undefined tag when no tag is provided', async () => { + await performResearch('Test query', {}, {}, 'json', false); + + expect(ContextGatherer).toHaveBeenCalledWith( + '/test/project/root', + undefined + ); + }); + + it('passes empty string tag when empty string is provided', async () => { + await performResearch('Test query', { tag: '' }, {}, 'json', false); + + expect(ContextGatherer).toHaveBeenCalledWith('/test/project/root', ''); + }); + + it('passes null tag when null is provided', async () => { + await performResearch('Test query', { tag: null }, {}, 'json', false); + + expect(ContextGatherer).toHaveBeenCalledWith('/test/project/root', null); + }); + }); + + describe('tag-aware readJSON calls', () => { + it('calls readJSON with correct tag parameter for task discovery', async () => { + const testTag = 'development'; + + await performResearch('Test query', { tag: testTag }, {}, 'json', false); + + expect(mockReadJSON).toHaveBeenCalledWith( + expect.stringContaining('tasks.json'), + '/test/project/root', + testTag + ); + }); + + it('calls readJSON with undefined tag when no tag provided', async () => { + await performResearch('Test query', {}, {}, 'json', false); + + expect(mockReadJSON).toHaveBeenCalledWith( + expect.stringContaining('tasks.json'), + '/test/project/root', + undefined + ); + }); + + it('calls readJSON with provided projectRoot and tag', async () => { + const customProjectRoot = '/custom/project/root'; + const testTag = 'production'; + + await performResearch( + 'Test query', + { + projectRoot: customProjectRoot, + tag: testTag + }, + {}, + 'json', + false + ); + + expect(mockReadJSON).toHaveBeenCalledWith( + expect.stringContaining('tasks.json'), + customProjectRoot, + testTag + ); + }); + }); + + describe('context gathering behavior for different tags', () => { + it('calls contextGatherer.gather with correct parameters', async () => { + const options = { + taskIds: ['1', '2'], + filePaths: ['src/file.js'], + customContext: 'Custom context', + includeProjectTree: true, + tag: 'feature-branch' + }; + + await performResearch('Test query', options, {}, 'json', false); + + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: expect.arrayContaining(['1', '2']), + files: ['src/file.js'], + customContext: 'Custom context', + includeProjectTree: true, + format: 'research', + includeTokenCounts: true + }); + }); + + it('handles empty task discovery gracefully when readJSON fails', async () => { + mockReadJSON.mockRejectedValueOnce(new Error('File not found')); + + const result = await performResearch( + 'Test query', + { tag: 'test-tag' }, + {}, + 'json', + false + ); + + // Should still succeed even if task discovery fails + expect(result).toBeDefined(); + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: [], + files: [], + customContext: '', + includeProjectTree: false, + format: 'research', + includeTokenCounts: true + }); + }); + + it('combines provided taskIds with auto-discovered tasks', async () => { + const providedTaskIds = ['3', '4']; + const autoDiscoveredIds = ['1', '2']; + + mockFuzzySearch.getTaskIds.mockReturnValue(autoDiscoveredIds); + + await performResearch( + 'Test query', + { + taskIds: providedTaskIds, + tag: 'feature-branch' + }, + {}, + 'json', + false + ); + + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: expect.arrayContaining([ + ...providedTaskIds, + ...autoDiscoveredIds + ]), + files: [], + customContext: '', + includeProjectTree: false, + format: 'research', + includeTokenCounts: true + }); + }); + + it('removes duplicate tasks when auto-discovered tasks overlap with provided tasks', async () => { + const providedTaskIds = ['1', '2']; + const autoDiscoveredIds = ['2', '3']; // '2' is duplicate + + mockFuzzySearch.getTaskIds.mockReturnValue(autoDiscoveredIds); + + await performResearch( + 'Test query', + { + taskIds: providedTaskIds, + tag: 'feature-branch' + }, + {}, + 'json', + false + ); + + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: ['1', '2', '3'], // Should include '3' but not duplicate '2' + files: [], + customContext: '', + includeProjectTree: false, + format: 'research', + includeTokenCounts: true + }); + }); + }); + + describe('tag-aware fuzzy search', () => { + it('initializes FuzzyTaskSearch with flattened tasks from correct tag', async () => { + const testTag = 'development'; + const mockFlattenedTasks = [ + { id: 1, title: 'Dev Task 1' }, + { id: 2, title: 'Dev Task 2' } + ]; + + mockFlattenTasks.mockReturnValue(mockFlattenedTasks); + + await performResearch('Test query', { tag: testTag }, {}, 'json', false); + + expect(mockFlattenTasks).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }) + ]) + ); + expect(FuzzyTaskSearch).toHaveBeenCalledWith( + mockFlattenedTasks, + 'research' + ); + }); + + it('calls fuzzy search with correct parameters', async () => { + const testQuery = 'authentication implementation'; + + await performResearch( + testQuery, + { tag: 'feature-branch' }, + {}, + 'json', + false + ); + + expect(mockFuzzySearch.findRelevantTasks).toHaveBeenCalledWith( + testQuery, + { + maxResults: 8, + includeRecent: true, + includeCategoryMatches: true + } + ); + }); + + it('handles empty tasks data gracefully', async () => { + mockReadJSON.mockResolvedValueOnce({ tasks: [] }); + + await performResearch( + 'Test query', + { tag: 'empty-tag' }, + {}, + 'json', + false + ); + + // Should not call FuzzyTaskSearch when no tasks exist + expect(FuzzyTaskSearch).not.toHaveBeenCalled(); + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: [], + files: [], + customContext: '', + includeProjectTree: false, + format: 'research', + includeTokenCounts: true + }); + }); + + it('handles null tasks data gracefully', async () => { + mockReadJSON.mockResolvedValueOnce(null); + + await performResearch( + 'Test query', + { tag: 'null-tag' }, + {}, + 'json', + false + ); + + // Should not call FuzzyTaskSearch when data is null + expect(FuzzyTaskSearch).not.toHaveBeenCalled(); + }); + }); + + describe('error handling for invalid tags', () => { + it('continues execution when readJSON throws error for invalid tag', async () => { + mockReadJSON.mockRejectedValueOnce(new Error('Tag not found')); + + const result = await performResearch( + 'Test query', + { tag: 'invalid-tag' }, + {}, + 'json', + false + ); + + // Should still succeed and return a result + expect(result).toBeDefined(); + expect(mockContextGatherer.gather).toHaveBeenCalled(); + }); + + it('logs debug message when task discovery fails', async () => { + const mockLog = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() + }; + + mockReadJSON.mockRejectedValueOnce(new Error('File not found')); + + await performResearch( + 'Test query', + { tag: 'error-tag' }, + { mcpLog: mockLog }, + 'json', + false + ); + + expect(mockLog.debug).toHaveBeenCalledWith( + expect.stringContaining('Could not auto-discover tasks') + ); + }); + + it('handles ContextGatherer constructor errors gracefully', async () => { + ContextGatherer.mockImplementationOnce(() => { + throw new Error('Invalid tag provided'); + }); + + await expect( + performResearch('Test query', { tag: 'invalid-tag' }, {}, 'json', false) + ).rejects.toThrow('Invalid tag provided'); + }); + + it('handles ContextGatherer.gather errors gracefully', async () => { + mockContextGatherer.gather.mockRejectedValueOnce( + new Error('Gather failed') + ); + + await expect( + performResearch( + 'Test query', + { tag: 'gather-error-tag' }, + {}, + 'json', + false + ) + ).rejects.toThrow('Gather failed'); + }); + }); + + describe('MCP integration with tags', () => { + it('uses MCP logger when mcpLog is provided in context', async () => { + const mockMCPLog = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() + }; + + mockReadJSON.mockRejectedValueOnce(new Error('Test error')); + + await performResearch( + 'Test query', + { tag: 'mcp-tag' }, + { mcpLog: mockMCPLog }, + 'json', + false + ); + + expect(mockMCPLog.debug).toHaveBeenCalledWith( + expect.stringContaining('Could not auto-discover tasks') + ); + }); + + it('passes session to generateTextService when provided', async () => { + const mockSession = { userId: 'test-user', env: {} }; + + await performResearch( + 'Test query', + { tag: 'session-tag' }, + { session: mockSession }, + 'json', + false + ); + + expect(generateTextService).toHaveBeenCalledWith( + expect.objectContaining({ + session: mockSession + }) + ); + }); + }); + + describe('output format handling with tags', () => { + it('displays UI banner only in text format', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await performResearch('Test query', { tag: 'ui-tag' }, {}, 'text', false); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('🔍 AI Research Query') + ); + + consoleSpy.mockRestore(); + }); + + it('does not display UI banner in json format', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await performResearch('Test query', { tag: 'ui-tag' }, {}, 'json', false); + + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('🔍 AI Research Query') + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('comprehensive tag integration test', () => { + it('performs complete research flow with tag-aware functionality', async () => { + const testOptions = { + taskIds: ['1', '2'], + filePaths: ['src/main.js'], + customContext: 'Testing tag integration', + includeProjectTree: true, + detailLevel: 'high', + tag: 'integration-test', + projectRoot: '/custom/root' + }; + + const testContext = { + session: { userId: 'test-user' }, + mcpLog: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() + }, + commandName: 'test-research', + outputType: 'mcp' + }; + + // Mock successful task discovery + mockFuzzySearch.getTaskIds.mockReturnValue(['3', '4']); + + const result = await performResearch( + 'Integration test query', + testOptions, + testContext, + 'json', + false + ); + + // Verify ContextGatherer was initialized with correct tag + expect(ContextGatherer).toHaveBeenCalledWith( + '/custom/root', + 'integration-test' + ); + + // Verify readJSON was called with correct parameters + expect(mockReadJSON).toHaveBeenCalledWith( + expect.stringContaining('tasks.json'), + '/custom/root', + 'integration-test' + ); + + // Verify context gathering was called with combined tasks + expect(mockContextGatherer.gather).toHaveBeenCalledWith({ + tasks: ['1', '2', '3', '4'], + files: ['src/main.js'], + customContext: 'Testing tag integration', + includeProjectTree: true, + format: 'research', + includeTokenCounts: true + }); + + // Verify AI service was called with session + expect(generateTextService).toHaveBeenCalledWith( + expect.objectContaining({ + session: testContext.session, + role: 'research' + }) + ); + + expect(result).toBeDefined(); + }); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/set-task-status.test.js b/tests/unit/scripts/modules/task-manager/set-task-status.test.js index 6e252b00..72e75b95 100644 --- a/tests/unit/scripts/modules/task-manager/set-task-status.test.js +++ b/tests/unit/scripts/modules/task-manager/set-task-status.test.js @@ -234,11 +234,12 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '2', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -271,11 +272,12 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '3.1', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -308,11 +310,12 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '1,2', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined); + expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(writeJSON).toHaveBeenCalledWith( tasksPath, expect.objectContaining({ @@ -341,6 +344,7 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '3', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); @@ -379,7 +383,10 @@ describe('setTaskStatus', () => { // Act & Assert await expect( - setTaskStatus(tasksPath, '99', 'done', { mcpLog: { info: jest.fn() } }) + setTaskStatus(tasksPath, '99', 'done', { + tag: 'master', + mcpLog: { info: jest.fn() } + }) ).rejects.toThrow('Task 99 not found'); }); @@ -418,7 +425,10 @@ describe('setTaskStatus', () => { // Act & Assert await expect( - setTaskStatus(tasksPath, '3.1', 'done', { mcpLog: { info: jest.fn() } }) + setTaskStatus(tasksPath, '3.1', 'done', { + tag: 'master', + mcpLog: { info: jest.fn() } + }) ).rejects.toThrow('has no subtasks'); }); @@ -435,7 +445,10 @@ describe('setTaskStatus', () => { // Act & Assert await expect( - setTaskStatus(tasksPath, '3.99', 'done', { mcpLog: { info: jest.fn() } }) + setTaskStatus(tasksPath, '3.99', 'done', { + tag: 'master', + mcpLog: { info: jest.fn() } + }) ).rejects.toThrow('Subtask 99 not found'); }); @@ -492,6 +505,7 @@ describe('setTaskStatus', () => { // Act const result = await setTaskStatus(tasksPath, taskIds, newStatus, { + tag: 'master', mcpLog: { info: jest.fn() } }); @@ -555,6 +569,7 @@ describe('setTaskStatus', () => { // Act await setTaskStatus(tasksPath, '1', 'done', { + tag: 'master', mcpLog: { info: jest.fn() } }); diff --git a/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js b/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js new file mode 100644 index 00000000..89027ed4 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js @@ -0,0 +1,201 @@ +import { jest } from '@jest/globals'; + +// Provide fs mock early so existsSync can be stubbed +jest.unstable_mockModule('fs', () => { + const mockFs = { + existsSync: jest.fn(() => true), + writeFileSync: jest.fn(), + readFileSync: jest.fn(), + unlinkSync: jest.fn() + }; + return { default: mockFs, ...mockFs }; +}); + +// --- Mock dependencies --- +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + isSilentMode: jest.fn(() => false), + findProjectRoot: jest.fn(() => '/project'), + flattenTasksWithSubtasks: jest.fn(() => []), + truncate: jest.fn((t) => t), + isEmpty: jest.fn(() => false), + resolveEnvVariable: jest.fn(), + findTaskById: jest.fn(), + getCurrentTag: jest.fn(() => 'master') +})); + +jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + getStatusWithColor: jest.fn((s) => s), + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), + stopLoadingIndicator: jest.fn(), + displayAiUsageSummary: jest.fn() +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn().mockResolvedValue() + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/ai-services-unified.js', + () => ({ + generateTextService: jest + .fn() + .mockResolvedValue({ mainResult: { content: '' }, telemetryData: {} }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/config-manager.js', + () => ({ + getDebugFlag: jest.fn(() => false) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/prompt-manager.js', + () => ({ + default: jest.fn().mockReturnValue({ + loadPrompt: jest.fn().mockReturnValue('Update the subtask') + }), + getPromptManager: jest.fn().mockReturnValue({ + loadPrompt: jest.fn().mockReturnValue('Update the subtask') + }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/contextGatherer.js', + () => ({ + ContextGatherer: jest.fn().mockImplementation(() => ({ + gather: jest.fn().mockReturnValue({ + fullContext: '', + summary: '' + }) + })) + }) +); + +// Import mocked utils to leverage mocks later +const { readJSON, log } = await import( + '../../../../../scripts/modules/utils.js' +); + +// Import function under test +const { default: updateSubtaskById } = await import( + '../../../../../scripts/modules/task-manager/update-subtask-by-id.js' +); + +describe('updateSubtaskById validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + test('throws error on invalid subtask id format', async () => { + await expect( + updateSubtaskById( + 'tasks/tasks.json', + 'invalid', + 'my prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Invalid subtask ID format'); + }); + + test('throws error when prompt is empty', async () => { + await expect( + updateSubtaskById( + 'tasks/tasks.json', + '1.1', + '', + false, + { tag: 'master' }, + 'json' + ) + ).rejects.toThrow('Prompt cannot be empty'); + }); + + test('throws error if tasks file does not exist', async () => { + // Mock fs.existsSync to return false via jest.spyOn (dynamic import of fs) + const fs = await import('fs'); + fs.existsSync.mockReturnValue(false); + await expect( + updateSubtaskById( + 'tasks/tasks.json', + '1.1', + 'prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Tasks file not found'); + }); + + test('throws error if parent task missing', async () => { + // Mock existsSync true + const fs = await import('fs'); + fs.existsSync.mockReturnValue(true); + // readJSON returns tasks without parent id 1 + readJSON.mockReturnValue({ tag: 'master', tasks: [] }); + await expect( + updateSubtaskById( + 'tasks/tasks.json', + '1.1', + 'prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Parent task with ID 1 not found'); + // log called with error level + expect(log).toHaveBeenCalled(); + }); + + test('successfully updates subtask with valid inputs', async () => { + const fs = await import('fs'); + const { writeJSON } = await import( + '../../../../../scripts/modules/utils.js' + ); + + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ + tag: 'master', + tasks: [ + { + id: 1, + title: 'Parent Task', + subtasks: [{ id: 1, title: 'Original subtask', status: 'pending' }] + } + ] + }); + + // updateSubtaskById doesn't return a value on success, it just executes + await expect( + updateSubtaskById( + 'tasks/tasks.json', + '1.1', + 'Update this subtask', + false, + { tag: 'master' }, + 'json' + ) + ).resolves.not.toThrow(); + + expect(writeJSON).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js b/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js new file mode 100644 index 00000000..bc3842c1 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js @@ -0,0 +1,121 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('fs', () => { + const mockFs = { + existsSync: jest.fn(() => true), + writeFileSync: jest.fn(), + readFileSync: jest.fn(), + unlinkSync: jest.fn() + }; + return { default: mockFs, ...mockFs }; +}); + +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + readJSON: jest.fn(), + writeJSON: jest.fn(), + log: jest.fn(), + isSilentMode: jest.fn(() => false), + findProjectRoot: jest.fn(() => '/project'), + flattenTasksWithSubtasks: jest.fn(() => []), + truncate: jest.fn((t) => t), + isEmpty: jest.fn(() => false), + resolveEnvVariable: jest.fn(), + findTaskById: jest.fn(), + getCurrentTag: jest.fn(() => 'master') +})); + +jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + getStatusWithColor: jest.fn((s) => s), + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), + stopLoadingIndicator: jest.fn(), + displayAiUsageSummary: jest.fn() +})); + +jest.unstable_mockModule( + '../../../../../scripts/modules/task-manager/generate-task-files.js', + () => ({ + default: jest.fn().mockResolvedValue() + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/ai-services-unified.js', + () => ({ + generateTextService: jest + .fn() + .mockResolvedValue({ mainResult: { content: '{}' }, telemetryData: {} }) + }) +); + +jest.unstable_mockModule( + '../../../../../scripts/modules/config-manager.js', + () => ({ + getDebugFlag: jest.fn(() => false), + isApiKeySet: jest.fn(() => true) + }) +); + +const { readJSON, log } = await import( + '../../../../../scripts/modules/utils.js' +); +const { default: updateTaskById } = await import( + '../../../../../scripts/modules/task-manager/update-task-by-id.js' +); + +describe('updateTaskById validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + test('throws error if prompt is empty', async () => { + await expect( + updateTaskById( + 'tasks/tasks.json', + 1, + '', + false, + { tag: 'master' }, + 'json' + ) + ).rejects.toThrow('Prompt cannot be empty'); + }); + + test('throws error if task file missing', async () => { + const fs = await import('fs'); + fs.existsSync.mockReturnValue(false); + await expect( + updateTaskById( + 'tasks/tasks.json', + 1, + 'prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Tasks file not found'); + }); + + test('throws error when task ID not found', async () => { + const fs = await import('fs'); + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ tag: 'master', tasks: [] }); + await expect( + updateTaskById( + 'tasks/tasks.json', + 42, + 'prompt', + false, + { + tag: 'master' + }, + 'json' + ) + ).rejects.toThrow('Task with ID 42 not found'); + expect(log).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/scripts/modules/task-manager/update-tasks.test.js b/tests/unit/scripts/modules/task-manager/update-tasks.test.js index 09cc226b..f3be5e75 100644 --- a/tests/unit/scripts/modules/task-manager/update-tasks.test.js +++ b/tests/unit/scripts/modules/task-manager/update-tasks.test.js @@ -171,7 +171,7 @@ describe('updateTasks', () => { mockFromId, mockPrompt, false, // research - { projectRoot: '/mock/path' }, // context + { projectRoot: '/mock/path', tag: 'master' }, // context 'json' // output format ); @@ -241,7 +241,7 @@ describe('updateTasks', () => { mockFromId, mockPrompt, false, - { projectRoot: '/mock/path' }, + { projectRoot: '/mock/path', tag: 'master' }, 'json' ); diff --git a/tests/unit/task-manager/clear-subtasks.test.js b/tests/unit/task-manager/clear-subtasks.test.js new file mode 100644 index 00000000..e56c2293 --- /dev/null +++ b/tests/unit/task-manager/clear-subtasks.test.js @@ -0,0 +1,53 @@ +import fs from 'fs'; +import path from 'path'; +import clearSubtasks from '../../../scripts/modules/task-manager/clear-subtasks.js'; + +const TMP = path.join(process.cwd(), '.tmp_clear_subtasks'); +const TASKS = path.join(TMP, 'tasks.json'); + +function seed() { + fs.rmSync(TMP, { recursive: true, force: true }); + fs.mkdirSync(path.join(TMP, '.taskmaster'), { recursive: true }); + fs.writeFileSync( + TASKS, + JSON.stringify( + { + master: { + tasks: [ + { + id: 1, + title: 'Parent', + subtasks: [ + { id: 1, title: 'Sub1' }, + { id: 2, title: 'Sub2' } + ] + }, + { id: 2, title: 'Solo' } + ], + metadata: { created: new Date().toISOString() } + } + }, + null, + 2 + ) + ); +} + +describe('clearSubtasks', () => { + beforeEach(seed); + afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); + + it('clears subtasks for given task id', () => { + clearSubtasks(TASKS, '1', { projectRoot: TMP, tag: 'master' }); + const data = JSON.parse(fs.readFileSync(TASKS, 'utf8')); + const parent = data.master.tasks.find((t) => t.id === 1); + expect(parent.subtasks.length).toBe(0); + }); + + it('does nothing when task has no subtasks', () => { + clearSubtasks(TASKS, '2', { projectRoot: TMP, tag: 'master' }); + const data = JSON.parse(fs.readFileSync(TASKS, 'utf8')); + const solo = data.master.tasks.find((t) => t.id === 2); + expect(solo.subtasks).toBeUndefined(); + }); +}); diff --git a/tests/unit/task-manager/move-task.test.js b/tests/unit/task-manager/move-task.test.js new file mode 100644 index 00000000..8b2fb2a6 --- /dev/null +++ b/tests/unit/task-manager/move-task.test.js @@ -0,0 +1,54 @@ +import fs from 'fs'; +import path from 'path'; +import moveTask from '../../../scripts/modules/task-manager/move-task.js'; + +const TMP = path.join(process.cwd(), '.tmp_move_task'); +const TASKS = path.join(TMP, 'tasks.json'); + +function seed(initialTasks) { + fs.rmSync(TMP, { recursive: true, force: true }); + fs.mkdirSync(path.join(TMP, '.taskmaster'), { recursive: true }); + fs.writeFileSync( + TASKS, + JSON.stringify( + { + master: { + tasks: initialTasks, + metadata: { created: new Date().toISOString() } + } + }, + null, + 2 + ) + ); +} + +describe('moveTask basic scenarios', () => { + afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); + + it('moves a task to a new ID within same tag', async () => { + seed([ + { id: 1, title: 'A' }, + { id: 2, title: 'B' } + ]); + + await moveTask(TASKS, '1', '3', false, { projectRoot: TMP, tag: 'master' }); + + const data = JSON.parse(fs.readFileSync(TASKS, 'utf8')); + const ids = data.master.tasks.map((t) => t.id); + expect(ids).toEqual(expect.arrayContaining([2, 3])); + expect(ids).not.toContain(1); + }); + + it('refuses to move across tags', async () => { + // build dual-tag structure + seed([{ id: 1, title: 'task' }]); + const raw = JSON.parse(fs.readFileSync(TASKS, 'utf8')); + raw.other = { tasks: [], metadata: { created: new Date().toISOString() } }; + fs.writeFileSync(TASKS, JSON.stringify(raw, null, 2)); + + await expect( + moveTask(TASKS, '1', '2', false, { projectRoot: TMP, tag: 'other' }) + ).rejects.toThrow(/Source task/); + }); +}); diff --git a/tests/unit/task-manager/tag-boundary.test.js b/tests/unit/task-manager/tag-boundary.test.js new file mode 100644 index 00000000..86d9e937 --- /dev/null +++ b/tests/unit/task-manager/tag-boundary.test.js @@ -0,0 +1,278 @@ +import fs from 'fs'; +import path from 'path'; +import { + createTag, + useTag, + deleteTag +} from '../../../scripts/modules/task-manager/tag-management.js'; + +// Temporary workspace for each test run +const TEMP_DIR = path.join(process.cwd(), '.tmp_tag_boundary'); +const TASKS_PATH = path.join(TEMP_DIR, 'tasks.json'); +const STATE_PATH = path.join(TEMP_DIR, '.taskmaster', 'state.json'); + +function seedWorkspace() { + // Reset temp dir + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + fs.mkdirSync(path.join(TEMP_DIR, '.taskmaster'), { + recursive: true, + force: true + }); + + // Minimal master tag file + fs.writeFileSync( + TASKS_PATH, + JSON.stringify( + { + master: { + tasks: [{ id: 1, title: 'Seed task', status: 'pending' }], + metadata: { created: new Date().toISOString() } + } + }, + null, + 2 + ), + 'utf8' + ); + + // Initial state.json + fs.writeFileSync( + STATE_PATH, + JSON.stringify( + { currentTag: 'master', lastSwitched: new Date().toISOString() }, + null, + 2 + ), + 'utf8' + ); +} + +describe('Tag boundary resolution', () => { + beforeEach(seedWorkspace); + afterAll(() => fs.rmSync(TEMP_DIR, { recursive: true, force: true })); + + it('switches currentTag in state.json when useTag succeeds', async () => { + await createTag( + TASKS_PATH, + 'feature-x', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + await useTag( + TASKS_PATH, + 'feature-x', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + + const state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); + expect(state.currentTag).toBe('feature-x'); + }); + + it('throws error when switching to non-existent tag', async () => { + await expect( + useTag(TASKS_PATH, 'ghost', {}, { projectRoot: TEMP_DIR }, 'json') + ).rejects.toThrow(/does not exist/); + }); + + it('deleting active tag auto-switches back to master', async () => { + await createTag(TASKS_PATH, 'temp', {}, { projectRoot: TEMP_DIR }, 'json'); + await useTag(TASKS_PATH, 'temp', {}, { projectRoot: TEMP_DIR }, 'json'); + + // Delete the active tag with force flag (yes: true) + await deleteTag( + TASKS_PATH, + 'temp', + { yes: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + const state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); + expect(state.currentTag).toBe('master'); + + const tasksFile = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(tasksFile.temp).toBeUndefined(); + expect(tasksFile.master).toBeDefined(); + }); + + it('createTag with copyFromCurrent deep-copies tasks (mutation isolated)', async () => { + // create new tag with copy + await createTag( + TASKS_PATH, + 'alpha', + { copyFromCurrent: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + // mutate a field inside alpha tasks + const updatedData = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + updatedData.alpha.tasks[0].title = 'Changed in alpha'; + fs.writeFileSync(TASKS_PATH, JSON.stringify(updatedData, null, 2)); + + const finalData = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(finalData.master.tasks[0].title).toBe('Seed task'); + expect(finalData.alpha.tasks[0].title).toBe('Changed in alpha'); + }); + + it('addTask to non-master tag does not leak into master', async () => { + // create and switch + await createTag( + TASKS_PATH, + 'feature-api', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + + // Call addTask with manual data to avoid AI + const { default: addTask } = await import( + '../../../scripts/modules/task-manager/add-task.js' + ); + + await addTask( + TASKS_PATH, + 'Manual task', + [], + null, + { projectRoot: TEMP_DIR, tag: 'feature-api' }, + 'json', + { + title: 'API work', + description: 'Implement endpoint', + details: 'Details', + testStrategy: 'Tests' + }, + false + ); + + const data = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(data['feature-api'].tasks.length).toBe(1); // the new task only + expect(data.master.tasks.length).toBe(1); // still only seed + }); + + it('reserved tag names are rejected', async () => { + await expect( + createTag(TASKS_PATH, 'master', {}, { projectRoot: TEMP_DIR }, 'json') + ).rejects.toThrow(/reserved tag/i); + }); + + it('cannot delete the master tag', async () => { + await expect( + deleteTag( + TASKS_PATH, + 'master', + { yes: true }, + { projectRoot: TEMP_DIR }, + 'json' + ) + ).rejects.toThrow(/Cannot delete the "master" tag/); + }); + + it('copyTag deep copy – mutation does not affect source', async () => { + const { copyTag } = await import( + '../../../scripts/modules/task-manager/tag-management.js' + ); + + await createTag( + TASKS_PATH, + 'source', + { copyFromCurrent: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + await copyTag( + TASKS_PATH, + 'source', + 'clone', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + + // mutate clone task title + const data1 = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + data1.clone.tasks[0].title = 'Modified in clone'; + fs.writeFileSync(TASKS_PATH, JSON.stringify(data1, null, 2)); + + const data2 = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(data2.source.tasks[0].title).toBe('Seed task'); + expect(data2.clone.tasks[0].title).toBe('Modified in clone'); + }); + + it('adds task to tag derived from state.json when no explicit tag supplied', async () => { + // Create new tag and update state.json to make it current + await createTag( + TASKS_PATH, + 'feature-auto', + {}, + { projectRoot: TEMP_DIR }, + 'json' + ); + const state1 = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); + state1.currentTag = 'feature-auto'; + fs.writeFileSync(STATE_PATH, JSON.stringify(state1, null, 2)); + + const { default: addTask } = await import( + '../../../scripts/modules/task-manager/add-task.js' + ); + const { resolveTag } = await import('../../../scripts/modules/utils.js'); + + const tag = resolveTag({ projectRoot: TEMP_DIR }); + + // Add task without passing tag -> should resolve to feature-auto + await addTask( + TASKS_PATH, + 'Auto task', + [], + null, + { projectRoot: TEMP_DIR, tag }, + 'json', + { + title: 'Auto task', + description: '-', + details: '-', + testStrategy: '-' + }, + false + ); + + const data = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(data['feature-auto'].tasks.length).toBe(1); + expect(data.master.tasks.length).toBe(1); // master unchanged + }); + + it('falls back to master when state.json lacks currentTag', async () => { + // wipe currentTag field + fs.writeFileSync(STATE_PATH, JSON.stringify({}, null, 2)); + + const { default: addTask } = await import( + '../../../scripts/modules/task-manager/add-task.js' + ); + const { resolveTag } = await import('../../../scripts/modules/utils.js'); + + const tag = resolveTag({ projectRoot: TEMP_DIR }); // should return master + + await addTask( + TASKS_PATH, + 'Fallback task', + [], + null, + { projectRoot: TEMP_DIR, tag }, + 'json', + { + title: 'Fallback', + description: '-', + details: '-', + testStrategy: '-' + }, + false + ); + + const data = JSON.parse(fs.readFileSync(TASKS_PATH, 'utf8')); + expect(data.master.tasks.length).toBe(2); // seed + new task + }); +}); diff --git a/tests/unit/task-master.test.js b/tests/unit/task-master.test.js index c3b9c48b..89655e4f 100644 --- a/tests/unit/task-master.test.js +++ b/tests/unit/task-master.test.js @@ -432,8 +432,10 @@ describe('initTaskMaster', () => { path.join(taskMasterDir, 'state.json') ); // PRD and complexity report paths are undefined when not provided - expect(taskMaster.getPrdPath()).toBeUndefined(); - expect(taskMaster.getComplexityReportPath()).toBeUndefined(); + expect(typeof taskMaster.getComplexityReportPath()).toBe('string'); + expect(taskMaster.getComplexityReportPath()).toMatch( + /task-complexity-report\.json$/ + ); }); }); }); From 858d4a1c5486d20e7e3a8e37e3329d7fb8200310 Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Fri, 18 Jul 2025 13:06:56 -0400 Subject: [PATCH 18/23] fix: Update VS Code profile with MCP config transformation (#971) * remove dash in server name * add OLLAMA_API_KEY to VS Code MCP instructions * transform vscode mcp to correct format * add changeset * switch back to task-master-ai * use task-master-ai --- .changeset/gentle-beds-beam.md | 5 + src/profiles/vscode.js | 165 ++++++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 .changeset/gentle-beds-beam.md diff --git a/.changeset/gentle-beds-beam.md b/.changeset/gentle-beds-beam.md new file mode 100644 index 00000000..fa3fe2f3 --- /dev/null +++ b/.changeset/gentle-beds-beam.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Update VS Code profile with MCP config transformation diff --git a/src/profiles/vscode.js b/src/profiles/vscode.js index a5c0757c..06e28797 100644 --- a/src/profiles/vscode.js +++ b/src/profiles/vscode.js @@ -1,6 +1,162 @@ // VS Code conversion profile for rule-transformer +import path from 'path'; +import fs from 'fs'; +import { log } from '../../scripts/modules/utils.js'; import { createProfile, COMMON_TOOL_MAPPINGS } from './base-profile.js'; +/** + * Transform standard MCP config format to VS Code format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed VS Code configuration object + */ +function transformToVSCodeFormat(mcpConfig) { + const vscodeConfig = {}; + + // Transform mcpServers to servers + if (mcpConfig.mcpServers) { + vscodeConfig.servers = {}; + + for (const [serverName, serverConfig] of Object.entries( + mcpConfig.mcpServers + )) { + // Transform server configuration + const transformedServer = { + ...serverConfig + }; + + // Add type: "stdio" after the env block + if (transformedServer.env) { + // Reorder properties: keep command, args, env, then add type + const reorderedServer = {}; + if (transformedServer.command) + reorderedServer.command = transformedServer.command; + if (transformedServer.args) + reorderedServer.args = transformedServer.args; + if (transformedServer.env) reorderedServer.env = transformedServer.env; + reorderedServer.type = 'stdio'; + + // Add any other properties that might exist + Object.keys(transformedServer).forEach((key) => { + if (!['command', 'args', 'env', 'type'].includes(key)) { + reorderedServer[key] = transformedServer[key]; + } + }); + + vscodeConfig.servers[serverName] = reorderedServer; + } else { + // If no env block, just add type at the end + transformedServer.type = 'stdio'; + vscodeConfig.servers[serverName] = transformedServer; + } + } + } + + return vscodeConfig; +} + +/** + * Lifecycle function called after MCP config generation to transform to VS Code format + * @param {string} targetDir - Target project directory + * @param {string} assetsDir - Assets directory (unused for VS Code) + */ +function onPostConvertRulesProfile(targetDir, assetsDir) { + const vscodeConfigPath = path.join(targetDir, '.vscode', 'mcp.json'); + + if (!fs.existsSync(vscodeConfigPath)) { + log('debug', '[VS Code] No .vscode/mcp.json found to transform'); + return; + } + + try { + // Read the generated standard MCP config + const mcpConfigContent = fs.readFileSync(vscodeConfigPath, 'utf8'); + const mcpConfig = JSON.parse(mcpConfigContent); + + // Check if it's already in VS Code format (has servers instead of mcpServers) + if (mcpConfig.servers) { + log( + 'info', + '[VS Code] mcp.json already in VS Code format, skipping transformation' + ); + return; + } + + // Transform to VS Code format + const vscodeConfig = transformToVSCodeFormat(mcpConfig); + + // Write back the transformed config with proper formatting + fs.writeFileSync( + vscodeConfigPath, + JSON.stringify(vscodeConfig, null, 2) + '\n' + ); + + log('info', '[VS Code] Transformed mcp.json to VS Code format'); + log('debug', `[VS Code] Renamed mcpServers->servers, added type: "stdio"`); + } catch (error) { + log('error', `[VS Code] Failed to transform mcp.json: ${error.message}`); + } +} + +/** + * Lifecycle function called when removing VS Code profile + * @param {string} targetDir - Target project directory + */ +function onRemoveRulesProfile(targetDir) { + const vscodeConfigPath = path.join(targetDir, '.vscode', 'mcp.json'); + + if (!fs.existsSync(vscodeConfigPath)) { + log('debug', '[VS Code] No .vscode/mcp.json found to clean up'); + return; + } + + try { + // Read the current config + const configContent = fs.readFileSync(vscodeConfigPath, 'utf8'); + const config = JSON.parse(configContent); + + // Check if it has the servers section and task-master-ai server + if (config.servers && config.servers['task-master-ai']) { + // Remove task-master-ai server + delete config.servers['task-master-ai']; + + // Check if there are other MCP servers + const remainingServers = Object.keys(config.servers); + + if (remainingServers.length === 0) { + // No other servers, remove entire file + fs.rmSync(vscodeConfigPath, { force: true }); + log('info', '[VS Code] Removed empty mcp.json file'); + + // Also remove .vscode directory if it's empty + const vscodeDir = path.dirname(vscodeConfigPath); + try { + const dirContents = fs.readdirSync(vscodeDir); + if (dirContents.length === 0) { + fs.rmSync(vscodeDir, { recursive: true, force: true }); + log('debug', '[VS Code] Removed empty .vscode directory'); + } + } catch (err) { + // Directory might not be empty or might not exist, that's fine + } + } else { + // Write back the modified config + fs.writeFileSync( + vscodeConfigPath, + JSON.stringify(config, null, 2) + '\n' + ); + log( + 'info', + '[VS Code] Removed TaskMaster from mcp.json, preserved other configurations' + ); + } + } else { + log('debug', '[VS Code] TaskMaster not found in mcp.json'); + } + } catch (error) { + log('error', `[VS Code] Failed to clean up mcp.json: ${error.message}`); + } +} + // Create and export vscode profile using the base factory export const vscodeProfile = createProfile({ name: 'vscode', @@ -8,6 +164,8 @@ export const vscodeProfile = createProfile({ url: 'code.visualstudio.com', docsUrl: 'code.visualstudio.com/docs', rulesDir: '.github/instructions', // VS Code instructions location + profileDir: '.vscode', // VS Code configuration directory + mcpConfigName: 'mcp.json', // VS Code uses mcp.json in .vscode directory customReplacements: [ // Core VS Code directory structure changes { from: /\.cursor\/rules/g, to: '.github/instructions' }, @@ -28,5 +186,10 @@ export const vscodeProfile = createProfile({ // VS Code specific terminology { from: /rules directory/g, to: 'instructions directory' }, { from: /cursor rules/gi, to: 'VS Code instructions' } - ] + ], + onPostConvert: onPostConvertRulesProfile, + onRemove: onRemoveRulesProfile }); + +// Export lifecycle functions separately to avoid naming conflicts +export { onPostConvertRulesProfile, onRemoveRulesProfile }; From 444aa5ae1943ba72d012b3f01b1cc9362a328248 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Sat, 19 Jul 2025 00:57:47 +0300 Subject: [PATCH 19/23] Batch fixes before release (#1011) * fix: improve projectRoot * fix: improve task-master lang command * feat: add documentation to the readme so more people can access it * fix: expand command subtask dependency validation * fix: update command more reliable with perplexity and other models * chore: fix CI * chore: implement requested changes * chore: fix CI --- .changeset/beige-windows-clean.md | 5 + .changeset/blue-rocks-clean.md | 7 ++ .changeset/blue-rocks-dirty.md | 7 ++ .changeset/early-parts-throw.md | 5 + .changeset/two-pots-move.md | 7 ++ README.md | 8 +- scripts/modules/commands.js | 6 +- scripts/modules/task-manager/expand-task.js | 16 ++-- scripts/modules/task-manager/models.js | 16 ++-- scripts/modules/task-manager/parse-prd.js | 1 - .../modules/task-manager/response-language.js | 2 +- scripts/modules/task-manager/update-tasks.js | 92 ++++++++++++++----- src/prompts/expand-task.json | 8 +- src/prompts/update-tasks.json | 4 +- src/task-master.js | 26 +----- src/utils/path-utils.js | 8 +- tests/unit/task-master.test.js | 13 ++- 17 files changed, 148 insertions(+), 83 deletions(-) create mode 100644 .changeset/beige-windows-clean.md create mode 100644 .changeset/blue-rocks-clean.md create mode 100644 .changeset/blue-rocks-dirty.md create mode 100644 .changeset/early-parts-throw.md create mode 100644 .changeset/two-pots-move.md diff --git a/.changeset/beige-windows-clean.md b/.changeset/beige-windows-clean.md new file mode 100644 index 00000000..a09be2f6 --- /dev/null +++ b/.changeset/beige-windows-clean.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": minor +--- + +Created a comprehensive documentation site for Task Master AI. Visit https://docs.task-master.dev to explore guides, API references, and examples. diff --git a/.changeset/blue-rocks-clean.md b/.changeset/blue-rocks-clean.md new file mode 100644 index 00000000..576c44fe --- /dev/null +++ b/.changeset/blue-rocks-clean.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": patch +--- + +Make `task-master update` more reliable with AI responses + +The `update` command now handles AI responses more robustly. If the AI forgets to include certain task fields, the command will automatically fill in the missing data from your original tasks instead of failing. This means smoother bulk task updates without losing important information like IDs, dependencies, or completed subtasks. diff --git a/.changeset/blue-rocks-dirty.md b/.changeset/blue-rocks-dirty.md new file mode 100644 index 00000000..3686b8d5 --- /dev/null +++ b/.changeset/blue-rocks-dirty.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": patch +--- + +Fix subtask dependency validation when expanding tasks + +When using `task-master expand` to break down tasks into subtasks, dependencies between subtasks are now properly validated. Previously, subtasks with dependencies would fail validation. Now subtasks can correctly depend on their siblings within the same parent task. diff --git a/.changeset/early-parts-throw.md b/.changeset/early-parts-throw.md new file mode 100644 index 00000000..06524be0 --- /dev/null +++ b/.changeset/early-parts-throw.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Fix `task-master lang --setup` breaking when no language is defined, now defaults to English diff --git a/.changeset/two-pots-move.md b/.changeset/two-pots-move.md new file mode 100644 index 00000000..7b9620bd --- /dev/null +++ b/.changeset/two-pots-move.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": minor +--- + +Improve project root detection + +- No longer creates an infinite loop when unable to detect your code workspace \ No newline at end of file diff --git a/README.md b/README.md index 180d2a56..1e1a3bbc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,13 @@ A task management system for AI-driven development with Claude, designed to work ## Documentation -For more detailed information, check out the documentation in the `docs` directory: +📚 **[View Full Documentation](https://docs.task-master.dev)** + +For detailed guides, API references, and comprehensive examples, visit our documentation site. + +### Quick Reference + +The following documentation is also available in the `docs` directory: - [Configuration Guide](docs/configuration.md) - Set up environment variables and customize Task Master - [Tutorial](docs/tutorial.md) - Step-by-step guide to getting started with Task Master diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index d5f0e55e..25469ee5 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -3727,10 +3727,7 @@ Examples: const taskMaster = initTaskMaster({}); const projectRoot = taskMaster.getProjectRoot(); // Find project root for context const { response, setup } = options; - console.log( - chalk.blue('Response language set to:', JSON.stringify(options)) - ); - let responseLanguage = response || 'English'; + let responseLanguage = response !== undefined ? response : 'English'; if (setup) { console.log( chalk.blue('Starting interactive response language setup...') @@ -3772,6 +3769,7 @@ Examples: `❌ Error setting response language: ${result.error.message}` ) ); + process.exit(1); } }); diff --git a/scripts/modules/task-manager/expand-task.js b/scripts/modules/task-manager/expand-task.js index f8ba362d..150e4b5d 100644 --- a/scripts/modules/task-manager/expand-task.js +++ b/scripts/modules/task-manager/expand-task.js @@ -40,8 +40,10 @@ const subtaskSchema = z .min(10) .describe('Detailed description of the subtask'), dependencies: z - .array(z.number().int()) - .describe('IDs of prerequisite subtasks within this expansion'), + .array(z.string()) + .describe( + 'Array of subtask dependencies within the same parent task. Use format ["parentTaskId.1", "parentTaskId.2"]. Subtasks can only depend on siblings, not external tasks.' + ), details: z.string().min(20).describe('Implementation details and guidance'), status: z .string() @@ -235,12 +237,10 @@ function parseSubtasksFromText( ...rawSubtask, id: currentId, dependencies: Array.isArray(rawSubtask.dependencies) - ? rawSubtask.dependencies - .map((dep) => (typeof dep === 'string' ? parseInt(dep, 10) : dep)) - .filter( - (depId) => - !Number.isNaN(depId) && depId >= startId && depId < currentId - ) + ? rawSubtask.dependencies.filter( + (dep) => + typeof dep === 'string' && dep.startsWith(`${parentTaskId}.`) + ) : [], status: 'pending' }; diff --git a/scripts/modules/task-manager/models.js b/scripts/modules/task-manager/models.js index 229dda5b..d3b06119 100644 --- a/scripts/modules/task-manager/models.js +++ b/scripts/modules/task-manager/models.js @@ -25,6 +25,10 @@ import { findConfigPath } from '../../../src/utils/path-utils.js'; import { log } from '../utils.js'; import { CUSTOM_PROVIDERS } from '../../../src/constants/providers.js'; +// Constants +const CONFIG_MISSING_ERROR = + 'The configuration file is missing. Run "task-master init" to create it.'; + /** * Fetches the list of models from OpenRouter API. * @returns {Promise} A promise that resolves with the list of model IDs or null if fetch fails. @@ -168,9 +172,7 @@ async function getModelConfiguration(options = {}) { ); if (!configExists) { - throw new Error( - 'The configuration file is missing. Run "task-master models --setup" to create it.' - ); + throw new Error(CONFIG_MISSING_ERROR); } try { @@ -298,9 +300,7 @@ async function getAvailableModelsList(options = {}) { ); if (!configExists) { - throw new Error( - 'The configuration file is missing. Run "task-master models --setup" to create it.' - ); + throw new Error(CONFIG_MISSING_ERROR); } try { @@ -391,9 +391,7 @@ async function setModel(role, modelId, options = {}) { ); if (!configExists) { - throw new Error( - 'The configuration file is missing. Run "task-master models --setup" to create it.' - ); + throw new Error(CONFIG_MISSING_ERROR); } // Validate role diff --git a/scripts/modules/task-manager/parse-prd.js b/scripts/modules/task-manager/parse-prd.js index dcaf567c..33d8bdcb 100644 --- a/scripts/modules/task-manager/parse-prd.js +++ b/scripts/modules/task-manager/parse-prd.js @@ -19,7 +19,6 @@ import { import { generateObjectService } from '../ai-services-unified.js'; import { getDebugFlag } from '../config-manager.js'; import { getPromptManager } from '../prompt-manager.js'; -import generateTaskFiles from './generate-task-files.js'; import { displayAiUsageSummary } from '../ui.js'; // Define the Zod schema for a SINGLE task object diff --git a/scripts/modules/task-manager/response-language.js b/scripts/modules/task-manager/response-language.js index 90be4c1a..8823a740 100644 --- a/scripts/modules/task-manager/response-language.js +++ b/scripts/modules/task-manager/response-language.js @@ -34,7 +34,7 @@ function setResponseLanguage(lang, options = {}) { error: { code: 'CONFIG_MISSING', message: - 'The configuration file is missing. Run "task-master models --setup" to create it.' + 'The configuration file is missing. Run "task-master init" to create it.' } }; } diff --git a/scripts/modules/task-manager/update-tasks.js b/scripts/modules/task-manager/update-tasks.js index 726872ee..66c6a65c 100644 --- a/scripts/modules/task-manager/update-tasks.js +++ b/scripts/modules/task-manager/update-tasks.js @@ -42,7 +42,39 @@ const updatedTaskSchema = z subtasks: z.array(z.any()).nullable() // Keep subtasks flexible for now }) .strip(); // Allow potential extra fields during parsing if needed, then validate structure + +// Preprocessing schema that adds defaults before validation +const preprocessTaskSchema = z.preprocess((task) => { + // Ensure task is an object + if (typeof task !== 'object' || task === null) { + return {}; + } + + // Return task with defaults for missing fields + return { + ...task, + // Add defaults for required fields if missing + id: task.id ?? 0, + title: task.title ?? 'Untitled Task', + description: task.description ?? '', + status: task.status ?? 'pending', + dependencies: Array.isArray(task.dependencies) ? task.dependencies : [], + // Optional fields - preserve undefined/null distinction + priority: task.hasOwnProperty('priority') ? task.priority : null, + details: task.hasOwnProperty('details') ? task.details : null, + testStrategy: task.hasOwnProperty('testStrategy') + ? task.testStrategy + : null, + subtasks: Array.isArray(task.subtasks) + ? task.subtasks + : task.subtasks === null + ? null + : [] + }; +}, updatedTaskSchema); + const updatedTaskArraySchema = z.array(updatedTaskSchema); +const preprocessedTaskArraySchema = z.array(preprocessTaskSchema); /** * Parses an array of task objects from AI's text response. @@ -195,32 +227,50 @@ function parseUpdatedTasksFromText(text, expectedCount, logFn, isMCP) { ); } - // Preprocess tasks to ensure required fields have proper defaults - const preprocessedTasks = parsedTasks.map((task) => ({ - ...task, - // Ensure subtasks is always an array (not null or undefined) - subtasks: Array.isArray(task.subtasks) ? task.subtasks : [], - // Ensure status has a default value if missing - status: task.status || 'pending', - // Ensure dependencies is always an array - dependencies: Array.isArray(task.dependencies) ? task.dependencies : [] - })); + // Log missing fields for debugging before preprocessing + let hasWarnings = false; + parsedTasks.forEach((task, index) => { + const missingFields = []; + if (!task.hasOwnProperty('id')) missingFields.push('id'); + if (!task.hasOwnProperty('status')) missingFields.push('status'); + if (!task.hasOwnProperty('dependencies')) + missingFields.push('dependencies'); - const validationResult = updatedTaskArraySchema.safeParse(preprocessedTasks); - if (!validationResult.success) { - report('error', 'Parsed task array failed Zod validation.'); - validationResult.error.errors.forEach((err) => { - report('error', ` - Path '${err.path.join('.')}': ${err.message}`); - }); - throw new Error( - `AI response failed task structure validation: ${validationResult.error.message}` + if (missingFields.length > 0) { + hasWarnings = true; + report( + 'warn', + `Task ${index} is missing fields: ${missingFields.join(', ')} - will use defaults` + ); + } + }); + + if (hasWarnings) { + report( + 'warn', + 'Some tasks were missing required fields. Applying defaults...' ); } - report('info', 'Successfully validated task structure.'); - return validationResult.data.slice( + // Use the preprocessing schema to add defaults and validate + const preprocessResult = preprocessedTaskArraySchema.safeParse(parsedTasks); + + if (!preprocessResult.success) { + // This should rarely happen now since preprocessing adds defaults + report('error', 'Failed to validate task array even after preprocessing.'); + preprocessResult.error.errors.forEach((err) => { + report('error', ` - Path '${err.path.join('.')}': ${err.message}`); + }); + + throw new Error( + `AI response failed validation: ${preprocessResult.error.message}` + ); + } + + report('info', 'Successfully validated and transformed task structure.'); + return preprocessResult.data.slice( 0, - expectedCount || validationResult.data.length + expectedCount || preprocessResult.data.length ); } diff --git a/src/prompts/expand-task.json b/src/prompts/expand-task.json index b3d1748b..827bdf88 100644 --- a/src/prompts/expand-task.json +++ b/src/prompts/expand-task.json @@ -56,17 +56,17 @@ "prompts": { "complexity-report": { "condition": "expansionPrompt", - "system": "You are an AI assistant helping with task breakdown. Generate {{#if (gt subtaskCount 0)}}exactly {{subtaskCount}}{{else}}an appropriate number of{{/if}} subtasks based on the provided prompt and context.\nRespond ONLY with a valid JSON object containing a single key \"subtasks\" whose value is an array of the generated subtask objects.\nEach subtask object in the array must have keys: \"id\", \"title\", \"description\", \"dependencies\", \"details\", \"status\".\nEnsure the 'id' starts from {{nextSubtaskId}} and is sequential.\nEnsure 'dependencies' only reference valid prior subtask IDs generated in this response (starting from {{nextSubtaskId}}).\nEnsure 'status' is 'pending'.\nDo not include any other text or explanation.", + "system": "You are an AI assistant helping with task breakdown. Generate {{#if (gt subtaskCount 0)}}exactly {{subtaskCount}}{{else}}an appropriate number of{{/if}} subtasks based on the provided prompt and context.\nRespond ONLY with a valid JSON object containing a single key \"subtasks\" whose value is an array of the generated subtask objects.\nEach subtask object in the array must have keys: \"id\", \"title\", \"description\", \"dependencies\", \"details\", \"status\".\nEnsure the 'id' starts from {{nextSubtaskId}} and is sequential.\nFor 'dependencies', use the full subtask ID format: \"{{task.id}}.1\", \"{{task.id}}.2\", etc. Only reference subtasks within this same task.\nEnsure 'status' is 'pending'.\nDo not include any other text or explanation.", "user": "{{expansionPrompt}}{{#if additionalContext}}\n\n{{additionalContext}}{{/if}}{{#if complexityReasoningContext}}\n\n{{complexityReasoningContext}}{{/if}}{{#if gatheredContext}}\n\n# Project Context\n\n{{gatheredContext}}{{/if}}" }, "research": { "condition": "useResearch === true && !expansionPrompt", "system": "You are an AI assistant that responds ONLY with valid JSON objects as requested. The object should contain a 'subtasks' array.", - "user": "Analyze the following task and break it down into {{#if (gt subtaskCount 0)}}exactly {{subtaskCount}}{{else}}an appropriate number of{{/if}} specific subtasks using your research capabilities. Assign sequential IDs starting from {{nextSubtaskId}}.\n\nParent Task:\nID: {{task.id}}\nTitle: {{task.title}}\nDescription: {{task.description}}\nCurrent details: {{#if task.details}}{{task.details}}{{else}}None{{/if}}{{#if additionalContext}}\nConsider this context: {{additionalContext}}{{/if}}{{#if complexityReasoningContext}}\nComplexity Analysis Reasoning: {{complexityReasoningContext}}{{/if}}{{#if gatheredContext}}\n\n# Project Context\n\n{{gatheredContext}}{{/if}}\n\nCRITICAL: Respond ONLY with a valid JSON object containing a single key \"subtasks\". The value must be an array of the generated subtasks, strictly matching this structure:\n\n{\n \"subtasks\": [\n {\n \"id\": , // Sequential ID starting from {{nextSubtaskId}}\n \"title\": \"\",\n \"description\": \"\",\n \"dependencies\": [], // e.g., [{{nextSubtaskId}} + 1]. If no dependencies, use an empty array [].\n \"details\": \"\",\n \"testStrategy\": \"\" // Optional\n },\n // ... (repeat for {{#if (gt subtaskCount 0)}}{{subtaskCount}}{{else}}appropriate number of{{/if}} subtasks)\n ]\n}\n\nImportant: For the 'dependencies' field, if a subtask has no dependencies, you MUST use an empty array, for example: \"dependencies\": []. Do not use null or omit the field.\n\nDo not include ANY explanatory text, markdown, or code block markers. Just the JSON object." + "user": "Analyze the following task and break it down into {{#if (gt subtaskCount 0)}}exactly {{subtaskCount}}{{else}}an appropriate number of{{/if}} specific subtasks using your research capabilities. Assign sequential IDs starting from {{nextSubtaskId}}.\n\nParent Task:\nID: {{task.id}}\nTitle: {{task.title}}\nDescription: {{task.description}}\nCurrent details: {{#if task.details}}{{task.details}}{{else}}None{{/if}}{{#if additionalContext}}\nConsider this context: {{additionalContext}}{{/if}}{{#if complexityReasoningContext}}\nComplexity Analysis Reasoning: {{complexityReasoningContext}}{{/if}}{{#if gatheredContext}}\n\n# Project Context\n\n{{gatheredContext}}{{/if}}\n\nCRITICAL: Respond ONLY with a valid JSON object containing a single key \"subtasks\". The value must be an array of the generated subtasks, strictly matching this structure:\n\n{\n \"subtasks\": [\n {\n \"id\": , // Sequential ID starting from {{nextSubtaskId}}\n \"title\": \"\",\n \"description\": \"\",\n \"dependencies\": [\"\"], // Use full subtask IDs like [\"{{task.id}}.1\", \"{{task.id}}.2\"]. If no dependencies, use an empty array [].\n \"details\": \"\",\n \"testStrategy\": \"\" // Optional\n },\n // ... (repeat for {{#if (gt subtaskCount 0)}}{{subtaskCount}}{{else}}appropriate number of{{/if}} subtasks)\n ]\n}\n\nImportant: For the 'dependencies' field, if a subtask has no dependencies, you MUST use an empty array, for example: \"dependencies\": []. Do not use null or omit the field.\n\nDo not include ANY explanatory text, markdown, or code block markers. Just the JSON object." }, "default": { - "system": "You are an AI assistant helping with task breakdown for software development.\nYou need to break down a high-level task into {{#if (gt subtaskCount 0)}}{{subtaskCount}}{{else}}an appropriate number of{{/if}} specific subtasks that can be implemented one by one.\n\nSubtasks should:\n1. Be specific and actionable implementation steps\n2. Follow a logical sequence\n3. Each handle a distinct part of the parent task\n4. Include clear guidance on implementation approach\n5. Have appropriate dependency chains between subtasks (using the new sequential IDs)\n6. Collectively cover all aspects of the parent task\n\nFor each subtask, provide:\n- id: Sequential integer starting from the provided nextSubtaskId\n- title: Clear, specific title\n- description: Detailed description\n- dependencies: Array of prerequisite subtask IDs (use the new sequential IDs)\n- details: Implementation details, the output should be in string\n- testStrategy: Optional testing approach\n\nRespond ONLY with a valid JSON object containing a single key \"subtasks\" whose value is an array matching the structure described. Do not include any explanatory text, markdown formatting, or code block markers.", - "user": "Break down this task into {{#if (gt subtaskCount 0)}}exactly {{subtaskCount}}{{else}}an appropriate number of{{/if}} specific subtasks:\n\nTask ID: {{task.id}}\nTitle: {{task.title}}\nDescription: {{task.description}}\nCurrent details: {{#if task.details}}{{task.details}}{{else}}None{{/if}}{{#if additionalContext}}\nAdditional context: {{additionalContext}}{{/if}}{{#if complexityReasoningContext}}\nComplexity Analysis Reasoning: {{complexityReasoningContext}}{{/if}}{{#if gatheredContext}}\n\n# Project Context\n\n{{gatheredContext}}{{/if}}\n\nReturn ONLY the JSON object containing the \"subtasks\" array, matching this structure:\n\n{\n \"subtasks\": [\n {\n \"id\": {{nextSubtaskId}}, // First subtask ID\n \"title\": \"Specific subtask title\",\n \"description\": \"Detailed description\",\n \"dependencies\": [], // e.g., [{{nextSubtaskId}} + 1] if it depends on the next\n \"details\": \"Implementation guidance\",\n \"testStrategy\": \"Optional testing approach\"\n },\n // ... (repeat for {{#if (gt subtaskCount 0)}}a total of {{subtaskCount}}{{else}}an appropriate number of{{/if}} subtasks with sequential IDs)\n ]\n}" + "system": "You are an AI assistant helping with task breakdown for software development.\nYou need to break down a high-level task into {{#if (gt subtaskCount 0)}}{{subtaskCount}}{{else}}an appropriate number of{{/if}} specific subtasks that can be implemented one by one.\n\nSubtasks should:\n1. Be specific and actionable implementation steps\n2. Follow a logical sequence\n3. Each handle a distinct part of the parent task\n4. Include clear guidance on implementation approach\n5. Have appropriate dependency chains between subtasks (using full subtask IDs)\n6. Collectively cover all aspects of the parent task\n\nFor each subtask, provide:\n- id: Sequential integer starting from the provided nextSubtaskId\n- title: Clear, specific title\n- description: Detailed description\n- dependencies: Array of prerequisite subtask IDs using full format like [\"{{task.id}}.1\", \"{{task.id}}.2\"]\n- details: Implementation details, the output should be in string\n- testStrategy: Optional testing approach\n\nRespond ONLY with a valid JSON object containing a single key \"subtasks\" whose value is an array matching the structure described. Do not include any explanatory text, markdown formatting, or code block markers.", + "user": "Break down this task into {{#if (gt subtaskCount 0)}}exactly {{subtaskCount}}{{else}}an appropriate number of{{/if}} specific subtasks:\n\nTask ID: {{task.id}}\nTitle: {{task.title}}\nDescription: {{task.description}}\nCurrent details: {{#if task.details}}{{task.details}}{{else}}None{{/if}}{{#if additionalContext}}\nAdditional context: {{additionalContext}}{{/if}}{{#if complexityReasoningContext}}\nComplexity Analysis Reasoning: {{complexityReasoningContext}}{{/if}}{{#if gatheredContext}}\n\n# Project Context\n\n{{gatheredContext}}{{/if}}\n\nReturn ONLY the JSON object containing the \"subtasks\" array, matching this structure:\n\n{\n \"subtasks\": [\n {\n \"id\": {{nextSubtaskId}}, // First subtask ID\n \"title\": \"Specific subtask title\",\n \"description\": \"Detailed description\",\n \"dependencies\": [], // e.g., [\"{{task.id}}.1\", \"{{task.id}}.2\"] for dependencies. Use empty array [] if no dependencies\n \"details\": \"Implementation guidance\",\n \"testStrategy\": \"Optional testing approach\"\n },\n // ... (repeat for {{#if (gt subtaskCount 0)}}a total of {{subtaskCount}}{{else}}an appropriate number of{{/if}} subtasks with sequential IDs)\n ]\n}" } } } diff --git a/src/prompts/update-tasks.json b/src/prompts/update-tasks.json index c5ba680a..6392580b 100644 --- a/src/prompts/update-tasks.json +++ b/src/prompts/update-tasks.json @@ -31,8 +31,8 @@ }, "prompts": { "default": { - "system": "You are an AI assistant helping to update software development tasks based on new context.\nYou will be given a set of tasks and a prompt describing changes or new implementation details.\nYour job is to update the tasks to reflect these changes, while preserving their basic structure.\n\nGuidelines:\n1. Maintain the same IDs, statuses, and dependencies unless specifically mentioned in the prompt\n2. Update titles, descriptions, details, and test strategies to reflect the new information\n3. Do not change anything unnecessarily - just adapt what needs to change based on the prompt\n4. You should return ALL the tasks in order, not just the modified ones\n5. Return a complete valid JSON object with the updated tasks array\n6. VERY IMPORTANT: Preserve all subtasks marked as \"done\" or \"completed\" - do not modify their content\n7. For tasks with completed subtasks, build upon what has already been done rather than rewriting everything\n8. If an existing completed subtask needs to be changed/undone based on the new context, DO NOT modify it directly\n9. Instead, add a new subtask that clearly indicates what needs to be changed or replaced\n10. Use the existence of completed subtasks as an opportunity to make new subtasks more specific and targeted\n\nThe changes described in the prompt should be applied to ALL tasks in the list.", - "user": "Here are the tasks to update:\n{{{json tasks}}}\n\nPlease update these tasks based on the following new context:\n{{updatePrompt}}\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 projectContext}}\n\n# Project Context\n\n{{projectContext}}{{/if}}\n\nReturn only the updated tasks as a valid JSON array." + "system": "You are an AI assistant helping to update software development tasks based on new context.\nYou will be given a set of tasks and a prompt describing changes or new implementation details.\nYour job is to update the tasks to reflect these changes, while preserving their basic structure.\n\nCRITICAL RULES:\n1. Return ONLY a JSON array - no explanations, no markdown, no additional text before or after\n2. Each task MUST have ALL fields from the original (do not omit any fields)\n3. Maintain the same IDs, statuses, and dependencies unless specifically mentioned in the prompt\n4. Update titles, descriptions, details, and test strategies to reflect the new information\n5. Do not change anything unnecessarily - just adapt what needs to change based on the prompt\n6. You should return ALL the tasks in order, not just the modified ones\n7. Return a complete valid JSON array with all tasks\n8. VERY IMPORTANT: Preserve all subtasks marked as \"done\" or \"completed\" - do not modify their content\n9. For tasks with completed subtasks, build upon what has already been done rather than rewriting everything\n10. If an existing completed subtask needs to be changed/undone based on the new context, DO NOT modify it directly\n11. Instead, add a new subtask that clearly indicates what needs to be changed or replaced\n12. Use the existence of completed subtasks as an opportunity to make new subtasks more specific and targeted\n\nThe changes described in the prompt should be applied to ALL tasks in the list.", + "user": "Here are the tasks to update:\n{{{json tasks}}}\n\nPlease update these tasks based on the following new context:\n{{updatePrompt}}\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 projectContext}}\n\n# Project Context\n\n{{projectContext}}{{/if}}\n\nRequired JSON structure for EACH task (ALL fields MUST be present):\n{\n \"id\": ,\n \"title\": ,\n \"description\": ,\n \"status\": ,\n \"dependencies\": ,\n \"priority\": ,\n \"details\": ,\n \"testStrategy\": ,\n \"subtasks\": \n}\n\nReturn a valid JSON array containing ALL the tasks with ALL their fields:\n- id (number) - preserve existing value\n- title (string)\n- description (string)\n- status (string) - preserve existing value unless explicitly changing\n- dependencies (array) - preserve existing value unless explicitly changing\n- priority (string or null)\n- details (string or null)\n- testStrategy (string or null)\n- subtasks (array or null)\n\nReturn ONLY the JSON array now:" } } } diff --git a/src/task-master.js b/src/task-master.js index 51be8140..a5a83661 100644 --- a/src/task-master.js +++ b/src/task-master.js @@ -17,6 +17,7 @@ import { LEGACY_CONFIG_FILE, COMPLEXITY_REPORT_FILE } from './constants/paths.js'; +import { findProjectRoot } from './utils/path-utils.js'; /** * TaskMaster class manages all the paths for the application. @@ -159,22 +160,6 @@ export class TaskMaster { * @returns {TaskMaster} An initialized TaskMaster instance. */ export function initTaskMaster(overrides = {}) { - const findProjectRoot = (startDir = process.cwd()) => { - const projectMarkers = [TASKMASTER_DIR, LEGACY_CONFIG_FILE]; - let currentDir = path.resolve(startDir); - const rootDir = path.parse(currentDir).root; - while (currentDir !== rootDir) { - for (const marker of projectMarkers) { - const markerPath = path.join(currentDir, marker); - if (fs.existsSync(markerPath)) { - return currentDir; - } - } - currentDir = path.dirname(currentDir); - } - return null; - }; - const resolvePath = ( pathType, override, @@ -264,13 +249,8 @@ export function initTaskMaster(overrides = {}) { paths.projectRoot = resolvedOverride; } else { - const foundRoot = findProjectRoot(); - if (!foundRoot) { - throw new Error( - 'Unable to find project root. No project markers found. Run "init" command first.' - ); - } - paths.projectRoot = foundRoot; + // findProjectRoot now always returns a value (fallback to cwd) + paths.projectRoot = findProjectRoot(); } // TaskMaster Directory diff --git a/src/utils/path-utils.js b/src/utils/path-utils.js index a50f9481..076a525e 100644 --- a/src/utils/path-utils.js +++ b/src/utils/path-utils.js @@ -66,8 +66,10 @@ export function findProjectRoot(startDir = process.cwd()) { let currentDir = path.resolve(startDir); const rootDir = path.parse(currentDir).root; + const maxDepth = 50; // Reasonable limit to prevent infinite loops + let depth = 0; - while (currentDir !== rootDir) { + while (currentDir !== rootDir && depth < maxDepth) { // Check if current directory contains any project markers for (const marker of projectMarkers) { const markerPath = path.join(currentDir, marker); @@ -76,9 +78,11 @@ export function findProjectRoot(startDir = process.cwd()) { } } currentDir = path.dirname(currentDir); + depth++; } - return null; + // Fallback to current working directory if no project root found + return process.cwd(); } /** diff --git a/tests/unit/task-master.test.js b/tests/unit/task-master.test.js index 89655e4f..5af6e465 100644 --- a/tests/unit/task-master.test.js +++ b/tests/unit/task-master.test.js @@ -109,16 +109,15 @@ describe('initTaskMaster', () => { expect(taskMaster.getProjectRoot()).toBe(tempDir); }); - test('should throw error when no project markers found', () => { + test('should return cwd when no project markers found cuz we changed the behavior of this function', () => { // Arrange - Empty temp directory, no project markers process.chdir(tempDir); - // Act & Assert - expect(() => { - initTaskMaster({}); - }).toThrow( - 'Unable to find project root. No project markers found. Run "init" command first.' - ); + // Act + const taskMaster = initTaskMaster({}); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); }); }); From b2841c261f89036c340bdcb2a64b9358b30a0125 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Sat, 19 Jul 2025 08:26:12 +0300 Subject: [PATCH 20/23] chore: fix changeset release for extension package (#1012) * chore: fix changeset release for extension package * chore: fix CI --- apps/extension/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/extension/package.json b/apps/extension/package.json index 93d20ec4..ff483de1 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,5 +1,6 @@ { "name": "extension", + "private": true, "version": "0.20.0", "main": "index.js", "scripts": { From b7804302a132c36d4152b8a53f35fcfd80af1ac4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 19 Jul 2025 05:27:11 +0000 Subject: [PATCH 21/23] chore: rc version bump --- .changeset/pre.json | 29 ++++++++++++++++++++ CHANGELOG.md | 67 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 7 +++-- 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..f1325524 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,29 @@ +{ + "mode": "exit", + "tag": "rc", + "initialVersions": { + "task-master-ai": "0.20.0", + "extension": "0.20.0" + }, + "changesets": [ + "add-kiro-profile", + "beige-windows-clean", + "blue-rocks-clean", + "blue-rocks-dirty", + "claude-import-fix-new", + "cool-glasses-invite", + "early-parts-throw", + "fix-show-command-complexity", + "gentle-beds-beam", + "groq-kimi-k2-support", + "metal-papers-stay", + "public-crabs-ask", + "puny-friends-give", + "swift-turtles-sit", + "ten-glasses-feel", + "two-pots-move", + "update-mcp-readme", + "yellow-showers-heal", + "yummy-walls-eat" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 48219747..f6f44114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # task-master-ai +## 0.21.0-rc.0 + +### Minor Changes + +- [#1001](https://github.com/eyaltoledano/claude-task-master/pull/1001) [`75a36ea`](https://github.com/eyaltoledano/claude-task-master/commit/75a36ea99a1c738a555bdd4fe7c763d0c5925e37) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Add Kiro editor rule profile support + - Add support for Kiro IDE with custom rule files and MCP configuration + - Generate rule files in `.kiro/steering/` directory with markdown format + - Include MCP server configuration with enhanced file inclusion patterns + +- [#1011](https://github.com/eyaltoledano/claude-task-master/pull/1011) [`3eb050a`](https://github.com/eyaltoledano/claude-task-master/commit/3eb050aaddb90fca1a04517e2ee24f73934323be) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Created a comprehensive documentation site for Task Master AI. Visit https://docs.task-master.dev to explore guides, API references, and examples. + +- [#978](https://github.com/eyaltoledano/claude-task-master/pull/978) [`fedfd6a`](https://github.com/eyaltoledano/claude-task-master/commit/fedfd6a0f41a78094f7ee7f69be689b699475a79) Thanks [@ben-vargas](https://github.com/ben-vargas)! - Complete Groq provider integration and add MoonshotAI Kimi K2 model support + - Fixed Groq provider registration + - Added Groq API key validation + - Added GROQ_API_KEY to .env.example + - Added moonshotai/kimi-k2-instruct model with $1/$3 per 1M token pricing and 16k max output + +- [#974](https://github.com/eyaltoledano/claude-task-master/pull/974) [`5b0eda0`](https://github.com/eyaltoledano/claude-task-master/commit/5b0eda07f20a365aa2ec1736eed102bca81763a9) Thanks [@joedanz](https://github.com/joedanz)! - feat: Add Zed editor rule profile with agent rules and MCP config + - Resolves #637 + +- [#973](https://github.com/eyaltoledano/claude-task-master/pull/973) [`6d05e86`](https://github.com/eyaltoledano/claude-task-master/commit/6d05e8622c1d761acef10414940ff9a766b3b57d) Thanks [@joedanz](https://github.com/joedanz)! - Add Amp rule profile with AGENT.md and MCP config + +- [#1011](https://github.com/eyaltoledano/claude-task-master/pull/1011) [`3eb050a`](https://github.com/eyaltoledano/claude-task-master/commit/3eb050aaddb90fca1a04517e2ee24f73934323be) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Improve project root detection + - No longer creates an infinite loop when unable to detect your code workspace + +- [#970](https://github.com/eyaltoledano/claude-task-master/pull/970) [`b87499b`](https://github.com/eyaltoledano/claude-task-master/commit/b87499b56e626001371a87ed56ffc72675d829f3) Thanks [@joedanz](https://github.com/joedanz)! - Add OpenCode profile with AGENTS.md and MCP config + - Resolves #965 + +### Patch Changes + +- [#1011](https://github.com/eyaltoledano/claude-task-master/pull/1011) [`3eb050a`](https://github.com/eyaltoledano/claude-task-master/commit/3eb050aaddb90fca1a04517e2ee24f73934323be) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Make `task-master update` more reliable with AI responses + + The `update` command now handles AI responses more robustly. If the AI forgets to include certain task fields, the command will automatically fill in the missing data from your original tasks instead of failing. This means smoother bulk task updates without losing important information like IDs, dependencies, or completed subtasks. + +- [#1011](https://github.com/eyaltoledano/claude-task-master/pull/1011) [`3eb050a`](https://github.com/eyaltoledano/claude-task-master/commit/3eb050aaddb90fca1a04517e2ee24f73934323be) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Fix subtask dependency validation when expanding tasks + + When using `task-master expand` to break down tasks into subtasks, dependencies between subtasks are now properly validated. Previously, subtasks with dependencies would fail validation. Now subtasks can correctly depend on their siblings within the same parent task. + +- [#949](https://github.com/eyaltoledano/claude-task-master/pull/949) [`f662654`](https://github.com/eyaltoledano/claude-task-master/commit/f662654afb8e7a230448655265d6f41adf6df62c) Thanks [@ben-vargas](https://github.com/ben-vargas)! - Prevent CLAUDE.md overwrite by using Claude Code's import feature + - Task Master now creates its instructions in `.taskmaster/CLAUDE.md` instead of overwriting the user's `CLAUDE.md` + - Adds an import section to the user's CLAUDE.md that references the Task Master instructions + - Preserves existing user content in CLAUDE.md files + - Provides clean uninstall that only removes Task Master's additions + + **Breaking Change**: Task Master instructions for Claude Code are now stored in `.taskmaster/CLAUDE.md` and imported into the main CLAUDE.md file. Users who previously had Task Master content directly in their CLAUDE.md will need to run `task-master rules remove claude` followed by `task-master rules add claude` to migrate to the new structure. + +- [#943](https://github.com/eyaltoledano/claude-task-master/pull/943) [`f98df5c`](https://github.com/eyaltoledano/claude-task-master/commit/f98df5c0fdb253b2b55d4278c11d626529c4dba4) Thanks [@mm-parthy](https://github.com/mm-parthy)! - Implement Boundary-First Tag Resolution to ensure consistent and deterministic tag handling across CLI and MCP, resolving potential race conditions. + +- [#1011](https://github.com/eyaltoledano/claude-task-master/pull/1011) [`3eb050a`](https://github.com/eyaltoledano/claude-task-master/commit/3eb050aaddb90fca1a04517e2ee24f73934323be) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Fix `task-master lang --setup` breaking when no language is defined, now defaults to English + +- [#979](https://github.com/eyaltoledano/claude-task-master/pull/979) [`ab2e946`](https://github.com/eyaltoledano/claude-task-master/commit/ab2e94608749a2f148118daa0443bd32bca6e7a1) Thanks [@ben-vargas](https://github.com/ben-vargas)! - Fix: show command no longer requires complexity report file to exist + + The `tm show` command was incorrectly requiring the complexity report file to exist even when not needed. Now it only validates the complexity report path when a custom report file is explicitly provided via the -r/--report option. + +- [#971](https://github.com/eyaltoledano/claude-task-master/pull/971) [`5544222`](https://github.com/eyaltoledano/claude-task-master/commit/55442226d0aa4870470d2a9897f5538d6a0e329e) Thanks [@joedanz](https://github.com/joedanz)! - Update VS Code profile with MCP config transformation + +- [#1002](https://github.com/eyaltoledano/claude-task-master/pull/1002) [`6d0654c`](https://github.com/eyaltoledano/claude-task-master/commit/6d0654cb4191cee794e1c8cbf2b92dc33d4fb410) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Fix MCP server error when retrieving tools and resources + +- [#980](https://github.com/eyaltoledano/claude-task-master/pull/980) [`cc4fe20`](https://github.com/eyaltoledano/claude-task-master/commit/cc4fe205fb468e7144c650acc92486df30731560) Thanks [@joedanz](https://github.com/joedanz)! - Add MCP configuration support to Claude Code rules + +- [#968](https://github.com/eyaltoledano/claude-task-master/pull/968) [`7b4803a`](https://github.com/eyaltoledano/claude-task-master/commit/7b4803a479105691c7ed032fd878fe3d48d82724) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Fixed the comprehensive taskmaster system integration via custom slash commands with proper syntax + - Provide claude clode with a complete set of of commands that can trigger task master events directly within Claude Code + +- [#995](https://github.com/eyaltoledano/claude-task-master/pull/995) [`b78de8d`](https://github.com/eyaltoledano/claude-task-master/commit/b78de8dbb4d6dc93b48e2f81c32960ef069736ed) Thanks [@joedanz](https://github.com/joedanz)! - Correct MCP server name and use 'Add to Cursor' button with updated placeholder keys. + +- [#972](https://github.com/eyaltoledano/claude-task-master/pull/972) [`1c7badf`](https://github.com/eyaltoledano/claude-task-master/commit/1c7badff2f5c548bfa90a3b2634e63087a382a84) Thanks [@joedanz](https://github.com/joedanz)! - Add missing API keys to .env.example and README.md + ## 0.20.0 ### Minor Changes diff --git a/package.json b/package.json index 7af991f2..c10b4b02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "task-master-ai", - "version": "0.20.0", + "version": "0.21.0-rc.0", "description": "A task management system for ambitious AI-driven development that doesn't overwhelm and confuse Cursor.", "main": "index.js", "type": "module", @@ -9,7 +9,10 @@ "task-master-mcp": "mcp-server/server.js", "task-master-ai": "mcp-server/server.js" }, - "workspaces": ["apps/*", "."], + "workspaces": [ + "apps/*", + "." + ], "scripts": { "test": "node --experimental-vm-modules node_modules/.bin/jest", "test:fails": "node --experimental-vm-modules node_modules/.bin/jest --onlyFailures", From 5b3dd3f29b98099342c0f9616000824b662fb43b Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:56:40 +0300 Subject: [PATCH 22/23] chore: adjust kimi k2 max tokens (#1014) --- package.json | 5 +---- scripts/modules/supported-models.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c10b4b02..9a004872 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,7 @@ "task-master-mcp": "mcp-server/server.js", "task-master-ai": "mcp-server/server.js" }, - "workspaces": [ - "apps/*", - "." - ], + "workspaces": ["apps/*", "."], "scripts": { "test": "node --experimental-vm-modules node_modules/.bin/jest", "test:fails": "node --experimental-vm-modules node_modules/.bin/jest --onlyFailures", diff --git a/scripts/modules/supported-models.json b/scripts/modules/supported-models.json index a321e6ac..f968bc31 100644 --- a/scripts/modules/supported-models.json +++ b/scripts/modules/supported-models.json @@ -303,7 +303,7 @@ "output": 3.0 }, "allowed_roles": ["main", "fallback"], - "max_tokens": 16384 + "max_tokens": 131072 }, { "id": "llama-3.3-70b-versatile", From d87a7f10767c2ef5279f4797c0b4faf7156ebf08 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 19 Jul 2025 10:56:52 +0000 Subject: [PATCH 23/23] docs: Auto-update and format models.md --- docs/models.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models.md b/docs/models.md index 733a9ca3..a5c903dc 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,4 +1,4 @@ -# Available Models as of July 16, 2025 +# Available Models as of July 19, 2025 ## Main Models