This is page 52 of 69. Use http://codebase.md/eyaltoledano/claude-task-master?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── commands
│ │ └── dedupe.md
│ └── TM_COMMANDS_GUIDE.md
├── .claude-plugin
│ └── marketplace.json
├── .coderabbit.yaml
├── .cursor
│ ├── mcp.json
│ └── rules
│ ├── ai_providers.mdc
│ ├── ai_services.mdc
│ ├── architecture.mdc
│ ├── changeset.mdc
│ ├── commands.mdc
│ ├── context_gathering.mdc
│ ├── cursor_rules.mdc
│ ├── dependencies.mdc
│ ├── dev_workflow.mdc
│ ├── git_workflow.mdc
│ ├── glossary.mdc
│ ├── mcp.mdc
│ ├── new_features.mdc
│ ├── self_improve.mdc
│ ├── tags.mdc
│ ├── taskmaster.mdc
│ ├── tasks.mdc
│ ├── telemetry.mdc
│ ├── test_workflow.mdc
│ ├── tests.mdc
│ ├── ui.mdc
│ └── utilities.mdc
├── .cursorignore
├── .env.example
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.md
│ │ ├── enhancements---feature-requests.md
│ │ └── feedback.md
│ ├── PULL_REQUEST_TEMPLATE
│ │ ├── bugfix.md
│ │ ├── config.yml
│ │ ├── feature.md
│ │ └── integration.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── scripts
│ │ ├── auto-close-duplicates.mjs
│ │ ├── backfill-duplicate-comments.mjs
│ │ ├── check-pre-release-mode.mjs
│ │ ├── parse-metrics.mjs
│ │ ├── release.mjs
│ │ ├── tag-extension.mjs
│ │ ├── utils.mjs
│ │ └── validate-changesets.mjs
│ └── workflows
│ ├── auto-close-duplicates.yml
│ ├── backfill-duplicate-comments.yml
│ ├── ci.yml
│ ├── claude-dedupe-issues.yml
│ ├── claude-docs-trigger.yml
│ ├── claude-docs-updater.yml
│ ├── claude-issue-triage.yml
│ ├── claude.yml
│ ├── extension-ci.yml
│ ├── extension-release.yml
│ ├── log-issue-events.yml
│ ├── pre-release.yml
│ ├── release-check.yml
│ ├── release.yml
│ ├── update-models-md.yml
│ └── weekly-metrics-discord.yml
├── .gitignore
├── .kiro
│ ├── hooks
│ │ ├── tm-code-change-task-tracker.kiro.hook
│ │ ├── tm-complexity-analyzer.kiro.hook
│ │ ├── tm-daily-standup-assistant.kiro.hook
│ │ ├── tm-git-commit-task-linker.kiro.hook
│ │ ├── tm-pr-readiness-checker.kiro.hook
│ │ ├── tm-task-dependency-auto-progression.kiro.hook
│ │ └── tm-test-success-task-completer.kiro.hook
│ ├── settings
│ │ └── mcp.json
│ └── steering
│ ├── dev_workflow.md
│ ├── kiro_rules.md
│ ├── self_improve.md
│ ├── taskmaster_hooks_workflow.md
│ └── taskmaster.md
├── .manypkg.json
├── .mcp.json
├── .npmignore
├── .nvmrc
├── .taskmaster
│ ├── CLAUDE.md
│ ├── config.json
│ ├── docs
│ │ ├── autonomous-tdd-git-workflow.md
│ │ ├── MIGRATION-ROADMAP.md
│ │ ├── prd-tm-start.txt
│ │ ├── prd.txt
│ │ ├── README.md
│ │ ├── research
│ │ │ ├── 2025-06-14_how-can-i-improve-the-scope-up-and-scope-down-comm.md
│ │ │ ├── 2025-06-14_should-i-be-using-any-specific-libraries-for-this.md
│ │ │ ├── 2025-06-14_test-save-functionality.md
│ │ │ ├── 2025-06-14_test-the-fix-for-duplicate-saves-final-test.md
│ │ │ └── 2025-08-01_do-we-need-to-add-new-commands-or-can-we-just-weap.md
│ │ ├── task-template-importing-prd.txt
│ │ ├── tdd-workflow-phase-0-spike.md
│ │ ├── tdd-workflow-phase-1-core-rails.md
│ │ ├── tdd-workflow-phase-1-orchestrator.md
│ │ ├── tdd-workflow-phase-2-pr-resumability.md
│ │ ├── tdd-workflow-phase-3-extensibility-guardrails.md
│ │ ├── test-prd.txt
│ │ └── tm-core-phase-1.txt
│ ├── reports
│ │ ├── task-complexity-report_autonomous-tdd-git-workflow.json
│ │ ├── task-complexity-report_cc-kiro-hooks.json
│ │ ├── task-complexity-report_tdd-phase-1-core-rails.json
│ │ ├── task-complexity-report_tdd-workflow-phase-0.json
│ │ ├── task-complexity-report_test-prd-tag.json
│ │ ├── task-complexity-report_tm-core-phase-1.json
│ │ ├── task-complexity-report.json
│ │ └── tm-core-complexity.json
│ ├── state.json
│ ├── tasks
│ │ ├── task_001_tm-start.txt
│ │ ├── task_002_tm-start.txt
│ │ ├── task_003_tm-start.txt
│ │ ├── task_004_tm-start.txt
│ │ ├── task_007_tm-start.txt
│ │ └── tasks.json
│ └── templates
│ ├── example_prd_rpg.md
│ └── example_prd.md
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── apps
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── command-registry.ts
│ │ │ ├── commands
│ │ │ │ ├── auth.command.ts
│ │ │ │ ├── autopilot
│ │ │ │ │ ├── abort.command.ts
│ │ │ │ │ ├── commit.command.ts
│ │ │ │ │ ├── complete.command.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── next.command.ts
│ │ │ │ │ ├── resume.command.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── start.command.ts
│ │ │ │ │ └── status.command.ts
│ │ │ │ ├── briefs.command.ts
│ │ │ │ ├── context.command.ts
│ │ │ │ ├── export.command.ts
│ │ │ │ ├── list.command.ts
│ │ │ │ ├── models
│ │ │ │ │ ├── custom-providers.ts
│ │ │ │ │ ├── fetchers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompts.ts
│ │ │ │ │ ├── setup.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── next.command.ts
│ │ │ │ ├── set-status.command.ts
│ │ │ │ ├── show.command.ts
│ │ │ │ ├── start.command.ts
│ │ │ │ └── tags.command.ts
│ │ │ ├── index.ts
│ │ │ ├── lib
│ │ │ │ └── model-management.ts
│ │ │ ├── types
│ │ │ │ └── tag-management.d.ts
│ │ │ ├── ui
│ │ │ │ ├── components
│ │ │ │ │ ├── cardBox.component.ts
│ │ │ │ │ ├── dashboard.component.ts
│ │ │ │ │ ├── header.component.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── next-task.component.ts
│ │ │ │ │ ├── suggested-steps.component.ts
│ │ │ │ │ └── task-detail.component.ts
│ │ │ │ ├── display
│ │ │ │ │ ├── messages.ts
│ │ │ │ │ └── tables.ts
│ │ │ │ ├── formatters
│ │ │ │ │ ├── complexity-formatters.ts
│ │ │ │ │ ├── dependency-formatters.ts
│ │ │ │ │ ├── priority-formatters.ts
│ │ │ │ │ ├── status-formatters.spec.ts
│ │ │ │ │ └── status-formatters.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── layout
│ │ │ │ ├── helpers.spec.ts
│ │ │ │ └── helpers.ts
│ │ │ └── utils
│ │ │ ├── auth-helpers.ts
│ │ │ ├── auto-update.ts
│ │ │ ├── brief-selection.ts
│ │ │ ├── display-helpers.ts
│ │ │ ├── error-handler.ts
│ │ │ ├── index.ts
│ │ │ ├── project-root.ts
│ │ │ ├── task-status.ts
│ │ │ ├── ui.spec.ts
│ │ │ └── ui.ts
│ │ ├── tests
│ │ │ ├── integration
│ │ │ │ └── commands
│ │ │ │ └── autopilot
│ │ │ │ └── workflow.test.ts
│ │ │ └── unit
│ │ │ ├── commands
│ │ │ │ ├── autopilot
│ │ │ │ │ └── shared.test.ts
│ │ │ │ ├── list.command.spec.ts
│ │ │ │ └── show.command.spec.ts
│ │ │ └── ui
│ │ │ └── dashboard.component.spec.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── docs
│ │ ├── archive
│ │ │ ├── ai-client-utils-example.mdx
│ │ │ ├── ai-development-workflow.mdx
│ │ │ ├── command-reference.mdx
│ │ │ ├── configuration.mdx
│ │ │ ├── cursor-setup.mdx
│ │ │ ├── examples.mdx
│ │ │ └── Installation.mdx
│ │ ├── best-practices
│ │ │ ├── advanced-tasks.mdx
│ │ │ ├── configuration-advanced.mdx
│ │ │ └── index.mdx
│ │ ├── capabilities
│ │ │ ├── cli-root-commands.mdx
│ │ │ ├── index.mdx
│ │ │ ├── mcp.mdx
│ │ │ ├── rpg-method.mdx
│ │ │ └── task-structure.mdx
│ │ ├── CHANGELOG.md
│ │ ├── command-reference.mdx
│ │ ├── configuration.mdx
│ │ ├── docs.json
│ │ ├── favicon.svg
│ │ ├── getting-started
│ │ │ ├── api-keys.mdx
│ │ │ ├── contribute.mdx
│ │ │ ├── faq.mdx
│ │ │ └── quick-start
│ │ │ ├── configuration-quick.mdx
│ │ │ ├── execute-quick.mdx
│ │ │ ├── installation.mdx
│ │ │ ├── moving-forward.mdx
│ │ │ ├── prd-quick.mdx
│ │ │ ├── quick-start.mdx
│ │ │ ├── requirements.mdx
│ │ │ ├── rules-quick.mdx
│ │ │ └── tasks-quick.mdx
│ │ ├── introduction.mdx
│ │ ├── licensing.md
│ │ ├── logo
│ │ │ ├── dark.svg
│ │ │ ├── light.svg
│ │ │ └── task-master-logo.png
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── style.css
│ │ ├── tdd-workflow
│ │ │ ├── ai-agent-integration.mdx
│ │ │ └── quickstart.mdx
│ │ ├── vercel.json
│ │ └── whats-new.mdx
│ ├── extension
│ │ ├── .vscodeignore
│ │ ├── assets
│ │ │ ├── banner.png
│ │ │ ├── icon-dark.svg
│ │ │ ├── icon-light.svg
│ │ │ ├── icon.png
│ │ │ ├── screenshots
│ │ │ │ ├── kanban-board.png
│ │ │ │ └── task-details.png
│ │ │ └── sidebar-icon.svg
│ │ ├── CHANGELOG.md
│ │ ├── components.json
│ │ ├── docs
│ │ │ ├── extension-CI-setup.md
│ │ │ └── extension-development-guide.md
│ │ ├── esbuild.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── package.mjs
│ │ ├── package.publish.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── components
│ │ │ │ ├── ConfigView.tsx
│ │ │ │ ├── constants.ts
│ │ │ │ ├── TaskDetails
│ │ │ │ │ ├── AIActionsSection.tsx
│ │ │ │ │ ├── DetailsSection.tsx
│ │ │ │ │ ├── PriorityBadge.tsx
│ │ │ │ │ ├── SubtasksSection.tsx
│ │ │ │ │ ├── TaskMetadataSidebar.tsx
│ │ │ │ │ └── useTaskDetails.ts
│ │ │ │ ├── TaskDetailsView.tsx
│ │ │ │ ├── TaskMasterLogo.tsx
│ │ │ │ └── ui
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── breadcrumb.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── collapsible.tsx
│ │ │ │ ├── CollapsibleSection.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── shadcn-io
│ │ │ │ │ └── kanban
│ │ │ │ │ └── index.tsx
│ │ │ │ └── textarea.tsx
│ │ │ ├── extension.ts
│ │ │ ├── index.ts
│ │ │ ├── lib
│ │ │ │ └── utils.ts
│ │ │ ├── services
│ │ │ │ ├── config-service.ts
│ │ │ │ ├── error-handler.ts
│ │ │ │ ├── notification-preferences.ts
│ │ │ │ ├── polling-service.ts
│ │ │ │ ├── polling-strategies.ts
│ │ │ │ ├── sidebar-webview-manager.ts
│ │ │ │ ├── task-repository.ts
│ │ │ │ ├── terminal-manager.ts
│ │ │ │ └── webview-manager.ts
│ │ │ ├── test
│ │ │ │ └── extension.test.ts
│ │ │ ├── utils
│ │ │ │ ├── configManager.ts
│ │ │ │ ├── connectionManager.ts
│ │ │ │ ├── errorHandler.ts
│ │ │ │ ├── event-emitter.ts
│ │ │ │ ├── logger.ts
│ │ │ │ ├── mcpClient.ts
│ │ │ │ ├── notificationPreferences.ts
│ │ │ │ └── task-master-api
│ │ │ │ ├── cache
│ │ │ │ │ └── cache-manager.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── mcp-client.ts
│ │ │ │ ├── transformers
│ │ │ │ │ └── task-transformer.ts
│ │ │ │ └── types
│ │ │ │ └── index.ts
│ │ │ └── webview
│ │ │ ├── App.tsx
│ │ │ ├── components
│ │ │ │ ├── AppContent.tsx
│ │ │ │ ├── EmptyState.tsx
│ │ │ │ ├── ErrorBoundary.tsx
│ │ │ │ ├── PollingStatus.tsx
│ │ │ │ ├── PriorityBadge.tsx
│ │ │ │ ├── SidebarView.tsx
│ │ │ │ ├── TagDropdown.tsx
│ │ │ │ ├── TaskCard.tsx
│ │ │ │ ├── TaskEditModal.tsx
│ │ │ │ ├── TaskMasterKanban.tsx
│ │ │ │ ├── ToastContainer.tsx
│ │ │ │ └── ToastNotification.tsx
│ │ │ ├── constants
│ │ │ │ └── index.ts
│ │ │ ├── contexts
│ │ │ │ └── VSCodeContext.tsx
│ │ │ ├── hooks
│ │ │ │ ├── useTaskQueries.ts
│ │ │ │ ├── useVSCodeMessages.ts
│ │ │ │ └── useWebviewHeight.ts
│ │ │ ├── index.css
│ │ │ ├── index.tsx
│ │ │ ├── providers
│ │ │ │ └── QueryProvider.tsx
│ │ │ ├── reducers
│ │ │ │ └── appReducer.ts
│ │ │ ├── sidebar.tsx
│ │ │ ├── types
│ │ │ │ └── index.ts
│ │ │ └── utils
│ │ │ ├── logger.ts
│ │ │ └── toast.ts
│ │ └── tsconfig.json
│ └── mcp
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── shared
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ └── tools
│ │ ├── autopilot
│ │ │ ├── abort.tool.ts
│ │ │ ├── commit.tool.ts
│ │ │ ├── complete.tool.ts
│ │ │ ├── finalize.tool.ts
│ │ │ ├── index.ts
│ │ │ ├── next.tool.ts
│ │ │ ├── resume.tool.ts
│ │ │ ├── start.tool.ts
│ │ │ └── status.tool.ts
│ │ ├── README-ZOD-V3.md
│ │ └── tasks
│ │ ├── get-task.tool.ts
│ │ ├── get-tasks.tool.ts
│ │ └── index.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── assets
│ ├── .windsurfrules
│ ├── AGENTS.md
│ ├── claude
│ │ └── TM_COMMANDS_GUIDE.md
│ ├── config.json
│ ├── env.example
│ ├── example_prd_rpg.txt
│ ├── example_prd.txt
│ ├── GEMINI.md
│ ├── gitignore
│ ├── kiro-hooks
│ │ ├── tm-code-change-task-tracker.kiro.hook
│ │ ├── tm-complexity-analyzer.kiro.hook
│ │ ├── tm-daily-standup-assistant.kiro.hook
│ │ ├── tm-git-commit-task-linker.kiro.hook
│ │ ├── tm-pr-readiness-checker.kiro.hook
│ │ ├── tm-task-dependency-auto-progression.kiro.hook
│ │ └── tm-test-success-task-completer.kiro.hook
│ ├── roocode
│ │ ├── .roo
│ │ │ ├── rules-architect
│ │ │ │ └── architect-rules
│ │ │ ├── rules-ask
│ │ │ │ └── ask-rules
│ │ │ ├── rules-code
│ │ │ │ └── code-rules
│ │ │ ├── rules-debug
│ │ │ │ └── debug-rules
│ │ │ ├── rules-orchestrator
│ │ │ │ └── orchestrator-rules
│ │ │ └── rules-test
│ │ │ └── test-rules
│ │ └── .roomodes
│ ├── rules
│ │ ├── cursor_rules.mdc
│ │ ├── dev_workflow.mdc
│ │ ├── self_improve.mdc
│ │ ├── taskmaster_hooks_workflow.mdc
│ │ └── taskmaster.mdc
│ └── scripts_README.md
├── bin
│ └── task-master.js
├── biome.json
├── CHANGELOG.md
├── CLAUDE_CODE_PLUGIN.md
├── CLAUDE.md
├── context
│ ├── chats
│ │ ├── add-task-dependencies-1.md
│ │ └── max-min-tokens.txt.md
│ ├── fastmcp-core.txt
│ ├── fastmcp-docs.txt
│ ├── MCP_INTEGRATION.md
│ ├── mcp-js-sdk-docs.txt
│ ├── mcp-protocol-repo.txt
│ ├── mcp-protocol-schema-03262025.json
│ └── mcp-protocol-spec.txt
├── CONTRIBUTING.md
├── docs
│ ├── claude-code-integration.md
│ ├── CLI-COMMANDER-PATTERN.md
│ ├── command-reference.md
│ ├── configuration.md
│ ├── contributor-docs
│ │ ├── testing-roo-integration.md
│ │ └── worktree-setup.md
│ ├── cross-tag-task-movement.md
│ ├── examples
│ │ ├── claude-code-usage.md
│ │ └── codex-cli-usage.md
│ ├── examples.md
│ ├── licensing.md
│ ├── mcp-provider-guide.md
│ ├── mcp-provider.md
│ ├── migration-guide.md
│ ├── models.md
│ ├── providers
│ │ ├── codex-cli.md
│ │ └── gemini-cli.md
│ ├── README.md
│ ├── scripts
│ │ └── models-json-to-markdown.js
│ ├── task-structure.md
│ └── tutorial.md
├── images
│ ├── hamster-hiring.png
│ └── logo.png
├── index.js
├── jest.config.js
├── jest.resolver.cjs
├── LICENSE
├── llms-install.md
├── mcp-server
│ ├── server.js
│ └── src
│ ├── core
│ │ ├── __tests__
│ │ │ └── context-manager.test.js
│ │ ├── context-manager.js
│ │ ├── direct-functions
│ │ │ ├── add-dependency.js
│ │ │ ├── add-subtask.js
│ │ │ ├── add-tag.js
│ │ │ ├── add-task.js
│ │ │ ├── analyze-task-complexity.js
│ │ │ ├── cache-stats.js
│ │ │ ├── clear-subtasks.js
│ │ │ ├── complexity-report.js
│ │ │ ├── copy-tag.js
│ │ │ ├── create-tag-from-branch.js
│ │ │ ├── delete-tag.js
│ │ │ ├── expand-all-tasks.js
│ │ │ ├── expand-task.js
│ │ │ ├── fix-dependencies.js
│ │ │ ├── generate-task-files.js
│ │ │ ├── initialize-project.js
│ │ │ ├── list-tags.js
│ │ │ ├── models.js
│ │ │ ├── move-task-cross-tag.js
│ │ │ ├── move-task.js
│ │ │ ├── next-task.js
│ │ │ ├── parse-prd.js
│ │ │ ├── remove-dependency.js
│ │ │ ├── remove-subtask.js
│ │ │ ├── remove-task.js
│ │ │ ├── rename-tag.js
│ │ │ ├── research.js
│ │ │ ├── response-language.js
│ │ │ ├── rules.js
│ │ │ ├── scope-down.js
│ │ │ ├── scope-up.js
│ │ │ ├── set-task-status.js
│ │ │ ├── update-subtask-by-id.js
│ │ │ ├── update-task-by-id.js
│ │ │ ├── update-tasks.js
│ │ │ ├── use-tag.js
│ │ │ └── validate-dependencies.js
│ │ ├── task-master-core.js
│ │ └── utils
│ │ ├── env-utils.js
│ │ └── path-utils.js
│ ├── custom-sdk
│ │ ├── errors.js
│ │ ├── index.js
│ │ ├── json-extractor.js
│ │ ├── language-model.js
│ │ ├── message-converter.js
│ │ └── schema-converter.js
│ ├── index.js
│ ├── logger.js
│ ├── providers
│ │ └── mcp-provider.js
│ └── tools
│ ├── add-dependency.js
│ ├── add-subtask.js
│ ├── add-tag.js
│ ├── add-task.js
│ ├── analyze.js
│ ├── clear-subtasks.js
│ ├── complexity-report.js
│ ├── copy-tag.js
│ ├── delete-tag.js
│ ├── expand-all.js
│ ├── expand-task.js
│ ├── fix-dependencies.js
│ ├── generate.js
│ ├── get-operation-status.js
│ ├── index.js
│ ├── initialize-project.js
│ ├── list-tags.js
│ ├── models.js
│ ├── move-task.js
│ ├── next-task.js
│ ├── parse-prd.js
│ ├── README-ZOD-V3.md
│ ├── remove-dependency.js
│ ├── remove-subtask.js
│ ├── remove-task.js
│ ├── rename-tag.js
│ ├── research.js
│ ├── response-language.js
│ ├── rules.js
│ ├── scope-down.js
│ ├── scope-up.js
│ ├── set-task-status.js
│ ├── tool-registry.js
│ ├── update-subtask.js
│ ├── update-task.js
│ ├── update.js
│ ├── use-tag.js
│ ├── utils.js
│ └── validate-dependencies.js
├── mcp-test.js
├── output.json
├── package-lock.json
├── package.json
├── packages
│ ├── ai-sdk-provider-grok-cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── grok-cli-language-model.ts
│ │ │ ├── grok-cli-provider.test.ts
│ │ │ ├── grok-cli-provider.ts
│ │ │ ├── index.ts
│ │ │ ├── json-extractor.test.ts
│ │ │ ├── json-extractor.ts
│ │ │ ├── message-converter.test.ts
│ │ │ ├── message-converter.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ ├── build-config
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ └── tsdown.base.ts
│ │ └── tsconfig.json
│ ├── claude-code-plugin
│ │ ├── .claude-plugin
│ │ │ └── plugin.json
│ │ ├── .gitignore
│ │ ├── agents
│ │ │ ├── task-checker.md
│ │ │ ├── task-executor.md
│ │ │ └── task-orchestrator.md
│ │ ├── CHANGELOG.md
│ │ ├── commands
│ │ │ ├── add-dependency.md
│ │ │ ├── add-subtask.md
│ │ │ ├── add-task.md
│ │ │ ├── analyze-complexity.md
│ │ │ ├── analyze-project.md
│ │ │ ├── auto-implement-tasks.md
│ │ │ ├── command-pipeline.md
│ │ │ ├── complexity-report.md
│ │ │ ├── convert-task-to-subtask.md
│ │ │ ├── expand-all-tasks.md
│ │ │ ├── expand-task.md
│ │ │ ├── fix-dependencies.md
│ │ │ ├── generate-tasks.md
│ │ │ ├── help.md
│ │ │ ├── init-project-quick.md
│ │ │ ├── init-project.md
│ │ │ ├── install-taskmaster.md
│ │ │ ├── learn.md
│ │ │ ├── list-tasks-by-status.md
│ │ │ ├── list-tasks-with-subtasks.md
│ │ │ ├── list-tasks.md
│ │ │ ├── next-task.md
│ │ │ ├── parse-prd-with-research.md
│ │ │ ├── parse-prd.md
│ │ │ ├── project-status.md
│ │ │ ├── quick-install-taskmaster.md
│ │ │ ├── remove-all-subtasks.md
│ │ │ ├── remove-dependency.md
│ │ │ ├── remove-subtask.md
│ │ │ ├── remove-subtasks.md
│ │ │ ├── remove-task.md
│ │ │ ├── setup-models.md
│ │ │ ├── show-task.md
│ │ │ ├── smart-workflow.md
│ │ │ ├── sync-readme.md
│ │ │ ├── tm-main.md
│ │ │ ├── to-cancelled.md
│ │ │ ├── to-deferred.md
│ │ │ ├── to-done.md
│ │ │ ├── to-in-progress.md
│ │ │ ├── to-pending.md
│ │ │ ├── to-review.md
│ │ │ ├── update-single-task.md
│ │ │ ├── update-task.md
│ │ │ ├── update-tasks-from-id.md
│ │ │ ├── validate-dependencies.md
│ │ │ └── view-models.md
│ │ ├── mcp.json
│ │ └── package.json
│ ├── tm-bridge
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── add-tag-bridge.ts
│ │ │ ├── bridge-types.ts
│ │ │ ├── bridge-utils.ts
│ │ │ ├── expand-bridge.ts
│ │ │ ├── index.ts
│ │ │ ├── tags-bridge.ts
│ │ │ ├── update-bridge.ts
│ │ │ └── use-tag-bridge.ts
│ │ └── tsconfig.json
│ └── tm-core
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docs
│ │ └── listTasks-architecture.md
│ ├── package.json
│ ├── POC-STATUS.md
│ ├── README.md
│ ├── src
│ │ ├── common
│ │ │ ├── constants
│ │ │ │ ├── index.ts
│ │ │ │ ├── paths.ts
│ │ │ │ └── providers.ts
│ │ │ ├── errors
│ │ │ │ ├── index.ts
│ │ │ │ └── task-master-error.ts
│ │ │ ├── interfaces
│ │ │ │ ├── configuration.interface.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── storage.interface.ts
│ │ │ ├── logger
│ │ │ │ ├── factory.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.spec.ts
│ │ │ │ └── logger.ts
│ │ │ ├── mappers
│ │ │ │ ├── TaskMapper.test.ts
│ │ │ │ └── TaskMapper.ts
│ │ │ ├── types
│ │ │ │ ├── database.types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── legacy.ts
│ │ │ │ └── repository-types.ts
│ │ │ └── utils
│ │ │ ├── git-utils.ts
│ │ │ ├── id-generator.ts
│ │ │ ├── index.ts
│ │ │ ├── path-helpers.ts
│ │ │ ├── path-normalizer.spec.ts
│ │ │ ├── path-normalizer.ts
│ │ │ ├── project-root-finder.spec.ts
│ │ │ ├── project-root-finder.ts
│ │ │ ├── run-id-generator.spec.ts
│ │ │ └── run-id-generator.ts
│ │ ├── index.ts
│ │ ├── modules
│ │ │ ├── ai
│ │ │ │ ├── index.ts
│ │ │ │ ├── interfaces
│ │ │ │ │ └── ai-provider.interface.ts
│ │ │ │ └── providers
│ │ │ │ ├── base-provider.ts
│ │ │ │ └── index.ts
│ │ │ ├── auth
│ │ │ │ ├── auth-domain.spec.ts
│ │ │ │ ├── auth-domain.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ ├── auth-manager.spec.ts
│ │ │ │ │ └── auth-manager.ts
│ │ │ │ ├── services
│ │ │ │ │ ├── context-store.ts
│ │ │ │ │ ├── oauth-service.ts
│ │ │ │ │ ├── organization.service.ts
│ │ │ │ │ ├── supabase-session-storage.spec.ts
│ │ │ │ │ └── supabase-session-storage.ts
│ │ │ │ └── types.ts
│ │ │ ├── briefs
│ │ │ │ ├── briefs-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── brief-service.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── url-parser.ts
│ │ │ ├── commands
│ │ │ │ └── index.ts
│ │ │ ├── config
│ │ │ │ ├── config-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ ├── config-manager.spec.ts
│ │ │ │ │ └── config-manager.ts
│ │ │ │ └── services
│ │ │ │ ├── config-loader.service.spec.ts
│ │ │ │ ├── config-loader.service.ts
│ │ │ │ ├── config-merger.service.spec.ts
│ │ │ │ ├── config-merger.service.ts
│ │ │ │ ├── config-persistence.service.spec.ts
│ │ │ │ ├── config-persistence.service.ts
│ │ │ │ ├── environment-config-provider.service.spec.ts
│ │ │ │ ├── environment-config-provider.service.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runtime-state-manager.service.spec.ts
│ │ │ │ └── runtime-state-manager.service.ts
│ │ │ ├── dependencies
│ │ │ │ └── index.ts
│ │ │ ├── execution
│ │ │ │ ├── executors
│ │ │ │ │ ├── base-executor.ts
│ │ │ │ │ ├── claude-executor.ts
│ │ │ │ │ └── executor-factory.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── executor-service.ts
│ │ │ │ └── types.ts
│ │ │ ├── git
│ │ │ │ ├── adapters
│ │ │ │ │ ├── git-adapter.test.ts
│ │ │ │ │ └── git-adapter.ts
│ │ │ │ ├── git-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── services
│ │ │ │ ├── branch-name-generator.spec.ts
│ │ │ │ ├── branch-name-generator.ts
│ │ │ │ ├── commit-message-generator.test.ts
│ │ │ │ ├── commit-message-generator.ts
│ │ │ │ ├── scope-detector.test.ts
│ │ │ │ ├── scope-detector.ts
│ │ │ │ ├── template-engine.test.ts
│ │ │ │ └── template-engine.ts
│ │ │ ├── integration
│ │ │ │ ├── clients
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── supabase-client.ts
│ │ │ │ ├── integration-domain.ts
│ │ │ │ └── services
│ │ │ │ ├── export.service.ts
│ │ │ │ ├── task-expansion.service.ts
│ │ │ │ └── task-retrieval.service.ts
│ │ │ ├── reports
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ └── complexity-report-manager.ts
│ │ │ │ └── types.ts
│ │ │ ├── storage
│ │ │ │ ├── adapters
│ │ │ │ │ ├── activity-logger.ts
│ │ │ │ │ ├── api-storage.ts
│ │ │ │ │ └── file-storage
│ │ │ │ │ ├── file-operations.ts
│ │ │ │ │ ├── file-storage.ts
│ │ │ │ │ ├── format-handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── path-resolver.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── storage-factory.ts
│ │ │ │ └── utils
│ │ │ │ └── api-client.ts
│ │ │ ├── tasks
│ │ │ │ ├── entities
│ │ │ │ │ └── task.entity.ts
│ │ │ │ ├── parser
│ │ │ │ │ └── index.ts
│ │ │ │ ├── repositories
│ │ │ │ │ ├── supabase
│ │ │ │ │ │ ├── dependency-fetcher.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── supabase-repository.ts
│ │ │ │ │ └── task-repository.interface.ts
│ │ │ │ ├── services
│ │ │ │ │ ├── preflight-checker.service.ts
│ │ │ │ │ ├── tag.service.ts
│ │ │ │ │ ├── task-execution-service.ts
│ │ │ │ │ ├── task-loader.service.ts
│ │ │ │ │ └── task-service.ts
│ │ │ │ └── tasks-domain.ts
│ │ │ ├── ui
│ │ │ │ └── index.ts
│ │ │ └── workflow
│ │ │ ├── managers
│ │ │ │ ├── workflow-state-manager.spec.ts
│ │ │ │ └── workflow-state-manager.ts
│ │ │ ├── orchestrators
│ │ │ │ ├── workflow-orchestrator.test.ts
│ │ │ │ └── workflow-orchestrator.ts
│ │ │ ├── services
│ │ │ │ ├── test-result-validator.test.ts
│ │ │ │ ├── test-result-validator.ts
│ │ │ │ ├── test-result-validator.types.ts
│ │ │ │ ├── workflow-activity-logger.ts
│ │ │ │ └── workflow.service.ts
│ │ │ ├── types.ts
│ │ │ └── workflow-domain.ts
│ │ ├── subpath-exports.test.ts
│ │ ├── tm-core.ts
│ │ └── utils
│ │ └── time.utils.ts
│ ├── tests
│ │ ├── auth
│ │ │ └── auth-refresh.test.ts
│ │ ├── integration
│ │ │ ├── auth-token-refresh.test.ts
│ │ │ ├── list-tasks.test.ts
│ │ │ └── storage
│ │ │ └── activity-logger.test.ts
│ │ ├── mocks
│ │ │ └── mock-provider.ts
│ │ ├── setup.ts
│ │ └── unit
│ │ ├── base-provider.test.ts
│ │ ├── executor.test.ts
│ │ └── smoke.test.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── README-task-master.md
├── README.md
├── scripts
│ ├── create-worktree.sh
│ ├── dev.js
│ ├── init.js
│ ├── list-worktrees.sh
│ ├── modules
│ │ ├── ai-services-unified.js
│ │ ├── bridge-utils.js
│ │ ├── commands.js
│ │ ├── config-manager.js
│ │ ├── dependency-manager.js
│ │ ├── index.js
│ │ ├── prompt-manager.js
│ │ ├── supported-models.json
│ │ ├── sync-readme.js
│ │ ├── task-manager
│ │ │ ├── add-subtask.js
│ │ │ ├── add-task.js
│ │ │ ├── analyze-task-complexity.js
│ │ │ ├── clear-subtasks.js
│ │ │ ├── expand-all-tasks.js
│ │ │ ├── expand-task.js
│ │ │ ├── find-next-task.js
│ │ │ ├── generate-task-files.js
│ │ │ ├── is-task-dependent.js
│ │ │ ├── list-tasks.js
│ │ │ ├── migrate.js
│ │ │ ├── models.js
│ │ │ ├── move-task.js
│ │ │ ├── parse-prd
│ │ │ │ ├── index.js
│ │ │ │ ├── parse-prd-config.js
│ │ │ │ ├── parse-prd-helpers.js
│ │ │ │ ├── parse-prd-non-streaming.js
│ │ │ │ ├── parse-prd-streaming.js
│ │ │ │ └── parse-prd.js
│ │ │ ├── remove-subtask.js
│ │ │ ├── remove-task.js
│ │ │ ├── research.js
│ │ │ ├── response-language.js
│ │ │ ├── scope-adjustment.js
│ │ │ ├── set-task-status.js
│ │ │ ├── tag-management.js
│ │ │ ├── task-exists.js
│ │ │ ├── update-single-task-status.js
│ │ │ ├── update-subtask-by-id.js
│ │ │ ├── update-task-by-id.js
│ │ │ └── update-tasks.js
│ │ ├── task-manager.js
│ │ ├── ui.js
│ │ ├── update-config-tokens.js
│ │ ├── utils
│ │ │ ├── contextGatherer.js
│ │ │ ├── fuzzyTaskSearch.js
│ │ │ └── git-utils.js
│ │ └── utils.js
│ ├── task-complexity-report.json
│ ├── test-claude-errors.js
│ └── test-claude.js
├── sonar-project.properties
├── src
│ ├── ai-providers
│ │ ├── anthropic.js
│ │ ├── azure.js
│ │ ├── base-provider.js
│ │ ├── bedrock.js
│ │ ├── claude-code.js
│ │ ├── codex-cli.js
│ │ ├── gemini-cli.js
│ │ ├── google-vertex.js
│ │ ├── google.js
│ │ ├── grok-cli.js
│ │ ├── groq.js
│ │ ├── index.js
│ │ ├── lmstudio.js
│ │ ├── ollama.js
│ │ ├── openai-compatible.js
│ │ ├── openai.js
│ │ ├── openrouter.js
│ │ ├── perplexity.js
│ │ ├── xai.js
│ │ ├── zai-coding.js
│ │ └── zai.js
│ ├── constants
│ │ ├── commands.js
│ │ ├── paths.js
│ │ ├── profiles.js
│ │ ├── rules-actions.js
│ │ ├── task-priority.js
│ │ └── task-status.js
│ ├── profiles
│ │ ├── amp.js
│ │ ├── base-profile.js
│ │ ├── claude.js
│ │ ├── cline.js
│ │ ├── codex.js
│ │ ├── cursor.js
│ │ ├── gemini.js
│ │ ├── index.js
│ │ ├── kilo.js
│ │ ├── kiro.js
│ │ ├── opencode.js
│ │ ├── roo.js
│ │ ├── trae.js
│ │ ├── vscode.js
│ │ ├── windsurf.js
│ │ └── zed.js
│ ├── progress
│ │ ├── base-progress-tracker.js
│ │ ├── cli-progress-factory.js
│ │ ├── parse-prd-tracker.js
│ │ ├── progress-tracker-builder.js
│ │ └── tracker-ui.js
│ ├── prompts
│ │ ├── add-task.json
│ │ ├── analyze-complexity.json
│ │ ├── expand-task.json
│ │ ├── parse-prd.json
│ │ ├── README.md
│ │ ├── research.json
│ │ ├── schemas
│ │ │ ├── parameter.schema.json
│ │ │ ├── prompt-template.schema.json
│ │ │ ├── README.md
│ │ │ └── variant.schema.json
│ │ ├── update-subtask.json
│ │ ├── update-task.json
│ │ └── update-tasks.json
│ ├── provider-registry
│ │ └── index.js
│ ├── schemas
│ │ ├── add-task.js
│ │ ├── analyze-complexity.js
│ │ ├── base-schemas.js
│ │ ├── expand-task.js
│ │ ├── parse-prd.js
│ │ ├── registry.js
│ │ ├── update-subtask.js
│ │ ├── update-task.js
│ │ └── update-tasks.js
│ ├── task-master.js
│ ├── ui
│ │ ├── confirm.js
│ │ ├── indicators.js
│ │ └── parse-prd.js
│ └── utils
│ ├── asset-resolver.js
│ ├── create-mcp-config.js
│ ├── format.js
│ ├── getVersion.js
│ ├── logger-utils.js
│ ├── manage-gitignore.js
│ ├── path-utils.js
│ ├── profiles.js
│ ├── rule-transformer.js
│ ├── stream-parser.js
│ └── timeout-manager.js
├── test-clean-tags.js
├── test-config-manager.js
├── test-prd.txt
├── test-tag-functions.js
├── test-version-check-full.js
├── test-version-check.js
├── tests
│ ├── e2e
│ │ ├── e2e_helpers.sh
│ │ ├── parse_llm_output.cjs
│ │ ├── run_e2e.sh
│ │ ├── run_fallback_verification.sh
│ │ └── test_llm_analysis.sh
│ ├── fixtures
│ │ ├── .taskmasterconfig
│ │ ├── sample-claude-response.js
│ │ ├── sample-prd.txt
│ │ └── sample-tasks.js
│ ├── helpers
│ │ └── tool-counts.js
│ ├── integration
│ │ ├── claude-code-error-handling.test.js
│ │ ├── claude-code-optional.test.js
│ │ ├── cli
│ │ │ ├── commands.test.js
│ │ │ ├── complex-cross-tag-scenarios.test.js
│ │ │ └── move-cross-tag.test.js
│ │ ├── manage-gitignore.test.js
│ │ ├── mcp-server
│ │ │ └── direct-functions.test.js
│ │ ├── move-task-cross-tag.integration.test.js
│ │ ├── move-task-simple.integration.test.js
│ │ ├── profiles
│ │ │ ├── amp-init-functionality.test.js
│ │ │ ├── claude-init-functionality.test.js
│ │ │ ├── cline-init-functionality.test.js
│ │ │ ├── codex-init-functionality.test.js
│ │ │ ├── cursor-init-functionality.test.js
│ │ │ ├── gemini-init-functionality.test.js
│ │ │ ├── opencode-init-functionality.test.js
│ │ │ ├── roo-files-inclusion.test.js
│ │ │ ├── roo-init-functionality.test.js
│ │ │ ├── rules-files-inclusion.test.js
│ │ │ ├── trae-init-functionality.test.js
│ │ │ ├── vscode-init-functionality.test.js
│ │ │ └── windsurf-init-functionality.test.js
│ │ └── providers
│ │ └── temperature-support.test.js
│ ├── manual
│ │ ├── progress
│ │ │ ├── parse-prd-analysis.js
│ │ │ ├── test-parse-prd.js
│ │ │ └── TESTING_GUIDE.md
│ │ └── prompts
│ │ ├── prompt-test.js
│ │ └── README.md
│ ├── README.md
│ ├── setup.js
│ └── unit
│ ├── ai-providers
│ │ ├── base-provider.test.js
│ │ ├── claude-code.test.js
│ │ ├── codex-cli.test.js
│ │ ├── gemini-cli.test.js
│ │ ├── lmstudio.test.js
│ │ ├── mcp-components.test.js
│ │ ├── openai-compatible.test.js
│ │ ├── openai.test.js
│ │ ├── provider-registry.test.js
│ │ ├── zai-coding.test.js
│ │ ├── zai-provider.test.js
│ │ ├── zai-schema-introspection.test.js
│ │ └── zai.test.js
│ ├── ai-services-unified.test.js
│ ├── commands.test.js
│ ├── config-manager.test.js
│ ├── config-manager.test.mjs
│ ├── dependency-manager.test.js
│ ├── init.test.js
│ ├── initialize-project.test.js
│ ├── kebab-case-validation.test.js
│ ├── manage-gitignore.test.js
│ ├── mcp
│ │ └── tools
│ │ ├── __mocks__
│ │ │ └── move-task.js
│ │ ├── add-task.test.js
│ │ ├── analyze-complexity.test.js
│ │ ├── expand-all.test.js
│ │ ├── get-tasks.test.js
│ │ ├── initialize-project.test.js
│ │ ├── move-task-cross-tag-options.test.js
│ │ ├── move-task-cross-tag.test.js
│ │ ├── remove-task.test.js
│ │ └── tool-registration.test.js
│ ├── mcp-providers
│ │ ├── mcp-components.test.js
│ │ └── mcp-provider.test.js
│ ├── parse-prd.test.js
│ ├── profiles
│ │ ├── amp-integration.test.js
│ │ ├── claude-integration.test.js
│ │ ├── cline-integration.test.js
│ │ ├── codex-integration.test.js
│ │ ├── cursor-integration.test.js
│ │ ├── gemini-integration.test.js
│ │ ├── kilo-integration.test.js
│ │ ├── kiro-integration.test.js
│ │ ├── mcp-config-validation.test.js
│ │ ├── opencode-integration.test.js
│ │ ├── profile-safety-check.test.js
│ │ ├── roo-integration.test.js
│ │ ├── rule-transformer-cline.test.js
│ │ ├── rule-transformer-cursor.test.js
│ │ ├── rule-transformer-gemini.test.js
│ │ ├── rule-transformer-kilo.test.js
│ │ ├── rule-transformer-kiro.test.js
│ │ ├── rule-transformer-opencode.test.js
│ │ ├── rule-transformer-roo.test.js
│ │ ├── rule-transformer-trae.test.js
│ │ ├── rule-transformer-vscode.test.js
│ │ ├── rule-transformer-windsurf.test.js
│ │ ├── rule-transformer-zed.test.js
│ │ ├── rule-transformer.test.js
│ │ ├── selective-profile-removal.test.js
│ │ ├── subdirectory-support.test.js
│ │ ├── trae-integration.test.js
│ │ ├── vscode-integration.test.js
│ │ ├── windsurf-integration.test.js
│ │ └── zed-integration.test.js
│ ├── progress
│ │ └── base-progress-tracker.test.js
│ ├── prompt-manager.test.js
│ ├── prompts
│ │ ├── expand-task-prompt.test.js
│ │ └── prompt-migration.test.js
│ ├── scripts
│ │ └── modules
│ │ ├── commands
│ │ │ ├── move-cross-tag.test.js
│ │ │ └── README.md
│ │ ├── dependency-manager
│ │ │ ├── circular-dependencies.test.js
│ │ │ ├── cross-tag-dependencies.test.js
│ │ │ └── fix-dependencies-command.test.js
│ │ ├── task-manager
│ │ │ ├── add-subtask.test.js
│ │ │ ├── add-task.test.js
│ │ │ ├── analyze-task-complexity.test.js
│ │ │ ├── clear-subtasks.test.js
│ │ │ ├── complexity-report-tag-isolation.test.js
│ │ │ ├── expand-all-tasks.test.js
│ │ │ ├── expand-task.test.js
│ │ │ ├── find-next-task.test.js
│ │ │ ├── generate-task-files.test.js
│ │ │ ├── list-tasks.test.js
│ │ │ ├── models-baseurl.test.js
│ │ │ ├── move-task-cross-tag.test.js
│ │ │ ├── move-task.test.js
│ │ │ ├── parse-prd-schema.test.js
│ │ │ ├── parse-prd.test.js
│ │ │ ├── remove-subtask.test.js
│ │ │ ├── remove-task.test.js
│ │ │ ├── research.test.js
│ │ │ ├── scope-adjustment.test.js
│ │ │ ├── set-task-status.test.js
│ │ │ ├── setup.js
│ │ │ ├── update-single-task-status.test.js
│ │ │ ├── update-subtask-by-id.test.js
│ │ │ ├── update-task-by-id.test.js
│ │ │ └── update-tasks.test.js
│ │ ├── ui
│ │ │ └── cross-tag-error-display.test.js
│ │ └── utils-tag-aware-paths.test.js
│ ├── task-finder.test.js
│ ├── task-manager
│ │ ├── clear-subtasks.test.js
│ │ ├── move-task.test.js
│ │ ├── tag-boundary.test.js
│ │ └── tag-management.test.js
│ ├── task-master.test.js
│ ├── ui
│ │ └── indicators.test.js
│ ├── ui.test.js
│ ├── utils-strip-ansi.test.js
│ └── utils.test.js
├── tsconfig.json
├── tsdown.config.ts
├── turbo.json
└── update-task-migration-plan.md
```
# Files
--------------------------------------------------------------------------------
/scripts/modules/task-manager/move-task.js:
--------------------------------------------------------------------------------
```javascript
1 | import path from 'path';
2 | import {
3 | log,
4 | readJSON,
5 | writeJSON,
6 | setTasksForTag,
7 | traverseDependencies
8 | } from '../utils.js';
9 | import {
10 | findCrossTagDependencies,
11 | getDependentTaskIds,
12 | validateSubtaskMove
13 | } from '../dependency-manager.js';
14 |
15 | /**
16 | * Find all dependencies recursively for a set of source tasks with depth limiting
17 | * @param {Array} sourceTasks - The source tasks to find dependencies for
18 | * @param {Array} allTasks - All available tasks from all tags
19 | * @param {Object} options - Options object
20 | * @param {number} options.maxDepth - Maximum recursion depth (default: 50)
21 | * @param {boolean} options.includeSelf - Whether to include self-references (default: false)
22 | * @returns {Array} Array of all dependency task IDs
23 | */
24 | function findAllDependenciesRecursively(sourceTasks, allTasks, options = {}) {
25 | return traverseDependencies(sourceTasks, allTasks, {
26 | ...options,
27 | direction: 'forward',
28 | logger: { warn: console.warn }
29 | });
30 | }
31 |
32 | /**
33 | * Structured error class for move operations
34 | */
35 | class MoveTaskError extends Error {
36 | constructor(code, message, data = {}) {
37 | super(message);
38 | this.name = 'MoveTaskError';
39 | this.code = code;
40 | this.data = data;
41 | }
42 | }
43 |
44 | /**
45 | * Error codes for move operations
46 | */
47 | const MOVE_ERROR_CODES = {
48 | CROSS_TAG_DEPENDENCY_CONFLICTS: 'CROSS_TAG_DEPENDENCY_CONFLICTS',
49 | CANNOT_MOVE_SUBTASK: 'CANNOT_MOVE_SUBTASK',
50 | SOURCE_TARGET_TAGS_SAME: 'SOURCE_TARGET_TAGS_SAME',
51 | TASK_NOT_FOUND: 'TASK_NOT_FOUND',
52 | SUBTASK_NOT_FOUND: 'SUBTASK_NOT_FOUND',
53 | PARENT_TASK_NOT_FOUND: 'PARENT_TASK_NOT_FOUND',
54 | PARENT_TASK_NO_SUBTASKS: 'PARENT_TASK_NO_SUBTASKS',
55 | DESTINATION_TASK_NOT_FOUND: 'DESTINATION_TASK_NOT_FOUND',
56 | TASK_ALREADY_EXISTS: 'TASK_ALREADY_EXISTS',
57 | INVALID_TASKS_FILE: 'INVALID_TASKS_FILE',
58 | ID_COUNT_MISMATCH: 'ID_COUNT_MISMATCH',
59 | INVALID_SOURCE_TAG: 'INVALID_SOURCE_TAG',
60 | INVALID_TARGET_TAG: 'INVALID_TARGET_TAG'
61 | };
62 |
63 | /**
64 | * Normalize a dependency value to its numeric parent task ID.
65 | * - Numbers are returned as-is (if finite)
66 | * - Numeric strings are parsed ("5" -> 5)
67 | * - Dotted strings return the parent portion ("5.2" -> 5)
68 | * - Empty/invalid values return null
69 | * - null/undefined are preserved
70 | * @param {number|string|null|undefined} dep
71 | * @returns {number|null|undefined}
72 | */
73 | function normalizeDependency(dep) {
74 | if (dep === null || dep === undefined) return dep;
75 | if (typeof dep === 'number') return Number.isFinite(dep) ? dep : null;
76 | if (typeof dep === 'string') {
77 | const trimmed = dep.trim();
78 | if (trimmed === '') return null;
79 | const parentPart = trimmed.includes('.') ? trimmed.split('.')[0] : trimmed;
80 | const parsed = parseInt(parentPart, 10);
81 | return Number.isFinite(parsed) ? parsed : null;
82 | }
83 | return null;
84 | }
85 |
86 | /**
87 | * Normalize an array of dependency values to numeric IDs.
88 | * Preserves null/undefined input (returns as-is) and filters out invalid entries.
89 | * @param {Array<any>|null|undefined} deps
90 | * @returns {Array<number>|null|undefined}
91 | */
92 | function normalizeDependencies(deps) {
93 | if (deps === null || deps === undefined) return deps;
94 | if (!Array.isArray(deps)) return deps;
95 | return deps
96 | .map((d) => normalizeDependency(d))
97 | .filter((n) => Number.isFinite(n));
98 | }
99 |
100 | /**
101 | * Move one or more tasks/subtasks to new positions
102 | * @param {string} tasksPath - Path to tasks.json file
103 | * @param {string} sourceId - ID(s) of the task/subtask to move (e.g., '5' or '5.2' or '5,6,7')
104 | * @param {string} destinationId - ID(s) of the destination (e.g., '7' or '7.3' or '7,8,9')
105 | * @param {boolean} generateFiles - Whether to regenerate task files after moving
106 | * @param {Object} options - Additional options
107 | * @param {string} options.projectRoot - Project root directory for tag resolution
108 | * @param {string} options.tag - Explicit tag to use (optional)
109 | * @returns {Object} Result object with moved task details
110 | */
111 | async function moveTask(
112 | tasksPath,
113 | sourceId,
114 | destinationId,
115 | generateFiles = false,
116 | options = {}
117 | ) {
118 | const { projectRoot, tag } = options;
119 | // Check if we have comma-separated IDs (batch move)
120 | const sourceIds = sourceId.split(',').map((id) => id.trim());
121 | const destinationIds = destinationId.split(',').map((id) => id.trim());
122 |
123 | if (sourceIds.length !== destinationIds.length) {
124 | throw new MoveTaskError(
125 | MOVE_ERROR_CODES.ID_COUNT_MISMATCH,
126 | `Number of source IDs (${sourceIds.length}) must match number of destination IDs (${destinationIds.length})`
127 | );
128 | }
129 |
130 | // For batch moves, process each pair sequentially
131 | if (sourceIds.length > 1) {
132 | const results = [];
133 | for (let i = 0; i < sourceIds.length; i++) {
134 | const result = await moveTask(
135 | tasksPath,
136 | sourceIds[i],
137 | destinationIds[i],
138 | false, // Don't generate files for each individual move
139 | options
140 | );
141 | results.push(result);
142 | }
143 |
144 | // Note: Task file generation is no longer supported and has been removed
145 |
146 | return {
147 | message: `Successfully moved ${sourceIds.length} tasks/subtasks`,
148 | moves: results
149 | };
150 | }
151 |
152 | // Single move logic
153 | // Read the raw data without tag resolution to preserve tagged structure
154 | let rawData = readJSON(tasksPath, projectRoot, tag);
155 |
156 | // Handle the case where readJSON returns resolved data with _rawTaggedData
157 | if (rawData && rawData._rawTaggedData) {
158 | // Use the raw tagged data and discard the resolved view
159 | rawData = rawData._rawTaggedData;
160 | }
161 |
162 | // Ensure the tag exists in the raw data
163 | if (!rawData || !rawData[tag] || !Array.isArray(rawData[tag].tasks)) {
164 | throw new MoveTaskError(
165 | MOVE_ERROR_CODES.INVALID_TASKS_FILE,
166 | `Invalid tasks file or tag "${tag}" not found at ${tasksPath}`
167 | );
168 | }
169 |
170 | // Get the tasks for the current tag
171 | const tasks = rawData[tag].tasks;
172 |
173 | log(
174 | 'info',
175 | `Moving task/subtask ${sourceId} to ${destinationId} (tag: ${tag})`
176 | );
177 |
178 | // Parse source and destination IDs
179 | const isSourceSubtask = sourceId.includes('.');
180 | const isDestSubtask = destinationId.includes('.');
181 |
182 | let result;
183 |
184 | if (isSourceSubtask && isDestSubtask) {
185 | // Subtask to subtask
186 | result = moveSubtaskToSubtask(tasks, sourceId, destinationId);
187 | } else if (isSourceSubtask && !isDestSubtask) {
188 | // Subtask to task
189 | result = moveSubtaskToTask(tasks, sourceId, destinationId);
190 | } else if (!isSourceSubtask && isDestSubtask) {
191 | // Task to subtask
192 | result = moveTaskToSubtask(tasks, sourceId, destinationId);
193 | } else {
194 | // Task to task
195 | result = moveTaskToTask(tasks, sourceId, destinationId);
196 | }
197 |
198 | // Update the data structure with the modified tasks
199 | rawData[tag].tasks = tasks;
200 |
201 | // Always write the data object, never the _rawTaggedData directly
202 | // The writeJSON function will filter out _rawTaggedData automatically
203 | writeJSON(tasksPath, rawData, options.projectRoot, tag);
204 |
205 | // Note: Task file generation is no longer supported and has been removed
206 |
207 | return result;
208 | }
209 |
210 | // Helper functions for different move scenarios
211 | function moveSubtaskToSubtask(tasks, sourceId, destinationId) {
212 | // Parse IDs
213 | const [sourceParentId, sourceSubtaskId] = sourceId
214 | .split('.')
215 | .map((id) => parseInt(id, 10));
216 | const [destParentId, destSubtaskId] = destinationId
217 | .split('.')
218 | .map((id) => parseInt(id, 10));
219 |
220 | // Find source and destination parent tasks
221 | const sourceParentTask = tasks.find((t) => t.id === sourceParentId);
222 | const destParentTask = tasks.find((t) => t.id === destParentId);
223 |
224 | if (!sourceParentTask) {
225 | throw new MoveTaskError(
226 | MOVE_ERROR_CODES.PARENT_TASK_NOT_FOUND,
227 | `Source parent task with ID ${sourceParentId} not found`
228 | );
229 | }
230 | if (!destParentTask) {
231 | throw new MoveTaskError(
232 | MOVE_ERROR_CODES.PARENT_TASK_NOT_FOUND,
233 | `Destination parent task with ID ${destParentId} not found`
234 | );
235 | }
236 |
237 | // Initialize subtasks arrays if they don't exist (based on commit fixes)
238 | if (!sourceParentTask.subtasks) {
239 | sourceParentTask.subtasks = [];
240 | }
241 | if (!destParentTask.subtasks) {
242 | destParentTask.subtasks = [];
243 | }
244 |
245 | // Find source subtask
246 | const sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
247 | (st) => st.id === sourceSubtaskId
248 | );
249 | if (sourceSubtaskIndex === -1) {
250 | throw new MoveTaskError(
251 | MOVE_ERROR_CODES.SUBTASK_NOT_FOUND,
252 | `Source subtask ${sourceId} not found`
253 | );
254 | }
255 |
256 | const sourceSubtask = sourceParentTask.subtasks[sourceSubtaskIndex];
257 |
258 | if (sourceParentId === destParentId) {
259 | // Moving within the same parent
260 | if (destParentTask.subtasks.length > 0) {
261 | const destSubtaskIndex = destParentTask.subtasks.findIndex(
262 | (st) => st.id === destSubtaskId
263 | );
264 | if (destSubtaskIndex !== -1) {
265 | // Remove from old position
266 | sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
267 | // Insert at new position (adjust index if moving within same array)
268 | const adjustedIndex =
269 | sourceSubtaskIndex < destSubtaskIndex
270 | ? destSubtaskIndex - 1
271 | : destSubtaskIndex;
272 | destParentTask.subtasks.splice(adjustedIndex + 1, 0, sourceSubtask);
273 | } else {
274 | // Destination subtask doesn't exist, insert at end
275 | sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
276 | destParentTask.subtasks.push(sourceSubtask);
277 | }
278 | } else {
279 | // No existing subtasks, this will be the first one
280 | sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
281 | destParentTask.subtasks.push(sourceSubtask);
282 | }
283 | } else {
284 | // Moving between different parents
285 | moveSubtaskToAnotherParent(
286 | sourceSubtask,
287 | sourceParentTask,
288 | sourceSubtaskIndex,
289 | destParentTask,
290 | destSubtaskId
291 | );
292 | }
293 |
294 | return {
295 | message: `Moved subtask ${sourceId} to ${destinationId}`,
296 | movedItem: sourceSubtask
297 | };
298 | }
299 |
300 | function moveSubtaskToTask(tasks, sourceId, destinationId) {
301 | // Parse source ID
302 | const [sourceParentId, sourceSubtaskId] = sourceId
303 | .split('.')
304 | .map((id) => parseInt(id, 10));
305 | const destTaskId = parseInt(destinationId, 10);
306 |
307 | // Find source parent and destination task
308 | const sourceParentTask = tasks.find((t) => t.id === sourceParentId);
309 |
310 | if (!sourceParentTask) {
311 | throw new MoveTaskError(
312 | MOVE_ERROR_CODES.PARENT_TASK_NOT_FOUND,
313 | `Source parent task with ID ${sourceParentId} not found`
314 | );
315 | }
316 | if (!sourceParentTask.subtasks) {
317 | throw new MoveTaskError(
318 | MOVE_ERROR_CODES.PARENT_TASK_NO_SUBTASKS,
319 | `Source parent task ${sourceParentId} has no subtasks`
320 | );
321 | }
322 |
323 | // Find source subtask
324 | const sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
325 | (st) => st.id === sourceSubtaskId
326 | );
327 | if (sourceSubtaskIndex === -1) {
328 | throw new MoveTaskError(
329 | MOVE_ERROR_CODES.SUBTASK_NOT_FOUND,
330 | `Source subtask ${sourceId} not found`
331 | );
332 | }
333 |
334 | const sourceSubtask = sourceParentTask.subtasks[sourceSubtaskIndex];
335 |
336 | // Check if destination task exists
337 | const existingDestTask = tasks.find((t) => t.id === destTaskId);
338 | if (existingDestTask) {
339 | throw new MoveTaskError(
340 | MOVE_ERROR_CODES.TASK_ALREADY_EXISTS,
341 | `Cannot move to existing task ID ${destTaskId}. Choose a different ID or use subtask destination.`
342 | );
343 | }
344 |
345 | // Create new task from subtask
346 | const newTask = {
347 | id: destTaskId,
348 | title: sourceSubtask.title,
349 | description: sourceSubtask.description,
350 | status: sourceSubtask.status || 'pending',
351 | dependencies: sourceSubtask.dependencies || [],
352 | priority: sourceSubtask.priority || 'medium',
353 | details: sourceSubtask.details || '',
354 | testStrategy: sourceSubtask.testStrategy || '',
355 | subtasks: []
356 | };
357 |
358 | // Remove subtask from source parent
359 | sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
360 |
361 | // Insert new task in correct position
362 | const insertIndex = tasks.findIndex((t) => t.id > destTaskId);
363 | if (insertIndex === -1) {
364 | tasks.push(newTask);
365 | } else {
366 | tasks.splice(insertIndex, 0, newTask);
367 | }
368 |
369 | return {
370 | message: `Converted subtask ${sourceId} to task ${destinationId}`,
371 | movedItem: newTask
372 | };
373 | }
374 |
375 | function moveTaskToSubtask(tasks, sourceId, destinationId) {
376 | // Parse IDs
377 | const sourceTaskId = parseInt(sourceId, 10);
378 | const [destParentId, destSubtaskId] = destinationId
379 | .split('.')
380 | .map((id) => parseInt(id, 10));
381 |
382 | // Find source task and destination parent
383 | const sourceTaskIndex = tasks.findIndex((t) => t.id === sourceTaskId);
384 | const destParentTask = tasks.find((t) => t.id === destParentId);
385 |
386 | if (sourceTaskIndex === -1) {
387 | throw new MoveTaskError(
388 | MOVE_ERROR_CODES.TASK_NOT_FOUND,
389 | `Source task with ID ${sourceTaskId} not found`
390 | );
391 | }
392 | if (!destParentTask) {
393 | throw new MoveTaskError(
394 | MOVE_ERROR_CODES.PARENT_TASK_NOT_FOUND,
395 | `Destination parent task with ID ${destParentId} not found`
396 | );
397 | }
398 |
399 | const sourceTask = tasks[sourceTaskIndex];
400 |
401 | // Initialize subtasks array if it doesn't exist (based on commit fixes)
402 | if (!destParentTask.subtasks) {
403 | destParentTask.subtasks = [];
404 | }
405 |
406 | // Create new subtask from task
407 | const newSubtask = {
408 | id: destSubtaskId,
409 | title: sourceTask.title,
410 | description: sourceTask.description,
411 | status: sourceTask.status || 'pending',
412 | dependencies: sourceTask.dependencies || [],
413 | details: sourceTask.details || '',
414 | testStrategy: sourceTask.testStrategy || ''
415 | };
416 |
417 | // Find insertion position (based on commit fixes)
418 | let destSubtaskIndex = -1;
419 | if (destParentTask.subtasks.length > 0) {
420 | destSubtaskIndex = destParentTask.subtasks.findIndex(
421 | (st) => st.id === destSubtaskId
422 | );
423 | if (destSubtaskIndex === -1) {
424 | // Subtask doesn't exist, we'll insert at the end
425 | destSubtaskIndex = destParentTask.subtasks.length - 1;
426 | }
427 | }
428 |
429 | // Insert at specific position (based on commit fixes)
430 | const insertPosition = destSubtaskIndex === -1 ? 0 : destSubtaskIndex + 1;
431 | destParentTask.subtasks.splice(insertPosition, 0, newSubtask);
432 |
433 | // Remove the original task from the tasks array
434 | tasks.splice(sourceTaskIndex, 1);
435 |
436 | return {
437 | message: `Converted task ${sourceId} to subtask ${destinationId}`,
438 | movedItem: newSubtask
439 | };
440 | }
441 |
442 | function moveTaskToTask(tasks, sourceId, destinationId) {
443 | const sourceTaskId = parseInt(sourceId, 10);
444 | const destTaskId = parseInt(destinationId, 10);
445 |
446 | // Find source task
447 | const sourceTaskIndex = tasks.findIndex((t) => t.id === sourceTaskId);
448 | if (sourceTaskIndex === -1) {
449 | throw new MoveTaskError(
450 | MOVE_ERROR_CODES.TASK_NOT_FOUND,
451 | `Source task with ID ${sourceTaskId} not found`
452 | );
453 | }
454 |
455 | const sourceTask = tasks[sourceTaskIndex];
456 |
457 | // Check if destination exists
458 | const destTaskIndex = tasks.findIndex((t) => t.id === destTaskId);
459 |
460 | if (destTaskIndex !== -1) {
461 | // Destination exists - this could be overwriting or swapping
462 | const destTask = tasks[destTaskIndex];
463 |
464 | // For now, throw an error to avoid accidental overwrites
465 | throw new MoveTaskError(
466 | MOVE_ERROR_CODES.TASK_ALREADY_EXISTS,
467 | `Task with ID ${destTaskId} already exists. Use a different destination ID.`
468 | );
469 | } else {
470 | // Destination doesn't exist - create new task ID
471 | return moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId);
472 | }
473 | }
474 |
475 | function moveSubtaskToAnotherParent(
476 | sourceSubtask,
477 | sourceParentTask,
478 | sourceSubtaskIndex,
479 | destParentTask,
480 | destSubtaskId
481 | ) {
482 | const destSubtaskId_num = parseInt(destSubtaskId, 10);
483 |
484 | // Create new subtask with destination ID
485 | const newSubtask = {
486 | ...sourceSubtask,
487 | id: destSubtaskId_num
488 | };
489 |
490 | // Initialize subtasks array if it doesn't exist (based on commit fixes)
491 | if (!destParentTask.subtasks) {
492 | destParentTask.subtasks = [];
493 | }
494 |
495 | // Find insertion position
496 | let destSubtaskIndex = -1;
497 | if (destParentTask.subtasks.length > 0) {
498 | destSubtaskIndex = destParentTask.subtasks.findIndex(
499 | (st) => st.id === destSubtaskId_num
500 | );
501 | if (destSubtaskIndex === -1) {
502 | // Subtask doesn't exist, we'll insert at the end
503 | destSubtaskIndex = destParentTask.subtasks.length - 1;
504 | }
505 | }
506 |
507 | // Insert at the destination position (based on commit fixes)
508 | const insertPosition = destSubtaskIndex === -1 ? 0 : destSubtaskIndex + 1;
509 | destParentTask.subtasks.splice(insertPosition, 0, newSubtask);
510 |
511 | // Remove the subtask from the original parent
512 | sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
513 |
514 | return newSubtask;
515 | }
516 |
517 | function moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId) {
518 | const destTaskIndex = tasks.findIndex((t) => t.id === destTaskId);
519 |
520 | // Create moved task with new ID
521 | const movedTask = {
522 | ...sourceTask,
523 | id: destTaskId
524 | };
525 |
526 | // Update any dependencies that reference the old task ID
527 | tasks.forEach((task) => {
528 | if (task.dependencies && task.dependencies.includes(sourceTask.id)) {
529 | const depIndex = task.dependencies.indexOf(sourceTask.id);
530 | task.dependencies[depIndex] = destTaskId;
531 | }
532 | if (task.subtasks) {
533 | task.subtasks.forEach((subtask) => {
534 | if (
535 | subtask.dependencies &&
536 | subtask.dependencies.includes(sourceTask.id)
537 | ) {
538 | const depIndex = subtask.dependencies.indexOf(sourceTask.id);
539 | subtask.dependencies[depIndex] = destTaskId;
540 | }
541 | });
542 | }
543 | });
544 |
545 | // Update dependencies within movedTask's subtasks that reference sibling subtasks
546 | if (Array.isArray(movedTask.subtasks)) {
547 | movedTask.subtasks.forEach((subtask) => {
548 | if (Array.isArray(subtask.dependencies)) {
549 | subtask.dependencies = subtask.dependencies.map((dep) => {
550 | // If dependency is a string like "oldParent.subId", update to "newParent.subId"
551 | if (typeof dep === 'string' && dep.includes('.')) {
552 | const [depParent, depSub] = dep.split('.');
553 | if (parseInt(depParent, 10) === sourceTask.id) {
554 | return `${destTaskId}.${depSub}`;
555 | }
556 | }
557 | // If dependency is a number, and matches a subtask ID in the moved task, leave as is (context is implied)
558 | return dep;
559 | });
560 | }
561 | });
562 | }
563 |
564 | // Strategy based on commit fixes: remove source first, then replace destination
565 | // This avoids index shifting problems
566 |
567 | // Remove the source task first
568 | tasks.splice(sourceTaskIndex, 1);
569 |
570 | // Adjust the destination index if the source was before the destination
571 | // Since we removed the source, indices after it shift down by 1
572 | const adjustedDestIndex =
573 | sourceTaskIndex < destTaskIndex ? destTaskIndex - 1 : destTaskIndex;
574 |
575 | // Replace the placeholder destination task with the moved task (based on commit fixes)
576 | if (adjustedDestIndex >= 0 && adjustedDestIndex < tasks.length) {
577 | tasks[adjustedDestIndex] = movedTask;
578 | } else {
579 | // Insert at the end if index is out of bounds
580 | tasks.push(movedTask);
581 | }
582 |
583 | log('info', `Moved task ${sourceTask.id} to new ID ${destTaskId}`);
584 |
585 | return {
586 | message: `Moved task ${sourceTask.id} to new ID ${destTaskId}`,
587 | movedItem: movedTask
588 | };
589 | }
590 |
591 | /**
592 | * Get all tasks from all tags with tag information
593 | * @param {Object} rawData - The raw tagged data object
594 | * @returns {Array} A flat array of all task objects with tag property
595 | */
596 | function getAllTasksWithTags(rawData) {
597 | let allTasks = [];
598 | for (const tagName in rawData) {
599 | if (
600 | Object.prototype.hasOwnProperty.call(rawData, tagName) &&
601 | rawData[tagName] &&
602 | Array.isArray(rawData[tagName].tasks)
603 | ) {
604 | const tasksWithTag = rawData[tagName].tasks.map((task) => ({
605 | ...task,
606 | tag: tagName
607 | }));
608 | allTasks = allTasks.concat(tasksWithTag);
609 | }
610 | }
611 | return allTasks;
612 | }
613 |
614 | /**
615 | * Validate move operation parameters and data
616 | * @param {string} tasksPath - Path to tasks.json file
617 | * @param {Array} taskIds - Array of task IDs to move
618 | * @param {string} sourceTag - Source tag name
619 | * @param {string} targetTag - Target tag name
620 | * @param {Object} context - Context object
621 | * @returns {Object} Validation result with rawData and sourceTasks
622 | */
623 | async function validateMove(tasksPath, taskIds, sourceTag, targetTag, context) {
624 | const { projectRoot } = context;
625 |
626 | // Read the raw data without tag resolution to preserve tagged structure
627 | let rawData = readJSON(tasksPath, projectRoot, sourceTag);
628 |
629 | // Handle the case where readJSON returns resolved data with _rawTaggedData
630 | if (rawData && rawData._rawTaggedData) {
631 | rawData = rawData._rawTaggedData;
632 | }
633 |
634 | // Validate source tag exists
635 | if (
636 | !rawData ||
637 | !rawData[sourceTag] ||
638 | !Array.isArray(rawData[sourceTag].tasks)
639 | ) {
640 | throw new MoveTaskError(
641 | MOVE_ERROR_CODES.INVALID_SOURCE_TAG,
642 | `Source tag "${sourceTag}" not found or invalid`
643 | );
644 | }
645 |
646 | // Create target tag if it doesn't exist
647 | if (!rawData[targetTag]) {
648 | rawData[targetTag] = { tasks: [] };
649 | log('info', `Created new tag "${targetTag}"`);
650 | }
651 |
652 | // Normalize all IDs to strings once for consistent comparison
653 | const normalizedSearchIds = taskIds.map((id) => String(id));
654 |
655 | const sourceTasks = rawData[sourceTag].tasks.filter((t) => {
656 | const normalizedTaskId = String(t.id);
657 | return normalizedSearchIds.includes(normalizedTaskId);
658 | });
659 |
660 | // Validate subtask movement
661 | taskIds.forEach((taskId) => {
662 | validateSubtaskMove(taskId, sourceTag, targetTag);
663 | });
664 |
665 | return { rawData, sourceTasks };
666 | }
667 |
668 | /**
669 | * Load and prepare task data for move operation
670 | * @param {Object} validation - Validation result from validateMove
671 | * @returns {Object} Prepared data with rawData, sourceTasks, and allTasks
672 | */
673 | async function prepareTaskData(validation) {
674 | const { rawData, sourceTasks } = validation;
675 |
676 | // Get all tasks for validation
677 | const allTasks = getAllTasksWithTags(rawData);
678 |
679 | return { rawData, sourceTasks, allTasks };
680 | }
681 |
682 | /**
683 | * Resolve dependencies and determine tasks to move
684 | * @param {Array} sourceTasks - Source tasks to move
685 | * @param {Array} allTasks - All available tasks from all tags
686 | * @param {Object} options - Move options
687 | * @param {Array} taskIds - Original task IDs
688 | * @param {string} sourceTag - Source tag name
689 | * @param {string} targetTag - Target tag name
690 | * @returns {Object} Tasks to move and dependency resolution info
691 | */
692 | async function resolveDependencies(
693 | sourceTasks,
694 | allTasks,
695 | options,
696 | taskIds,
697 | sourceTag,
698 | targetTag
699 | ) {
700 | const { withDependencies = false, ignoreDependencies = false } = options;
701 |
702 | // Scope allTasks to the source tag to avoid cross-tag contamination when
703 | // computing dependency chains for --with-dependencies
704 | const tasksInSourceTag = Array.isArray(allTasks)
705 | ? allTasks.filter((t) => t && t.tag === sourceTag)
706 | : [];
707 |
708 | // Handle --with-dependencies flag first (regardless of cross-tag dependencies)
709 | if (withDependencies) {
710 | // Move dependent tasks along with main tasks
711 | // Find ALL dependencies recursively, but only using tasks from the source tag
712 | const allDependentTaskIdsRaw = findAllDependenciesRecursively(
713 | sourceTasks,
714 | tasksInSourceTag,
715 | { maxDepth: 100, includeSelf: false }
716 | );
717 |
718 | // Filter dependent IDs to those that actually exist in the source tag
719 | const sourceTagIds = new Set(
720 | tasksInSourceTag.map((t) =>
721 | typeof t.id === 'string' ? parseInt(t.id, 10) : t.id
722 | )
723 | );
724 | const allDependentTaskIds = allDependentTaskIdsRaw.filter((depId) => {
725 | // Only numeric task IDs are eligible to be moved (subtasks cannot be moved cross-tag)
726 | const normalizedId = normalizeDependency(depId);
727 | return Number.isFinite(normalizedId) && sourceTagIds.has(normalizedId);
728 | });
729 |
730 | const allTaskIdsToMove = [...new Set([...taskIds, ...allDependentTaskIds])];
731 |
732 | log(
733 | 'info',
734 | `Moving ${allTaskIdsToMove.length} tasks (including dependencies): ${allTaskIdsToMove.join(', ')}`
735 | );
736 |
737 | return {
738 | tasksToMove: allTaskIdsToMove,
739 | dependencyResolution: {
740 | type: 'with-dependencies',
741 | dependentTasks: allDependentTaskIds
742 | }
743 | };
744 | }
745 |
746 | // Find cross-tag dependencies (these shouldn't exist since dependencies are only within tags)
747 | const crossTagDependencies = findCrossTagDependencies(
748 | sourceTasks,
749 | sourceTag,
750 | targetTag,
751 | allTasks
752 | );
753 |
754 | if (crossTagDependencies.length > 0) {
755 | if (ignoreDependencies) {
756 | // Break cross-tag dependencies (edge case - shouldn't normally happen)
757 | sourceTasks.forEach((task) => {
758 | const sourceTagTasks = tasksInSourceTag;
759 | const targetTagTasks = Array.isArray(allTasks)
760 | ? allTasks.filter((t) => t && t.tag === targetTag)
761 | : [];
762 | task.dependencies = task.dependencies.filter((depId) => {
763 | const parentTaskId = normalizeDependency(depId);
764 |
765 | // If dependency resolves to a task in the source tag, drop it (would be cross-tag after move)
766 | if (
767 | Number.isFinite(parentTaskId) &&
768 | sourceTagTasks.some((t) => t.id === parentTaskId)
769 | ) {
770 | return false;
771 | }
772 |
773 | // If dependency resolves to a task in the target tag, keep it
774 | if (
775 | Number.isFinite(parentTaskId) &&
776 | targetTagTasks.some((t) => t.id === parentTaskId)
777 | ) {
778 | return true;
779 | }
780 |
781 | // Otherwise, keep as-is (unknown/unresolved dependency)
782 | return true;
783 | });
784 | });
785 |
786 | log(
787 | 'warn',
788 | `Removed ${crossTagDependencies.length} cross-tag dependencies`
789 | );
790 |
791 | return {
792 | tasksToMove: taskIds,
793 | dependencyResolution: {
794 | type: 'ignored-dependencies',
795 | conflicts: crossTagDependencies
796 | }
797 | };
798 | } else {
799 | // Block move and show error
800 | throw new MoveTaskError(
801 | MOVE_ERROR_CODES.CROSS_TAG_DEPENDENCY_CONFLICTS,
802 | `Cannot move tasks: ${crossTagDependencies.length} cross-tag dependency conflicts found`,
803 | {
804 | conflicts: crossTagDependencies,
805 | sourceTag,
806 | targetTag,
807 | taskIds
808 | }
809 | );
810 | }
811 | }
812 |
813 | return {
814 | tasksToMove: taskIds,
815 | dependencyResolution: { type: 'no-conflicts' }
816 | };
817 | }
818 |
819 | /**
820 | * Execute the actual move operation
821 | * @param {Array} tasksToMove - Array of task IDs to move
822 | * @param {string} sourceTag - Source tag name
823 | * @param {string} targetTag - Target tag name
824 | * @param {Object} rawData - Raw data object
825 | * @param {Object} context - Context object
826 | * @param {string} tasksPath - Path to tasks.json file
827 | * @returns {Object} Move operation result
828 | */
829 | async function executeMoveOperation(
830 | tasksToMove,
831 | sourceTag,
832 | targetTag,
833 | rawData,
834 | context,
835 | tasksPath
836 | ) {
837 | const { projectRoot } = context;
838 | const movedTasks = [];
839 |
840 | // Move each task from source to target tag
841 | for (const taskId of tasksToMove) {
842 | // Normalize taskId to number for comparison
843 | const normalizedTaskId =
844 | typeof taskId === 'string' ? parseInt(taskId, 10) : taskId;
845 |
846 | const sourceTaskIndex = rawData[sourceTag].tasks.findIndex(
847 | (t) => t.id === normalizedTaskId
848 | );
849 |
850 | if (sourceTaskIndex === -1) {
851 | throw new MoveTaskError(
852 | MOVE_ERROR_CODES.TASK_NOT_FOUND,
853 | `Task ${taskId} not found in source tag "${sourceTag}"`
854 | );
855 | }
856 |
857 | const taskToMove = rawData[sourceTag].tasks[sourceTaskIndex];
858 |
859 | // Check for ID conflicts in target tag
860 | const existingTaskIndex = rawData[targetTag].tasks.findIndex(
861 | (t) => t.id === normalizedTaskId
862 | );
863 | if (existingTaskIndex !== -1) {
864 | throw new MoveTaskError(
865 | MOVE_ERROR_CODES.TASK_ALREADY_EXISTS,
866 | `Task ${taskId} already exists in target tag "${targetTag}"`,
867 | {
868 | conflictingId: normalizedTaskId,
869 | targetTag,
870 | suggestions: [
871 | 'Choose a different target tag without conflicting IDs',
872 | 'Move a different set of IDs (avoid existing ones)',
873 | 'If needed, move within-tag to a new ID first, then cross-tag move'
874 | ]
875 | }
876 | );
877 | }
878 |
879 | // Remove from source tag
880 | rawData[sourceTag].tasks.splice(sourceTaskIndex, 1);
881 |
882 | // Preserve task metadata and add to target tag
883 | const taskWithPreservedMetadata = preserveTaskMetadata(
884 | taskToMove,
885 | sourceTag,
886 | targetTag
887 | );
888 | rawData[targetTag].tasks.push(taskWithPreservedMetadata);
889 |
890 | movedTasks.push({
891 | id: taskId,
892 | fromTag: sourceTag,
893 | toTag: targetTag
894 | });
895 |
896 | log('info', `Moved task ${taskId} from "${sourceTag}" to "${targetTag}"`);
897 | }
898 |
899 | return { rawData, movedTasks };
900 | }
901 |
902 | /**
903 | * Finalize the move operation by saving data and returning result
904 | * @param {Object} moveResult - Result from executeMoveOperation
905 | * @param {string} tasksPath - Path to tasks.json file
906 | * @param {Object} context - Context object
907 | * @param {string} sourceTag - Source tag name
908 | * @param {string} targetTag - Target tag name
909 | * @returns {Object} Final result object
910 | */
911 | async function finalizeMove(
912 | moveResult,
913 | tasksPath,
914 | context,
915 | sourceTag,
916 | targetTag,
917 | dependencyResolution
918 | ) {
919 | const { projectRoot } = context;
920 | const { rawData, movedTasks } = moveResult;
921 |
922 | // Write the updated data
923 | writeJSON(tasksPath, rawData, projectRoot, null);
924 |
925 | const response = {
926 | message: `Successfully moved ${movedTasks.length} tasks from "${sourceTag}" to "${targetTag}"`,
927 | movedTasks
928 | };
929 |
930 | // If we intentionally broke cross-tag dependencies, provide tips to validate & fix
931 | if (
932 | dependencyResolution &&
933 | dependencyResolution.type === 'ignored-dependencies'
934 | ) {
935 | response.tips = [
936 | 'Run "task-master validate-dependencies" to check for dependency issues.',
937 | 'Run "task-master fix-dependencies" to automatically repair dangling dependencies.'
938 | ];
939 | }
940 |
941 | return response;
942 | }
943 |
944 | /**
945 | * Move tasks between different tags with dependency handling
946 | * @param {string} tasksPath - Path to tasks.json file
947 | * @param {Array} taskIds - Array of task IDs to move
948 | * @param {string} sourceTag - Source tag name
949 | * @param {string} targetTag - Target tag name
950 | * @param {Object} options - Move options
951 | * @param {boolean} options.withDependencies - Move dependent tasks along with main task
952 | * @param {boolean} options.ignoreDependencies - Break cross-tag dependencies during move
953 | * @param {Object} context - Context object containing projectRoot and tag information
954 | * @returns {Object} Result object with moved task details
955 | */
956 | async function moveTasksBetweenTags(
957 | tasksPath,
958 | taskIds,
959 | sourceTag,
960 | targetTag,
961 | options = {},
962 | context = {}
963 | ) {
964 | // 1. Validation phase
965 | const validation = await validateMove(
966 | tasksPath,
967 | taskIds,
968 | sourceTag,
969 | targetTag,
970 | context
971 | );
972 |
973 | // 2. Load and prepare data
974 | const { rawData, sourceTasks, allTasks } = await prepareTaskData(validation);
975 |
976 | // 3. Handle dependencies
977 | const { tasksToMove, dependencyResolution } = await resolveDependencies(
978 | sourceTasks,
979 | allTasks,
980 | options,
981 | taskIds,
982 | sourceTag,
983 | targetTag
984 | );
985 |
986 | // 4. Execute move
987 | const moveResult = await executeMoveOperation(
988 | tasksToMove,
989 | sourceTag,
990 | targetTag,
991 | rawData,
992 | context,
993 | tasksPath
994 | );
995 |
996 | // 5. Save and return
997 | return await finalizeMove(
998 | moveResult,
999 | tasksPath,
1000 | context,
1001 | sourceTag,
1002 | targetTag,
1003 | dependencyResolution
1004 | );
1005 | }
1006 |
1007 | /**
1008 | * Detect ID conflicts in target tag
1009 | * @param {Array} taskIds - Array of task IDs to check
1010 | * @param {string} targetTag - Target tag name
1011 | * @param {Object} rawData - Raw data object
1012 | * @returns {Array} Array of conflicting task IDs
1013 | */
1014 | function detectIdConflicts(taskIds, targetTag, rawData) {
1015 | const conflicts = [];
1016 |
1017 | if (!rawData[targetTag] || !Array.isArray(rawData[targetTag].tasks)) {
1018 | return conflicts;
1019 | }
1020 |
1021 | taskIds.forEach((taskId) => {
1022 | // Normalize taskId to number for comparison
1023 | const normalizedTaskId =
1024 | typeof taskId === 'string' ? parseInt(taskId, 10) : taskId;
1025 | const existingTask = rawData[targetTag].tasks.find(
1026 | (t) => t.id === normalizedTaskId
1027 | );
1028 | if (existingTask) {
1029 | conflicts.push(taskId);
1030 | }
1031 | });
1032 |
1033 | return conflicts;
1034 | }
1035 |
1036 | /**
1037 | * Preserve task metadata during cross-tag moves
1038 | * @param {Object} task - Task object
1039 | * @param {string} sourceTag - Source tag name
1040 | * @param {string} targetTag - Target tag name
1041 | * @returns {Object} Task object with preserved metadata
1042 | */
1043 | function preserveTaskMetadata(task, sourceTag, targetTag) {
1044 | // Update the tag property to reflect the new location
1045 | task.tag = targetTag;
1046 |
1047 | // Add move history to task metadata
1048 | if (!task.metadata) {
1049 | task.metadata = {};
1050 | }
1051 |
1052 | if (!task.metadata.moveHistory) {
1053 | task.metadata.moveHistory = [];
1054 | }
1055 |
1056 | task.metadata.moveHistory.push({
1057 | fromTag: sourceTag,
1058 | toTag: targetTag,
1059 | timestamp: new Date().toISOString()
1060 | });
1061 |
1062 | return task;
1063 | }
1064 |
1065 | export default moveTask;
1066 | export {
1067 | moveTasksBetweenTags,
1068 | getAllTasksWithTags,
1069 | detectIdConflicts,
1070 | preserveTaskMetadata,
1071 | MoveTaskError,
1072 | MOVE_ERROR_CODES
1073 | };
1074 |
```
--------------------------------------------------------------------------------
/tests/unit/ai-services-unified.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { jest } from '@jest/globals';
2 |
3 | // Mock config-manager
4 | const mockGetMainProvider = jest.fn();
5 | const mockGetMainModelId = jest.fn();
6 | const mockGetResearchProvider = jest.fn();
7 | const mockGetResearchModelId = jest.fn();
8 | const mockGetFallbackProvider = jest.fn();
9 | const mockGetFallbackModelId = jest.fn();
10 | const mockGetParametersForRole = jest.fn();
11 | const mockGetResponseLanguage = jest.fn();
12 | const mockGetUserId = jest.fn();
13 | const mockGetDebugFlag = jest.fn();
14 | const mockIsApiKeySet = jest.fn();
15 |
16 | // --- Mock MODEL_MAP Data ---
17 | // Provide a simplified structure sufficient for cost calculation tests
18 | const mockModelMap = {
19 | anthropic: [
20 | {
21 | id: 'test-main-model',
22 | cost_per_1m_tokens: { input: 3, output: 15, currency: 'USD' }
23 | },
24 | {
25 | id: 'test-fallback-model',
26 | cost_per_1m_tokens: { input: 3, output: 15, currency: 'USD' }
27 | }
28 | ],
29 | perplexity: [
30 | {
31 | id: 'test-research-model',
32 | cost_per_1m_tokens: { input: 1, output: 1, currency: 'USD' }
33 | }
34 | ],
35 | openai: [
36 | {
37 | id: 'test-openai-model',
38 | cost_per_1m_tokens: { input: 2, output: 6, currency: 'USD' }
39 | }
40 | ]
41 | // Add other providers/models if needed for specific tests
42 | };
43 | const mockGetBaseUrlForRole = jest.fn();
44 | const mockGetAllProviders = jest.fn();
45 | const mockGetOllamaBaseURL = jest.fn();
46 | const mockGetAzureBaseURL = jest.fn();
47 | const mockGetBedrockBaseURL = jest.fn();
48 | const mockGetVertexProjectId = jest.fn();
49 | const mockGetVertexLocation = jest.fn();
50 | const mockGetAvailableModels = jest.fn();
51 | const mockValidateProvider = jest.fn();
52 | const mockValidateProviderModelCombination = jest.fn();
53 | const mockGetConfig = jest.fn();
54 | const mockWriteConfig = jest.fn();
55 | const mockIsConfigFilePresent = jest.fn();
56 | const mockGetMcpApiKeyStatus = jest.fn();
57 | const mockGetMainMaxTokens = jest.fn();
58 | const mockGetMainTemperature = jest.fn();
59 | const mockGetResearchMaxTokens = jest.fn();
60 | const mockGetResearchTemperature = jest.fn();
61 | const mockGetFallbackMaxTokens = jest.fn();
62 | const mockGetFallbackTemperature = jest.fn();
63 | const mockGetLogLevel = jest.fn();
64 | const mockGetDefaultNumTasks = jest.fn();
65 | const mockGetDefaultSubtasks = jest.fn();
66 | const mockGetDefaultPriority = jest.fn();
67 | const mockGetProjectName = jest.fn();
68 |
69 | jest.unstable_mockModule('../../scripts/modules/config-manager.js', () => ({
70 | // Core config access
71 | getConfig: mockGetConfig,
72 | writeConfig: mockWriteConfig,
73 | isConfigFilePresent: mockIsConfigFilePresent,
74 | ConfigurationError: class ConfigurationError extends Error {
75 | constructor(message) {
76 | super(message);
77 | this.name = 'ConfigurationError';
78 | }
79 | },
80 |
81 | // Validation
82 | validateProvider: mockValidateProvider,
83 | validateProviderModelCombination: mockValidateProviderModelCombination,
84 | VALID_PROVIDERS: ['anthropic', 'perplexity', 'openai', 'google'],
85 | MODEL_MAP: mockModelMap,
86 | getAvailableModels: mockGetAvailableModels,
87 |
88 | // Role-specific getters
89 | getMainProvider: mockGetMainProvider,
90 | getMainModelId: mockGetMainModelId,
91 | getMainMaxTokens: mockGetMainMaxTokens,
92 | getMainTemperature: mockGetMainTemperature,
93 | getResearchProvider: mockGetResearchProvider,
94 | getResearchModelId: mockGetResearchModelId,
95 | getResearchMaxTokens: mockGetResearchMaxTokens,
96 | getResearchTemperature: mockGetResearchTemperature,
97 | getFallbackProvider: mockGetFallbackProvider,
98 | getFallbackModelId: mockGetFallbackModelId,
99 | getFallbackMaxTokens: mockGetFallbackMaxTokens,
100 | getFallbackTemperature: mockGetFallbackTemperature,
101 | getParametersForRole: mockGetParametersForRole,
102 | getResponseLanguage: mockGetResponseLanguage,
103 | getUserId: mockGetUserId,
104 | getDebugFlag: mockGetDebugFlag,
105 | getBaseUrlForRole: mockGetBaseUrlForRole,
106 |
107 | // Global settings
108 | getLogLevel: mockGetLogLevel,
109 | getDefaultNumTasks: mockGetDefaultNumTasks,
110 | getDefaultSubtasks: mockGetDefaultSubtasks,
111 | getDefaultPriority: mockGetDefaultPriority,
112 | getProjectName: mockGetProjectName,
113 |
114 | // API Key and provider functions
115 | isApiKeySet: mockIsApiKeySet,
116 | getAllProviders: mockGetAllProviders,
117 | getOllamaBaseURL: mockGetOllamaBaseURL,
118 | getAzureBaseURL: mockGetAzureBaseURL,
119 | getBedrockBaseURL: mockGetBedrockBaseURL,
120 | getVertexProjectId: mockGetVertexProjectId,
121 | getVertexLocation: mockGetVertexLocation,
122 | getMcpApiKeyStatus: mockGetMcpApiKeyStatus,
123 |
124 | // Providers without API keys
125 | providersWithoutApiKeys: ['ollama', 'bedrock', 'gemini-cli', 'codex-cli']
126 | }));
127 |
128 | // Mock AI Provider Classes with proper methods
129 | const mockAnthropicProvider = {
130 | generateText: jest.fn(),
131 | streamText: jest.fn(),
132 | generateObject: jest.fn(),
133 | getRequiredApiKeyName: jest.fn(() => 'ANTHROPIC_API_KEY'),
134 | isRequiredApiKey: jest.fn(() => true)
135 | };
136 |
137 | const mockPerplexityProvider = {
138 | generateText: jest.fn(),
139 | streamText: jest.fn(),
140 | generateObject: jest.fn(),
141 | getRequiredApiKeyName: jest.fn(() => 'PERPLEXITY_API_KEY'),
142 | isRequiredApiKey: jest.fn(() => true)
143 | };
144 |
145 | const mockOpenAIProvider = {
146 | generateText: jest.fn(),
147 | streamText: jest.fn(),
148 | generateObject: jest.fn(),
149 | getRequiredApiKeyName: jest.fn(() => 'OPENAI_API_KEY'),
150 | isRequiredApiKey: jest.fn(() => true)
151 | };
152 |
153 | const mockOllamaProvider = {
154 | generateText: jest.fn(),
155 | streamText: jest.fn(),
156 | generateObject: jest.fn(),
157 | getRequiredApiKeyName: jest.fn(() => null),
158 | isRequiredApiKey: jest.fn(() => false)
159 | };
160 |
161 | // Codex CLI mock provider instance
162 | const mockCodexProvider = {
163 | generateText: jest.fn(),
164 | streamText: jest.fn(),
165 | generateObject: jest.fn(),
166 | getRequiredApiKeyName: jest.fn(() => 'OPENAI_API_KEY'),
167 | isRequiredApiKey: jest.fn(() => false)
168 | };
169 |
170 | // Claude Code mock provider instance
171 | const mockClaudeProvider = {
172 | generateText: jest.fn(),
173 | streamText: jest.fn(),
174 | generateObject: jest.fn(),
175 | getRequiredApiKeyName: jest.fn(() => 'CLAUDE_CODE_API_KEY'),
176 | isRequiredApiKey: jest.fn(() => false)
177 | };
178 |
179 | // Mock the provider classes to return our mock instances
180 | jest.unstable_mockModule('../../src/ai-providers/index.js', () => ({
181 | AnthropicAIProvider: jest.fn(() => mockAnthropicProvider),
182 | PerplexityAIProvider: jest.fn(() => mockPerplexityProvider),
183 | GoogleAIProvider: jest.fn(() => ({
184 | generateText: jest.fn(),
185 | streamText: jest.fn(),
186 | generateObject: jest.fn(),
187 | getRequiredApiKeyName: jest.fn(() => 'GOOGLE_GENERATIVE_AI_API_KEY'),
188 | isRequiredApiKey: jest.fn(() => true)
189 | })),
190 | OpenAIProvider: jest.fn(() => mockOpenAIProvider),
191 | XAIProvider: jest.fn(() => ({
192 | generateText: jest.fn(),
193 | streamText: jest.fn(),
194 | generateObject: jest.fn(),
195 | getRequiredApiKeyName: jest.fn(() => 'XAI_API_KEY'),
196 | isRequiredApiKey: jest.fn(() => true)
197 | })),
198 | GroqProvider: jest.fn(() => ({
199 | generateText: jest.fn(),
200 | streamText: jest.fn(),
201 | generateObject: jest.fn(),
202 | getRequiredApiKeyName: jest.fn(() => 'GROQ_API_KEY'),
203 | isRequiredApiKey: jest.fn(() => true)
204 | })),
205 | OpenRouterAIProvider: jest.fn(() => ({
206 | generateText: jest.fn(),
207 | streamText: jest.fn(),
208 | generateObject: jest.fn(),
209 | getRequiredApiKeyName: jest.fn(() => 'OPENROUTER_API_KEY'),
210 | isRequiredApiKey: jest.fn(() => true)
211 | })),
212 | OllamaAIProvider: jest.fn(() => mockOllamaProvider),
213 | BedrockAIProvider: jest.fn(() => ({
214 | generateText: jest.fn(),
215 | streamText: jest.fn(),
216 | generateObject: jest.fn(),
217 | getRequiredApiKeyName: jest.fn(() => 'AWS_ACCESS_KEY_ID'),
218 | isRequiredApiKey: jest.fn(() => false)
219 | })),
220 | AzureProvider: jest.fn(() => ({
221 | generateText: jest.fn(),
222 | streamText: jest.fn(),
223 | generateObject: jest.fn(),
224 | getRequiredApiKeyName: jest.fn(() => 'AZURE_API_KEY'),
225 | isRequiredApiKey: jest.fn(() => true)
226 | })),
227 | VertexAIProvider: jest.fn(() => ({
228 | generateText: jest.fn(),
229 | streamText: jest.fn(),
230 | generateObject: jest.fn(),
231 | getRequiredApiKeyName: jest.fn(() => null),
232 | isRequiredApiKey: jest.fn(() => false)
233 | })),
234 | ClaudeCodeProvider: jest.fn(() => mockClaudeProvider),
235 | GeminiCliProvider: jest.fn(() => ({
236 | generateText: jest.fn(),
237 | streamText: jest.fn(),
238 | generateObject: jest.fn(),
239 | getRequiredApiKeyName: jest.fn(() => 'GEMINI_API_KEY'),
240 | isRequiredApiKey: jest.fn(() => false)
241 | })),
242 | CodexCliProvider: jest.fn(() => mockCodexProvider),
243 | GrokCliProvider: jest.fn(() => ({
244 | generateText: jest.fn(),
245 | streamText: jest.fn(),
246 | generateObject: jest.fn(),
247 | getRequiredApiKeyName: jest.fn(() => 'XAI_API_KEY'),
248 | isRequiredApiKey: jest.fn(() => false)
249 | })),
250 | OpenAICompatibleProvider: jest.fn(() => ({
251 | generateText: jest.fn(),
252 | streamText: jest.fn(),
253 | generateObject: jest.fn(),
254 | getRequiredApiKeyName: jest.fn(() => 'OPENAI_COMPATIBLE_API_KEY'),
255 | isRequiredApiKey: jest.fn(() => true)
256 | })),
257 | ZAIProvider: jest.fn(() => ({
258 | generateText: jest.fn(),
259 | streamText: jest.fn(),
260 | generateObject: jest.fn(),
261 | getRequiredApiKeyName: jest.fn(() => 'ZAI_API_KEY'),
262 | isRequiredApiKey: jest.fn(() => true)
263 | })),
264 | ZAICodingProvider: jest.fn(() => ({
265 | generateText: jest.fn(),
266 | streamText: jest.fn(),
267 | generateObject: jest.fn(),
268 | getRequiredApiKeyName: jest.fn(() => 'ZAI_API_KEY'),
269 | isRequiredApiKey: jest.fn(() => true)
270 | })),
271 | LMStudioProvider: jest.fn(() => ({
272 | generateText: jest.fn(),
273 | streamText: jest.fn(),
274 | generateObject: jest.fn(),
275 | getRequiredApiKeyName: jest.fn(() => 'LMSTUDIO_API_KEY'),
276 | isRequiredApiKey: jest.fn(() => false)
277 | }))
278 | }));
279 |
280 | // Mock utils logger, API key resolver, AND findProjectRoot
281 | const mockLog = jest.fn();
282 | const mockResolveEnvVariable = jest.fn();
283 | const mockFindProjectRoot = jest.fn();
284 | const mockIsSilentMode = jest.fn();
285 | const mockLogAiUsage = jest.fn();
286 | const mockFindCycles = jest.fn();
287 | const mockFormatTaskId = jest.fn();
288 | const mockTaskExists = jest.fn();
289 | const mockFindTaskById = jest.fn();
290 | const mockTruncate = jest.fn();
291 | const mockToKebabCase = jest.fn();
292 | const mockDetectCamelCaseFlags = jest.fn();
293 | const mockDisableSilentMode = jest.fn();
294 | const mockEnableSilentMode = jest.fn();
295 | const mockGetTaskManager = jest.fn();
296 | const mockAddComplexityToTask = jest.fn();
297 | const mockReadJSON = jest.fn();
298 | const mockWriteJSON = jest.fn();
299 | const mockSanitizePrompt = jest.fn();
300 | const mockReadComplexityReport = jest.fn();
301 | const mockFindTaskInComplexityReport = jest.fn();
302 | const mockAggregateTelemetry = jest.fn();
303 | const mockGetCurrentTag = jest.fn(() => 'master');
304 | const mockResolveTag = jest.fn(() => 'master');
305 | const mockGetTasksForTag = jest.fn(() => []);
306 |
307 | jest.unstable_mockModule('../../scripts/modules/utils.js', () => ({
308 | LOG_LEVELS: { error: 0, warn: 1, info: 2, debug: 3 },
309 | log: mockLog,
310 | resolveEnvVariable: mockResolveEnvVariable,
311 | findProjectRoot: mockFindProjectRoot,
312 | isSilentMode: mockIsSilentMode,
313 | logAiUsage: mockLogAiUsage,
314 | findCycles: mockFindCycles,
315 | formatTaskId: mockFormatTaskId,
316 | taskExists: mockTaskExists,
317 | findTaskById: mockFindTaskById,
318 | truncate: mockTruncate,
319 | toKebabCase: mockToKebabCase,
320 | detectCamelCaseFlags: mockDetectCamelCaseFlags,
321 | disableSilentMode: mockDisableSilentMode,
322 | enableSilentMode: mockEnableSilentMode,
323 | getTaskManager: mockGetTaskManager,
324 | addComplexityToTask: mockAddComplexityToTask,
325 | readJSON: mockReadJSON,
326 | writeJSON: mockWriteJSON,
327 | sanitizePrompt: mockSanitizePrompt,
328 | readComplexityReport: mockReadComplexityReport,
329 | findTaskInComplexityReport: mockFindTaskInComplexityReport,
330 | aggregateTelemetry: mockAggregateTelemetry,
331 | getCurrentTag: mockGetCurrentTag,
332 | resolveTag: mockResolveTag,
333 | getTasksForTag: mockGetTasksForTag
334 | }));
335 |
336 | // Import the module to test (AFTER mocks)
337 | const { generateTextService } = await import(
338 | '../../scripts/modules/ai-services-unified.js'
339 | );
340 |
341 | describe('Unified AI Services', () => {
342 | const fakeProjectRoot = '/fake/project/root'; // Define for reuse
343 |
344 | beforeEach(() => {
345 | // Clear mocks before each test
346 | jest.clearAllMocks(); // Clears all mocks
347 |
348 | // Set default mock behaviors
349 | mockGetMainProvider.mockReturnValue('anthropic');
350 | mockGetMainModelId.mockReturnValue('test-main-model');
351 | mockGetResearchProvider.mockReturnValue('perplexity');
352 | mockGetResearchModelId.mockReturnValue('test-research-model');
353 | mockGetFallbackProvider.mockReturnValue('anthropic');
354 | mockGetFallbackModelId.mockReturnValue('test-fallback-model');
355 | mockGetParametersForRole.mockImplementation((role) => {
356 | if (role === 'main') return { maxTokens: 100, temperature: 0.5 };
357 | if (role === 'research') return { maxTokens: 200, temperature: 0.3 };
358 | if (role === 'fallback') return { maxTokens: 150, temperature: 0.6 };
359 | return { maxTokens: 100, temperature: 0.5 }; // Default
360 | });
361 | mockGetResponseLanguage.mockReturnValue('English');
362 | mockResolveEnvVariable.mockImplementation((key) => {
363 | if (key === 'ANTHROPIC_API_KEY') return 'mock-anthropic-key';
364 | if (key === 'PERPLEXITY_API_KEY') return 'mock-perplexity-key';
365 | if (key === 'OPENAI_API_KEY') return 'mock-openai-key';
366 | if (key === 'OLLAMA_API_KEY') return 'mock-ollama-key';
367 | return null;
368 | });
369 |
370 | // Set a default behavior for the new mock
371 | mockFindProjectRoot.mockReturnValue(fakeProjectRoot);
372 | mockGetDebugFlag.mockReturnValue(false);
373 | mockGetUserId.mockReturnValue('test-user-id'); // Add default mock for getUserId
374 | mockIsApiKeySet.mockReturnValue(true); // Default to true for most tests
375 | mockGetBaseUrlForRole.mockReturnValue(null); // Default to no base URL
376 | });
377 |
378 | describe('generateTextService', () => {
379 | test('should use main provider/model and succeed', async () => {
380 | mockAnthropicProvider.generateText.mockResolvedValue({
381 | text: 'Main provider response',
382 | usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }
383 | });
384 |
385 | const params = {
386 | role: 'main',
387 | session: { env: {} },
388 | systemPrompt: 'System',
389 | prompt: 'Test'
390 | };
391 | const result = await generateTextService(params);
392 |
393 | expect(result.mainResult).toBe('Main provider response');
394 | expect(result).toHaveProperty('telemetryData');
395 | expect(mockGetMainProvider).toHaveBeenCalledWith(fakeProjectRoot);
396 | expect(mockGetMainModelId).toHaveBeenCalledWith(fakeProjectRoot);
397 | expect(mockGetParametersForRole).toHaveBeenCalledWith(
398 | 'main',
399 | fakeProjectRoot
400 | );
401 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(1);
402 | expect(mockPerplexityProvider.generateText).not.toHaveBeenCalled();
403 | });
404 |
405 | test('should fall back to fallback provider if main fails', async () => {
406 | const mainError = new Error('Main provider failed');
407 | mockAnthropicProvider.generateText
408 | .mockRejectedValueOnce(mainError)
409 | .mockResolvedValueOnce({
410 | text: 'Fallback provider response',
411 | usage: { inputTokens: 15, outputTokens: 25, totalTokens: 40 }
412 | });
413 |
414 | const explicitRoot = '/explicit/test/root';
415 | const params = {
416 | role: 'main',
417 | prompt: 'Fallback test',
418 | projectRoot: explicitRoot
419 | };
420 | const result = await generateTextService(params);
421 |
422 | expect(result.mainResult).toBe('Fallback provider response');
423 | expect(result).toHaveProperty('telemetryData');
424 | expect(mockGetMainProvider).toHaveBeenCalledWith(explicitRoot);
425 | expect(mockGetFallbackProvider).toHaveBeenCalledWith(explicitRoot);
426 | expect(mockGetParametersForRole).toHaveBeenCalledWith(
427 | 'main',
428 | explicitRoot
429 | );
430 | expect(mockGetParametersForRole).toHaveBeenCalledWith(
431 | 'fallback',
432 | explicitRoot
433 | );
434 |
435 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(2);
436 | expect(mockPerplexityProvider.generateText).not.toHaveBeenCalled();
437 | expect(mockLog).toHaveBeenCalledWith(
438 | 'error',
439 | expect.stringContaining('Service call failed for role main')
440 | );
441 | expect(mockLog).toHaveBeenCalledWith(
442 | 'debug',
443 | expect.stringContaining('New AI service call with role: fallback')
444 | );
445 | });
446 |
447 | test('should fall back to research provider if main and fallback fail', async () => {
448 | const mainError = new Error('Main failed');
449 | const fallbackError = new Error('Fallback failed');
450 | mockAnthropicProvider.generateText
451 | .mockRejectedValueOnce(mainError)
452 | .mockRejectedValueOnce(fallbackError);
453 | mockPerplexityProvider.generateText.mockResolvedValue({
454 | text: 'Research provider response',
455 | usage: { inputTokens: 20, outputTokens: 30, totalTokens: 50 }
456 | });
457 |
458 | const params = { role: 'main', prompt: 'Research fallback test' };
459 | const result = await generateTextService(params);
460 |
461 | expect(result.mainResult).toBe('Research provider response');
462 | expect(result).toHaveProperty('telemetryData');
463 | expect(mockGetMainProvider).toHaveBeenCalledWith(fakeProjectRoot);
464 | expect(mockGetFallbackProvider).toHaveBeenCalledWith(fakeProjectRoot);
465 | expect(mockGetResearchProvider).toHaveBeenCalledWith(fakeProjectRoot);
466 | expect(mockGetParametersForRole).toHaveBeenCalledWith(
467 | 'main',
468 | fakeProjectRoot
469 | );
470 | expect(mockGetParametersForRole).toHaveBeenCalledWith(
471 | 'fallback',
472 | fakeProjectRoot
473 | );
474 | expect(mockGetParametersForRole).toHaveBeenCalledWith(
475 | 'research',
476 | fakeProjectRoot
477 | );
478 |
479 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(2);
480 | expect(mockPerplexityProvider.generateText).toHaveBeenCalledTimes(1);
481 | expect(mockLog).toHaveBeenCalledWith(
482 | 'error',
483 | expect.stringContaining('Service call failed for role fallback')
484 | );
485 | expect(mockLog).toHaveBeenCalledWith(
486 | 'debug',
487 | expect.stringContaining('New AI service call with role: research')
488 | );
489 | });
490 |
491 | test('should throw error if all providers in sequence fail', async () => {
492 | mockAnthropicProvider.generateText.mockRejectedValue(
493 | new Error('Anthropic failed')
494 | );
495 | mockPerplexityProvider.generateText.mockRejectedValue(
496 | new Error('Perplexity failed')
497 | );
498 |
499 | const params = { role: 'main', prompt: 'All fail test' };
500 |
501 | await expect(generateTextService(params)).rejects.toThrow(
502 | 'Perplexity failed' // Error from the last attempt (research)
503 | );
504 |
505 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(2); // main, fallback
506 | expect(mockPerplexityProvider.generateText).toHaveBeenCalledTimes(1); // research
507 | });
508 |
509 | test('should handle retryable errors correctly', async () => {
510 | const retryableError = new Error('Rate limit');
511 | mockAnthropicProvider.generateText
512 | .mockRejectedValueOnce(retryableError) // Fails once
513 | .mockResolvedValueOnce({
514 | // Succeeds on retry
515 | text: 'Success after retry',
516 | usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 }
517 | });
518 |
519 | const params = { role: 'main', prompt: 'Retry success test' };
520 | const result = await generateTextService(params);
521 |
522 | expect(result.mainResult).toBe('Success after retry');
523 | expect(result).toHaveProperty('telemetryData');
524 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(2); // Initial + 1 retry
525 | expect(mockLog).toHaveBeenCalledWith(
526 | 'info',
527 | expect.stringContaining(
528 | 'Something went wrong on the provider side. Retrying'
529 | )
530 | );
531 | });
532 |
533 | test('should use default project root or handle null if findProjectRoot returns null', async () => {
534 | mockFindProjectRoot.mockReturnValue(null); // Simulate not finding root
535 | mockAnthropicProvider.generateText.mockResolvedValue({
536 | text: 'Response with no root',
537 | usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }
538 | });
539 |
540 | const params = { role: 'main', prompt: 'No root test' }; // No explicit root passed
541 | await generateTextService(params);
542 |
543 | expect(mockGetMainProvider).toHaveBeenCalledWith(null);
544 | expect(mockGetParametersForRole).toHaveBeenCalledWith('main', null);
545 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(1);
546 | });
547 |
548 | test('should use configured responseLanguage in system prompt', async () => {
549 | mockGetResponseLanguage.mockReturnValue('中文');
550 | mockAnthropicProvider.generateText.mockResolvedValue('中文回复');
551 |
552 | const params = {
553 | role: 'main',
554 | systemPrompt: 'You are an assistant',
555 | prompt: 'Hello'
556 | };
557 | await generateTextService(params);
558 |
559 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledWith(
560 | expect.objectContaining({
561 | messages: [
562 | {
563 | role: 'system',
564 | content: expect.stringContaining('Always respond in 中文')
565 | },
566 | { role: 'user', content: 'Hello' }
567 | ]
568 | })
569 | );
570 | expect(mockGetResponseLanguage).toHaveBeenCalledWith(fakeProjectRoot);
571 | });
572 |
573 | test('should pass custom projectRoot to getResponseLanguage', async () => {
574 | const customRoot = '/custom/project/root';
575 | mockGetResponseLanguage.mockReturnValue('Español');
576 | mockAnthropicProvider.generateText.mockResolvedValue(
577 | 'Respuesta en Español'
578 | );
579 |
580 | const params = {
581 | role: 'main',
582 | systemPrompt: 'You are an assistant',
583 | prompt: 'Hello',
584 | projectRoot: customRoot
585 | };
586 | await generateTextService(params);
587 |
588 | expect(mockGetResponseLanguage).toHaveBeenCalledWith(customRoot);
589 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledWith(
590 | expect.objectContaining({
591 | messages: [
592 | {
593 | role: 'system',
594 | content: expect.stringContaining('Always respond in Español')
595 | },
596 | { role: 'user', content: 'Hello' }
597 | ]
598 | })
599 | );
600 | });
601 |
602 | // Add more tests for edge cases:
603 | // - Missing API keys (should throw from _resolveApiKey)
604 | // - Unsupported provider configured (should skip and log)
605 | // - Missing provider/model config for a role (should skip and log)
606 | // - Missing prompt
607 | // - Different initial roles (research, fallback)
608 | // - generateObjectService (mock schema, check object result)
609 | // - streamTextService (more complex to test, might need stream helpers)
610 | test('should skip provider with missing API key and try next in fallback sequence', async () => {
611 | // Mock anthropic to throw API key error
612 | mockAnthropicProvider.generateText.mockRejectedValue(
613 | new Error(
614 | "Required API key ANTHROPIC_API_KEY for provider 'anthropic' is not set in environment, session, or .env file."
615 | )
616 | );
617 |
618 | // Mock perplexity text response (since we'll skip anthropic)
619 | mockPerplexityProvider.generateText.mockResolvedValue({
620 | text: 'Perplexity response (skipped to research)',
621 | usage: { inputTokens: 20, outputTokens: 30, totalTokens: 50 }
622 | });
623 |
624 | const params = {
625 | role: 'main',
626 | prompt: 'Skip main provider test',
627 | session: { env: {} }
628 | };
629 |
630 | const result = await generateTextService(params);
631 |
632 | // Should have gotten the perplexity response
633 | expect(result.mainResult).toBe(
634 | 'Perplexity response (skipped to research)'
635 | );
636 |
637 | // Should log an error for the failed provider
638 | expect(mockLog).toHaveBeenCalledWith(
639 | 'error',
640 | expect.stringContaining(`Service call failed for role main`)
641 | );
642 |
643 | // Should attempt to call anthropic provider first
644 | expect(mockAnthropicProvider.generateText).toHaveBeenCalled();
645 |
646 | // Should call perplexity provider after anthropic fails
647 | expect(mockPerplexityProvider.generateText).toHaveBeenCalledTimes(1);
648 | });
649 |
650 | test('should skip multiple providers with missing API keys and use first available', async () => {
651 | // Define different providers for testing multiple skips
652 | mockGetFallbackProvider.mockReturnValue('openai'); // Different from main
653 | mockGetFallbackModelId.mockReturnValue('test-openai-model');
654 |
655 | // Mock providers to throw API key errors (simulating _resolveApiKey behavior)
656 | mockAnthropicProvider.generateText.mockRejectedValue(
657 | new Error(
658 | "Required API key ANTHROPIC_API_KEY for provider 'anthropic' is not set in environment, session, or .env file."
659 | )
660 | );
661 | mockOpenAIProvider.generateText.mockRejectedValue(
662 | new Error(
663 | "Required API key OPENAI_API_KEY for provider 'openai' is not set in environment, session, or .env file."
664 | )
665 | );
666 |
667 | // Mock perplexity text response (since we'll skip to research)
668 | mockPerplexityProvider.generateText.mockResolvedValue({
669 | text: 'Research response after skipping main and fallback',
670 | usage: { inputTokens: 20, outputTokens: 30, totalTokens: 50 }
671 | });
672 |
673 | const params = {
674 | role: 'main',
675 | prompt: 'Skip multiple providers test',
676 | session: { env: {} }
677 | };
678 |
679 | const result = await generateTextService(params);
680 |
681 | // Should have gotten the perplexity (research) response
682 | expect(result.mainResult).toBe(
683 | 'Research response after skipping main and fallback'
684 | );
685 |
686 | // Should log errors for both skipped providers
687 | expect(mockLog).toHaveBeenCalledWith(
688 | 'error',
689 | expect.stringContaining(`Service call failed for role main`)
690 | );
691 | expect(mockLog).toHaveBeenCalledWith(
692 | 'error',
693 | expect.stringContaining(`Service call failed for role fallback`)
694 | );
695 |
696 | // Should call all providers in sequence until one succeeds
697 | expect(mockAnthropicProvider.generateText).toHaveBeenCalled();
698 | expect(mockOpenAIProvider.generateText).toHaveBeenCalled();
699 |
700 | // Should call perplexity provider which succeeds
701 | expect(mockPerplexityProvider.generateText).toHaveBeenCalledTimes(1);
702 | });
703 |
704 | test('should throw error if all providers in sequence have missing API keys', async () => {
705 | // Mock all providers to throw API key errors
706 | mockAnthropicProvider.generateText.mockRejectedValue(
707 | new Error(
708 | "Required API key ANTHROPIC_API_KEY for provider 'anthropic' is not set in environment, session, or .env file."
709 | )
710 | );
711 | mockPerplexityProvider.generateText.mockRejectedValue(
712 | new Error(
713 | "Required API key PERPLEXITY_API_KEY for provider 'perplexity' is not set in environment, session, or .env file."
714 | )
715 | );
716 |
717 | const params = {
718 | role: 'main',
719 | prompt: 'All API keys missing test',
720 | session: { env: {} }
721 | };
722 |
723 | // Should throw error since all providers would fail
724 | await expect(generateTextService(params)).rejects.toThrow(
725 | "Required API key PERPLEXITY_API_KEY for provider 'perplexity' is not set"
726 | );
727 |
728 | // Should log errors for all failed providers
729 | expect(mockLog).toHaveBeenCalledWith(
730 | 'error',
731 | expect.stringContaining(`Service call failed for role main`)
732 | );
733 | expect(mockLog).toHaveBeenCalledWith(
734 | 'error',
735 | expect.stringContaining(`Service call failed for role fallback`)
736 | );
737 | expect(mockLog).toHaveBeenCalledWith(
738 | 'error',
739 | expect.stringContaining(`Service call failed for role research`)
740 | );
741 |
742 | // Should log final error
743 | expect(mockLog).toHaveBeenCalledWith(
744 | 'error',
745 | expect.stringContaining(
746 | 'All roles in the sequence [main, fallback, research] failed.'
747 | )
748 | );
749 |
750 | // Should attempt to call all providers in sequence
751 | expect(mockAnthropicProvider.generateText).toHaveBeenCalled();
752 | expect(mockPerplexityProvider.generateText).toHaveBeenCalled();
753 | });
754 |
755 | test('should not check API key for Ollama provider and try to use it', async () => {
756 | // Setup: Set main provider to ollama
757 | mockGetMainProvider.mockReturnValue('ollama');
758 | mockGetMainModelId.mockReturnValue('llama3');
759 |
760 | // Mock Ollama text generation to succeed
761 | mockOllamaProvider.generateText.mockResolvedValue({
762 | text: 'Ollama response (no API key required)',
763 | usage: { inputTokens: 10, outputTokens: 10, totalTokens: 20 }
764 | });
765 |
766 | const params = {
767 | role: 'main',
768 | prompt: 'Ollama special case test',
769 | session: { env: {} }
770 | };
771 |
772 | const result = await generateTextService(params);
773 |
774 | // Should have gotten the Ollama response
775 | expect(result.mainResult).toBe('Ollama response (no API key required)');
776 |
777 | // isApiKeySet shouldn't be called for Ollama
778 | // Note: This is indirect - the code just doesn't check isApiKeySet for ollama
779 | // so we're verifying ollama provider was called despite isApiKeySet being mocked to false
780 | mockIsApiKeySet.mockReturnValue(false); // Should be ignored for Ollama
781 |
782 | // Should call Ollama provider
783 | expect(mockOllamaProvider.generateText).toHaveBeenCalledTimes(1);
784 | });
785 |
786 | test('should correctly use the provided session for API key resolution', async () => {
787 | // Mock custom session object with env vars
788 | const customSession = { env: { ANTHROPIC_API_KEY: 'session-api-key' } };
789 |
790 | // Mock the anthropic response - if API key resolution works, this will be called
791 | mockAnthropicProvider.generateText.mockResolvedValue({
792 | text: 'Anthropic response with session key',
793 | usage: { inputTokens: 10, outputTokens: 10, totalTokens: 20 }
794 | });
795 |
796 | const params = {
797 | role: 'main',
798 | prompt: 'Session API key test',
799 | session: customSession
800 | };
801 |
802 | const result = await generateTextService(params);
803 |
804 | // Should have successfully resolved API key from session and called provider
805 | expect(mockAnthropicProvider.generateText).toHaveBeenCalled();
806 |
807 | // Should have gotten the anthropic response
808 | expect(result.mainResult).toBe('Anthropic response with session key');
809 | });
810 |
811 | // --- Codex CLI specific tests ---
812 | test('should use codex-cli provider without API key (OAuth)', async () => {
813 | // Arrange codex-cli as main provider
814 | mockGetMainProvider.mockReturnValue('codex-cli');
815 | mockGetMainModelId.mockReturnValue('gpt-5-codex');
816 | mockGetParametersForRole.mockReturnValue({
817 | maxTokens: 128000,
818 | temperature: 1
819 | });
820 | mockGetResponseLanguage.mockReturnValue('English');
821 | // No API key in env
822 | mockResolveEnvVariable.mockReturnValue(null);
823 | // Mock codex generateText response
824 | mockCodexProvider.generateText.mockResolvedValueOnce({
825 | text: 'ok',
826 | usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }
827 | });
828 |
829 | const { generateTextService } = await import(
830 | '../../scripts/modules/ai-services-unified.js'
831 | );
832 |
833 | const result = await generateTextService({
834 | role: 'main',
835 | prompt: 'Hello Codex',
836 | projectRoot: fakeProjectRoot
837 | });
838 |
839 | expect(result.mainResult).toBe('ok');
840 | expect(mockCodexProvider.generateText).toHaveBeenCalledWith(
841 | expect.objectContaining({
842 | modelId: 'gpt-5-codex',
843 | apiKey: null,
844 | maxTokens: 128000
845 | })
846 | );
847 | });
848 |
849 | test('should pass apiKey to codex-cli when provided', async () => {
850 | // Arrange codex-cli as main provider
851 | mockGetMainProvider.mockReturnValue('codex-cli');
852 | mockGetMainModelId.mockReturnValue('gpt-5-codex');
853 | mockGetParametersForRole.mockReturnValue({
854 | maxTokens: 128000,
855 | temperature: 1
856 | });
857 | mockGetResponseLanguage.mockReturnValue('English');
858 | // Provide API key via env resolver
859 | mockResolveEnvVariable.mockReturnValue('sk-test');
860 | // Mock codex generateText response
861 | mockCodexProvider.generateText.mockResolvedValueOnce({
862 | text: 'ok-with-key',
863 | usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }
864 | });
865 |
866 | const { generateTextService } = await import(
867 | '../../scripts/modules/ai-services-unified.js'
868 | );
869 |
870 | const result = await generateTextService({
871 | role: 'main',
872 | prompt: 'Hello Codex',
873 | projectRoot: fakeProjectRoot
874 | });
875 |
876 | expect(result.mainResult).toBe('ok-with-key');
877 | expect(mockCodexProvider.generateText).toHaveBeenCalledWith(
878 | expect.objectContaining({
879 | modelId: 'gpt-5-codex',
880 | apiKey: 'sk-test'
881 | })
882 | );
883 | });
884 |
885 | // --- Claude Code specific test ---
886 | test('should pass temperature to claude-code provider (provider handles filtering)', async () => {
887 | mockGetMainProvider.mockReturnValue('claude-code');
888 | mockGetMainModelId.mockReturnValue('sonnet');
889 | mockGetParametersForRole.mockReturnValue({
890 | maxTokens: 64000,
891 | temperature: 0.7
892 | });
893 | mockGetResponseLanguage.mockReturnValue('English');
894 | mockResolveEnvVariable.mockReturnValue(null);
895 |
896 | mockClaudeProvider.generateText.mockResolvedValueOnce({
897 | text: 'ok-claude',
898 | usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }
899 | });
900 |
901 | const { generateTextService } = await import(
902 | '../../scripts/modules/ai-services-unified.js'
903 | );
904 |
905 | const result = await generateTextService({
906 | role: 'main',
907 | prompt: 'Hello Claude',
908 | projectRoot: fakeProjectRoot
909 | });
910 |
911 | expect(result.mainResult).toBe('ok-claude');
912 | // The provider (BaseAIProvider) is responsible for filtering it based on supportsTemperature
913 | const callArgs = mockClaudeProvider.generateText.mock.calls[0][0];
914 | expect(callArgs).toHaveProperty('temperature', 0.7);
915 | expect(callArgs.maxTokens).toBe(64000);
916 | });
917 | });
918 | });
919 |
```
--------------------------------------------------------------------------------
/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * Tests for complexity report tag isolation functionality
3 | * Verifies that different tags maintain separate complexity reports
4 | */
5 |
6 | import { jest } from '@jest/globals';
7 | import fs from 'fs';
8 | import path from 'path';
9 |
10 | // Mock fs module - consolidated single registration
11 | const mockExistsSync = jest.fn();
12 | const mockReadFileSync = jest.fn();
13 | const mockWriteFileSync = jest.fn();
14 | const mockUnlinkSync = jest.fn();
15 | const mockMkdirSync = jest.fn();
16 | const mockReaddirSync = jest.fn(() => []);
17 | const mockStatSync = jest.fn(() => ({ isDirectory: () => false }));
18 |
19 | jest.unstable_mockModule('fs', () => ({
20 | default: {
21 | existsSync: mockExistsSync,
22 | readFileSync: mockReadFileSync,
23 | writeFileSync: mockWriteFileSync,
24 | unlinkSync: mockUnlinkSync,
25 | mkdirSync: mockMkdirSync,
26 | readdirSync: mockReaddirSync,
27 | statSync: mockStatSync
28 | },
29 | existsSync: mockExistsSync,
30 | readFileSync: mockReadFileSync,
31 | writeFileSync: mockWriteFileSync,
32 | unlinkSync: mockUnlinkSync,
33 | mkdirSync: mockMkdirSync,
34 | readdirSync: mockReaddirSync,
35 | statSync: mockStatSync
36 | }));
37 |
38 | // Mock the dependencies
39 | jest.unstable_mockModule('../../../../../src/utils/path-utils.js', () => ({
40 | resolveComplexityReportOutputPath: jest.fn(),
41 | findComplexityReportPath: jest.fn(),
42 | findConfigPath: jest.fn(),
43 | findPRDPath: jest.fn(() => '/mock/project/root/.taskmaster/docs/PRD.md'),
44 | findTasksPath: jest.fn(
45 | () => '/mock/project/root/.taskmaster/tasks/tasks.json'
46 | ),
47 | findProjectRoot: jest.fn(() => '/mock/project/root'),
48 | normalizeProjectRoot: jest.fn((root) => root)
49 | }));
50 |
51 | jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
52 | readJSON: jest.fn(),
53 | writeJSON: jest.fn(),
54 | log: jest.fn(),
55 | isSilentMode: jest.fn(() => false),
56 | enableSilentMode: jest.fn(),
57 | disableSilentMode: jest.fn(),
58 | flattenTasksWithSubtasks: jest.fn((tasks) => tasks),
59 | getTagAwareFilePath: jest.fn((basePath, tag, projectRoot) => {
60 | if (tag && tag !== 'master') {
61 | const dir = path.dirname(basePath);
62 | const ext = path.extname(basePath);
63 | const name = path.basename(basePath, ext);
64 | return path.join(projectRoot || '.', dir, `${name}_${tag}${ext}`);
65 | }
66 | return path.join(projectRoot || '.', basePath);
67 | }),
68 | findTaskById: jest.fn((tasks, taskId) => {
69 | if (!tasks || !Array.isArray(tasks)) {
70 | return { task: null, originalSubtaskCount: null, originalSubtasks: null };
71 | }
72 | const id = parseInt(taskId, 10);
73 | const task = tasks.find((t) => t.id === id);
74 | return task
75 | ? { task, originalSubtaskCount: null, originalSubtasks: null }
76 | : { task: null, originalSubtaskCount: null, originalSubtasks: null };
77 | }),
78 | taskExists: jest.fn((tasks, taskId) => {
79 | if (!tasks || !Array.isArray(tasks)) return false;
80 | const id = parseInt(taskId, 10);
81 | return tasks.some((t) => t.id === id);
82 | }),
83 | formatTaskId: jest.fn((id) => `Task ${id}`),
84 | findCycles: jest.fn(() => []),
85 | truncate: jest.fn((text) => text),
86 | addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })),
87 | aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {}),
88 | ensureTagMetadata: jest.fn((tagObj) => tagObj),
89 | getCurrentTag: jest.fn(() => 'master'),
90 | resolveTag: jest.fn(() => 'master'),
91 | markMigrationForNotice: jest.fn(),
92 | performCompleteTagMigration: jest.fn(),
93 | setTasksForTag: jest.fn(),
94 | getTasksForTag: jest.fn((data, tag) => data[tag]?.tasks || []),
95 | findProjectRoot: jest.fn(() => '/mock/project/root'),
96 | readComplexityReport: jest.fn(),
97 | findTaskInComplexityReport: jest.fn(),
98 | resolveEnvVariable: jest.fn((varName) => `mock_${varName}`),
99 | isEmpty: jest.fn(() => false),
100 | normalizeProjectRoot: jest.fn((root) => root),
101 | slugifyTagForFilePath: jest.fn((tagName) => {
102 | if (!tagName || typeof tagName !== 'string') {
103 | return 'unknown-tag';
104 | }
105 | return tagName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
106 | }),
107 | createTagAwareFilePath: jest.fn((basePath, tag, projectRoot) => {
108 | if (tag && tag !== 'master') {
109 | const dir = path.dirname(basePath);
110 | const ext = path.extname(basePath);
111 | const name = path.basename(basePath, ext);
112 | // Use the slugified tag
113 | const slugifiedTag = tag.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
114 | return path.join(
115 | projectRoot || '.',
116 | dir,
117 | `${name}_${slugifiedTag}${ext}`
118 | );
119 | }
120 | return path.join(projectRoot || '.', basePath);
121 | }),
122 | traverseDependencies: jest.fn((sourceTasks, allTasks, options = {}) => []),
123 | CONFIG: {
124 | defaultSubtasks: 3
125 | }
126 | }));
127 |
128 | jest.unstable_mockModule(
129 | '../../../../../scripts/modules/ai-services-unified.js',
130 | () => ({
131 | generateTextService: jest.fn().mockImplementation((params) => {
132 | const commandName = params?.commandName || 'default';
133 |
134 | if (commandName === 'analyze-complexity') {
135 | // Check if this is for a specific tag test by looking at the prompt
136 | const isFeatureTag =
137 | params?.prompt?.includes('feature') || params?.role === 'feature';
138 | const isMasterTag =
139 | params?.prompt?.includes('master') || params?.role === 'master';
140 |
141 | let taskTitle = 'Test Task';
142 | if (isFeatureTag) {
143 | taskTitle = 'Feature Task 1';
144 | } else if (isMasterTag) {
145 | taskTitle = 'Master Task 1';
146 | }
147 |
148 | return Promise.resolve({
149 | mainResult: JSON.stringify([
150 | {
151 | taskId: 1,
152 | taskTitle: taskTitle,
153 | complexityScore: 7,
154 | recommendedSubtasks: 4,
155 | expansionPrompt: 'Break down this task',
156 | reasoning: 'This task is moderately complex'
157 | },
158 | {
159 | taskId: 2,
160 | taskTitle: 'Task 2',
161 | complexityScore: 5,
162 | recommendedSubtasks: 3,
163 | expansionPrompt: 'Break down this task with a focus on task 2.',
164 | reasoning:
165 | 'Automatically added due to missing analysis in AI response.'
166 | }
167 | ]),
168 | telemetryData: {
169 | timestamp: new Date().toISOString(),
170 | commandName: 'analyze-complexity',
171 | modelUsed: 'claude-3-5-sonnet',
172 | providerName: 'anthropic',
173 | inputTokens: 1000,
174 | outputTokens: 500,
175 | totalTokens: 1500,
176 | totalCost: 0.012414,
177 | currency: 'USD'
178 | }
179 | });
180 | } else {
181 | // Default for expand-task and others
182 | return Promise.resolve({
183 | mainResult: JSON.stringify({
184 | subtasks: [
185 | {
186 | id: 1,
187 | title: 'Subtask 1',
188 | description: 'First subtask',
189 | dependencies: [],
190 | details: 'Implementation details',
191 | status: 'pending',
192 | testStrategy: 'Test strategy'
193 | }
194 | ]
195 | }),
196 | telemetryData: {
197 | timestamp: new Date().toISOString(),
198 | commandName: commandName || 'expand-task',
199 | modelUsed: 'claude-3-5-sonnet',
200 | providerName: 'anthropic',
201 | inputTokens: 1000,
202 | outputTokens: 500,
203 | totalTokens: 1500,
204 | totalCost: 0.012414,
205 | currency: 'USD'
206 | }
207 | });
208 | }
209 | }),
210 | streamTextService: jest.fn().mockResolvedValue({
211 | mainResult: async function* () {
212 | yield '{"tasks":[';
213 | yield '{"id":1,"title":"Test Task","priority":"high"}';
214 | yield ']}';
215 | },
216 | telemetryData: {
217 | timestamp: new Date().toISOString(),
218 | commandName: 'analyze-complexity',
219 | modelUsed: 'claude-3-5-sonnet',
220 | providerName: 'anthropic',
221 | inputTokens: 1000,
222 | outputTokens: 500,
223 | totalTokens: 1500,
224 | totalCost: 0.012414,
225 | currency: 'USD'
226 | }
227 | }),
228 | generateObjectService: jest.fn().mockImplementation((params) => {
229 | const commandName = params?.commandName || 'default';
230 |
231 | if (commandName === 'analyze-complexity') {
232 | // Check if this is for a specific tag test by looking at the prompt
233 | const isFeatureTag =
234 | params?.prompt?.includes('feature') || params?.role === 'feature';
235 | const isMasterTag =
236 | params?.prompt?.includes('master') || params?.role === 'master';
237 |
238 | let taskTitle = 'Test Task';
239 | if (isFeatureTag) {
240 | taskTitle = 'Feature Task 1';
241 | } else if (isMasterTag) {
242 | taskTitle = 'Master Task 1';
243 | }
244 |
245 | return Promise.resolve({
246 | mainResult: {
247 | complexityAnalysis: [
248 | {
249 | taskId: 1,
250 | taskTitle: taskTitle,
251 | complexityScore: 7,
252 | recommendedSubtasks: 4,
253 | expansionPrompt: 'Break down this task',
254 | reasoning: 'This task is moderately complex'
255 | },
256 | {
257 | taskId: 2,
258 | taskTitle: 'Task 2',
259 | complexityScore: 5,
260 | recommendedSubtasks: 3,
261 | expansionPrompt: 'Break down this task with a focus on task 2.',
262 | reasoning:
263 | 'Automatically added due to missing analysis in AI response.'
264 | }
265 | ]
266 | },
267 | telemetryData: {
268 | timestamp: new Date().toISOString(),
269 | commandName: 'analyze-complexity',
270 | modelUsed: 'claude-3-5-sonnet',
271 | providerName: 'anthropic',
272 | inputTokens: 1000,
273 | outputTokens: 500,
274 | totalTokens: 1500,
275 | totalCost: 0.012414,
276 | currency: 'USD'
277 | }
278 | });
279 | }
280 |
281 | // Default response for expand-task and others
282 | return Promise.resolve({
283 | mainResult: {
284 | subtasks: [
285 | {
286 | id: 1,
287 | title: 'Subtask 1',
288 | description: 'First subtask',
289 | dependencies: [],
290 | details: 'Implementation details',
291 | status: 'pending',
292 | testStrategy: 'Test strategy'
293 | }
294 | ]
295 | },
296 | telemetryData: {
297 | timestamp: new Date().toISOString(),
298 | commandName: 'expand-task',
299 | modelUsed: 'claude-3-5-sonnet',
300 | providerName: 'anthropic',
301 | inputTokens: 1000,
302 | outputTokens: 500,
303 | totalTokens: 1500,
304 | totalCost: 0.012414,
305 | currency: 'USD'
306 | }
307 | });
308 | })
309 | })
310 | );
311 |
312 | jest.unstable_mockModule(
313 | '../../../../../scripts/modules/config-manager.js',
314 | () => ({
315 | // Core config access
316 | getConfig: jest.fn(() => ({
317 | models: { main: { provider: 'anthropic', modelId: 'claude-3-5-sonnet' } },
318 | global: { projectName: 'Test Project' }
319 | })),
320 | writeConfig: jest.fn(() => true),
321 | ConfigurationError: class extends Error {},
322 | isConfigFilePresent: jest.fn(() => true),
323 |
324 | // Validation
325 | validateProvider: jest.fn(() => true),
326 | validateProviderModelCombination: jest.fn(() => true),
327 | VALIDATED_PROVIDERS: ['anthropic', 'openai', 'perplexity'],
328 | CUSTOM_PROVIDERS: { OLLAMA: 'ollama', BEDROCK: 'bedrock' },
329 | ALL_PROVIDERS: ['anthropic', 'openai', 'perplexity', 'ollama', 'bedrock'],
330 | MODEL_MAP: {
331 | anthropic: [
332 | {
333 | id: 'claude-3-5-sonnet',
334 | cost_per_1m_tokens: { input: 3, output: 15 }
335 | }
336 | ],
337 | openai: [{ id: 'gpt-4', cost_per_1m_tokens: { input: 30, output: 60 } }]
338 | },
339 | getAvailableModels: jest.fn(() => [
340 | {
341 | id: 'claude-3-5-sonnet',
342 | name: 'Claude 3.5 Sonnet',
343 | provider: 'anthropic'
344 | },
345 | { id: 'gpt-4', name: 'GPT-4', provider: 'openai' }
346 | ]),
347 |
348 | // Role-specific getters
349 | getMainProvider: jest.fn(() => 'anthropic'),
350 | getMainModelId: jest.fn(() => 'claude-3-5-sonnet'),
351 | getMainMaxTokens: jest.fn(() => 4000),
352 | getMainTemperature: jest.fn(() => 0.7),
353 | getResearchProvider: jest.fn(() => 'perplexity'),
354 | getResearchModelId: jest.fn(() => 'sonar-pro'),
355 | getResearchMaxTokens: jest.fn(() => 8700),
356 | getResearchTemperature: jest.fn(() => 0.1),
357 | getFallbackProvider: jest.fn(() => 'anthropic'),
358 | getFallbackModelId: jest.fn(() => 'claude-3-5-sonnet'),
359 | getFallbackMaxTokens: jest.fn(() => 4000),
360 | getFallbackTemperature: jest.fn(() => 0.7),
361 | getBaseUrlForRole: jest.fn(() => undefined),
362 |
363 | // Global setting getters
364 | getLogLevel: jest.fn(() => 'info'),
365 | getDebugFlag: jest.fn(() => false),
366 | getDefaultNumTasks: jest.fn(() => 10),
367 | getDefaultSubtasks: jest.fn(() => 5),
368 | getDefaultPriority: jest.fn(() => 'medium'),
369 | getProjectName: jest.fn(() => 'Test Project'),
370 | getOllamaBaseURL: jest.fn(() => 'http://localhost:11434/api'),
371 | getAzureBaseURL: jest.fn(() => undefined),
372 | getBedrockBaseURL: jest.fn(() => undefined),
373 | getParametersForRole: jest.fn(() => ({
374 | maxTokens: 4000,
375 | temperature: 0.7
376 | })),
377 | getUserId: jest.fn(() => '1234567890'),
378 |
379 | // API Key Checkers
380 | isApiKeySet: jest.fn(() => true),
381 | getMcpApiKeyStatus: jest.fn(() => true),
382 |
383 | // Additional functions
384 | getAllProviders: jest.fn(() => ['anthropic', 'openai', 'perplexity']),
385 | getVertexProjectId: jest.fn(() => undefined),
386 | getVertexLocation: jest.fn(() => undefined),
387 | hasCodebaseAnalysis: jest.fn(() => false)
388 | })
389 | );
390 |
391 | jest.unstable_mockModule(
392 | '../../../../../scripts/modules/prompt-manager.js',
393 | () => ({
394 | getPromptManager: jest.fn().mockReturnValue({
395 | loadPrompt: jest.fn().mockReturnValue({
396 | systemPrompt: 'Mocked system prompt',
397 | userPrompt: 'Mocked user prompt'
398 | })
399 | })
400 | })
401 | );
402 |
403 | jest.unstable_mockModule(
404 | '../../../../../scripts/modules/utils/contextGatherer.js',
405 | () => {
406 | class MockContextGatherer {
407 | constructor(projectRoot, tag) {
408 | this.projectRoot = projectRoot;
409 | this.tag = tag;
410 | this.allTasks = [];
411 | }
412 |
413 | async gather(options = {}) {
414 | return {
415 | context: 'Mock context gathered',
416 | analysisData: null,
417 | contextSections: 1,
418 | finalTaskIds: options.tasks || []
419 | };
420 | }
421 | }
422 |
423 | return {
424 | default: MockContextGatherer,
425 | ContextGatherer: MockContextGatherer,
426 | createContextGatherer: jest.fn(
427 | (projectRoot, tag) => new MockContextGatherer(projectRoot, tag)
428 | )
429 | };
430 | }
431 | );
432 |
433 | jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
434 | startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
435 | stopLoadingIndicator: jest.fn(),
436 | displayAiUsageSummary: jest.fn(),
437 | displayBanner: jest.fn(),
438 | getStatusWithColor: jest.fn((status) => status),
439 | succeedLoadingIndicator: jest.fn(),
440 | failLoadingIndicator: jest.fn(),
441 | warnLoadingIndicator: jest.fn(),
442 | infoLoadingIndicator: jest.fn(),
443 | displayContextAnalysis: jest.fn(),
444 | createProgressBar: jest.fn(() => ({
445 | start: jest.fn(),
446 | stop: jest.fn(),
447 | update: jest.fn()
448 | })),
449 | displayTable: jest.fn(),
450 | displayBox: jest.fn(),
451 | displaySuccess: jest.fn(),
452 | displayError: jest.fn(),
453 | displayWarning: jest.fn(),
454 | displayInfo: jest.fn(),
455 | displayTaskDetails: jest.fn(),
456 | displayTaskList: jest.fn(),
457 | displayComplexityReport: jest.fn(),
458 | displayNextTask: jest.fn(),
459 | displayDependencyStatus: jest.fn(),
460 | displayMigrationNotice: jest.fn(),
461 | formatDependenciesWithStatus: jest.fn((deps) => deps),
462 | formatTaskId: jest.fn((id) => `Task ${id}`),
463 | formatPriority: jest.fn((priority) => priority),
464 | formatDuration: jest.fn((duration) => duration),
465 | formatDate: jest.fn((date) => date),
466 | formatComplexityScore: jest.fn((score) => score),
467 | formatTelemetryData: jest.fn((data) => data),
468 | formatContextSummary: jest.fn((context) => context),
469 | formatTagName: jest.fn((tag) => tag),
470 | formatFilePath: jest.fn((path) => path),
471 | getComplexityWithColor: jest.fn((complexity) => complexity),
472 | getPriorityWithColor: jest.fn((priority) => priority),
473 | getTagWithColor: jest.fn((tag) => tag),
474 | getDependencyWithColor: jest.fn((dep) => dep),
475 | getTelemetryWithColor: jest.fn((data) => data),
476 | getContextWithColor: jest.fn((context) => context)
477 | }));
478 |
479 | // fs module already mocked at top of file with shared spy references
480 |
481 | // Mock @tm/bridge module
482 | jest.unstable_mockModule('@tm/bridge', () => ({
483 | tryExpandViaRemote: jest.fn().mockResolvedValue(null)
484 | }));
485 |
486 | // Mock bridge-utils module
487 | jest.unstable_mockModule(
488 | '../../../../../scripts/modules/bridge-utils.js',
489 | () => ({
490 | createBridgeLogger: jest.fn(() => ({
491 | logger: {
492 | info: jest.fn(),
493 | warn: jest.fn(),
494 | error: jest.fn(),
495 | debug: jest.fn()
496 | },
497 | report: jest.fn(),
498 | isMCP: false
499 | }))
500 | })
501 | );
502 |
503 | // Import the mocked modules
504 | const { resolveComplexityReportOutputPath, findComplexityReportPath } =
505 | await import('../../../../../src/utils/path-utils.js');
506 |
507 | const { readJSON, writeJSON, getTagAwareFilePath } = await import(
508 | '../../../../../scripts/modules/utils.js'
509 | );
510 |
511 | const { generateTextService, generateObjectService, streamTextService } =
512 | await import('../../../../../scripts/modules/ai-services-unified.js');
513 |
514 | // Import the modules under test
515 | const { default: analyzeTaskComplexity } = await import(
516 | '../../../../../scripts/modules/task-manager/analyze-task-complexity.js'
517 | );
518 |
519 | const { default: expandTask } = await import(
520 | '../../../../../scripts/modules/task-manager/expand-task.js'
521 | );
522 |
523 | describe('Complexity Report Tag Isolation', () => {
524 | const projectRoot = '/mock/project/root';
525 | const sampleTasks = {
526 | tasks: [
527 | {
528 | id: 1,
529 | title: 'Task 1',
530 | description: 'First task',
531 | status: 'pending'
532 | },
533 | {
534 | id: 2,
535 | title: 'Task 2',
536 | description: 'Second task',
537 | status: 'pending'
538 | }
539 | ]
540 | };
541 |
542 | const sampleComplexityReport = {
543 | meta: {
544 | generatedAt: new Date().toISOString(),
545 | tasksAnalyzed: 2,
546 | totalTasks: 2,
547 | analysisCount: 2,
548 | thresholdScore: 5,
549 | projectName: 'Test Project',
550 | usedResearch: false
551 | },
552 | complexityAnalysis: [
553 | {
554 | taskId: 1,
555 | taskTitle: 'Task 1',
556 | complexityScore: 7,
557 | recommendedSubtasks: 4,
558 | expansionPrompt: 'Break down this task',
559 | reasoning: 'This task is moderately complex'
560 | },
561 | {
562 | taskId: 2,
563 | taskTitle: 'Task 2',
564 | complexityScore: 5,
565 | recommendedSubtasks: 3,
566 | expansionPrompt: 'Break down this task',
567 | reasoning: 'This task is moderately complex'
568 | }
569 | ]
570 | };
571 |
572 | beforeEach(() => {
573 | jest.clearAllMocks();
574 |
575 | // Default mock implementations
576 | readJSON.mockReturnValue(sampleTasks);
577 | mockExistsSync.mockReturnValue(false);
578 | mockMkdirSync.mockImplementation(() => {});
579 |
580 | // Mock resolveComplexityReportOutputPath to return tag-aware paths
581 | resolveComplexityReportOutputPath.mockImplementation(
582 | (explicitPath, args) => {
583 | const tag = args?.tag;
584 | if (explicitPath) {
585 | return explicitPath;
586 | }
587 |
588 | let filename = 'task-complexity-report.json';
589 | if (tag && tag !== 'master') {
590 | // Use slugified tag for cross-platform compatibility
591 | const slugifiedTag = tag
592 | .replace(/[^a-zA-Z0-9_-]/g, '-')
593 | .toLowerCase();
594 | filename = `task-complexity-report_${slugifiedTag}.json`;
595 | }
596 |
597 | return path.join(projectRoot, '.taskmaster/reports', filename);
598 | }
599 | );
600 |
601 | // Mock findComplexityReportPath to return tag-aware paths
602 | findComplexityReportPath.mockImplementation((explicitPath, args) => {
603 | const tag = args?.tag;
604 | if (explicitPath) {
605 | return explicitPath;
606 | }
607 |
608 | let filename = 'task-complexity-report.json';
609 | if (tag && tag !== 'master') {
610 | filename = `task-complexity-report_${tag}.json`;
611 | }
612 |
613 | return path.join(projectRoot, '.taskmaster/reports', filename);
614 | });
615 | });
616 |
617 | describe('Path Resolution Tag Isolation', () => {
618 | test('should resolve master tag to default filename', () => {
619 | const result = resolveComplexityReportOutputPath(null, {
620 | tag: 'master',
621 | projectRoot
622 | });
623 | expect(result).toBe(
624 | path.join(
625 | projectRoot,
626 | '.taskmaster/reports',
627 | 'task-complexity-report.json'
628 | )
629 | );
630 | });
631 |
632 | test('should resolve non-master tag to tag-specific filename', () => {
633 | const result = resolveComplexityReportOutputPath(null, {
634 | tag: 'feature-auth',
635 | projectRoot
636 | });
637 | expect(result).toBe(
638 | path.join(
639 | projectRoot,
640 | '.taskmaster/reports',
641 | 'task-complexity-report_feature-auth.json'
642 | )
643 | );
644 | });
645 |
646 | test('should resolve undefined tag to default filename', () => {
647 | const result = resolveComplexityReportOutputPath(null, { projectRoot });
648 | expect(result).toBe(
649 | path.join(
650 | projectRoot,
651 | '.taskmaster/reports',
652 | 'task-complexity-report.json'
653 | )
654 | );
655 | });
656 |
657 | test('should respect explicit path over tag-aware resolution', () => {
658 | const explicitPath = '/custom/path/report.json';
659 | const result = resolveComplexityReportOutputPath(explicitPath, {
660 | tag: 'feature-auth',
661 | projectRoot
662 | });
663 | expect(result).toBe(explicitPath);
664 | });
665 | });
666 |
667 | describe('Analysis Generation Tag Isolation', () => {
668 | test('should generate master tag report to default location', async () => {
669 | const options = {
670 | file: 'tasks/tasks.json',
671 | threshold: '5',
672 | projectRoot,
673 | tag: 'master'
674 | };
675 |
676 | await analyzeTaskComplexity(options, {
677 | projectRoot,
678 | mcpLog: {
679 | info: jest.fn(),
680 | warn: jest.fn(),
681 | error: jest.fn(),
682 | debug: jest.fn(),
683 | success: jest.fn()
684 | }
685 | });
686 |
687 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith(
688 | undefined,
689 | expect.objectContaining({
690 | tag: 'master',
691 | projectRoot
692 | }),
693 | expect.any(Function)
694 | );
695 |
696 | expect(mockWriteFileSync).toHaveBeenCalledWith(
697 | path.join(
698 | projectRoot,
699 | '.taskmaster/reports',
700 | 'task-complexity-report.json'
701 | ),
702 | expect.any(String),
703 | 'utf8'
704 | );
705 | });
706 |
707 | test('should generate feature tag report to tag-specific location', async () => {
708 | const options = {
709 | file: 'tasks/tasks.json',
710 | threshold: '5',
711 | projectRoot,
712 | tag: 'feature-auth'
713 | };
714 |
715 | await analyzeTaskComplexity(options, {
716 | projectRoot,
717 | mcpLog: {
718 | info: jest.fn(),
719 | warn: jest.fn(),
720 | error: jest.fn(),
721 | debug: jest.fn(),
722 | success: jest.fn()
723 | }
724 | });
725 |
726 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith(
727 | undefined,
728 | expect.objectContaining({
729 | tag: 'feature-auth',
730 | projectRoot
731 | }),
732 | expect.any(Function)
733 | );
734 |
735 | expect(mockWriteFileSync).toHaveBeenCalledWith(
736 | path.join(
737 | projectRoot,
738 | '.taskmaster/reports',
739 | 'task-complexity-report_feature-auth.json'
740 | ),
741 | expect.any(String),
742 | 'utf8'
743 | );
744 | });
745 |
746 | test('should not overwrite master report when analyzing feature tag', async () => {
747 | // First, analyze master tag
748 | const masterOptions = {
749 | file: 'tasks/tasks.json',
750 | threshold: '5',
751 | projectRoot,
752 | tag: 'master'
753 | };
754 |
755 | await analyzeTaskComplexity(masterOptions, {
756 | projectRoot,
757 | mcpLog: {
758 | info: jest.fn(),
759 | warn: jest.fn(),
760 | error: jest.fn(),
761 | debug: jest.fn(),
762 | success: jest.fn()
763 | }
764 | });
765 |
766 | // Clear mocks to verify separate calls
767 | jest.clearAllMocks();
768 | readJSON.mockReturnValue(sampleTasks);
769 |
770 | // Then, analyze feature tag
771 | const featureOptions = {
772 | file: 'tasks/tasks.json',
773 | threshold: '5',
774 | projectRoot,
775 | tag: 'feature-auth'
776 | };
777 |
778 | await analyzeTaskComplexity(featureOptions, {
779 | projectRoot,
780 | mcpLog: {
781 | info: jest.fn(),
782 | warn: jest.fn(),
783 | error: jest.fn(),
784 | debug: jest.fn(),
785 | success: jest.fn()
786 | }
787 | });
788 |
789 | // Verify that the feature tag analysis wrote to its own file
790 | expect(mockWriteFileSync).toHaveBeenCalledWith(
791 | path.join(
792 | projectRoot,
793 | '.taskmaster/reports',
794 | 'task-complexity-report_feature-auth.json'
795 | ),
796 | expect.any(String),
797 | 'utf8'
798 | );
799 |
800 | // Verify that it did NOT write to the master file
801 | expect(mockWriteFileSync).not.toHaveBeenCalledWith(
802 | path.join(
803 | projectRoot,
804 | '.taskmaster/reports',
805 | 'task-complexity-report.json'
806 | ),
807 | expect.any(String),
808 | 'utf8'
809 | );
810 | });
811 | });
812 |
813 | describe('Report Reading Tag Isolation', () => {
814 | test('should read master tag report from default location', async () => {
815 | // Mock existing master report
816 | mockExistsSync.mockImplementation((filepath) => {
817 | return filepath.endsWith('task-complexity-report.json');
818 | });
819 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport));
820 |
821 | const options = {
822 | file: 'tasks/tasks.json',
823 | threshold: '5',
824 | projectRoot,
825 | tag: 'master'
826 | };
827 |
828 | await analyzeTaskComplexity(options, {
829 | projectRoot,
830 | mcpLog: {
831 | info: jest.fn(),
832 | warn: jest.fn(),
833 | error: jest.fn(),
834 | debug: jest.fn(),
835 | success: jest.fn()
836 | }
837 | });
838 |
839 | expect(mockExistsSync).toHaveBeenCalledWith(
840 | path.join(
841 | projectRoot,
842 | '.taskmaster/reports',
843 | 'task-complexity-report.json'
844 | )
845 | );
846 | });
847 |
848 | test('should read feature tag report from tag-specific location', async () => {
849 | // Mock existing feature tag report
850 | mockExistsSync.mockImplementation((filepath) => {
851 | return filepath.endsWith('task-complexity-report_feature-auth.json');
852 | });
853 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport));
854 |
855 | const options = {
856 | file: 'tasks/tasks.json',
857 | threshold: '5',
858 | projectRoot,
859 | tag: 'feature-auth'
860 | };
861 |
862 | await analyzeTaskComplexity(options, {
863 | projectRoot,
864 | mcpLog: {
865 | info: jest.fn(),
866 | warn: jest.fn(),
867 | error: jest.fn(),
868 | debug: jest.fn(),
869 | success: jest.fn()
870 | }
871 | });
872 |
873 | expect(mockExistsSync).toHaveBeenCalledWith(
874 | path.join(
875 | projectRoot,
876 | '.taskmaster/reports',
877 | 'task-complexity-report_feature-auth.json'
878 | )
879 | );
880 | });
881 |
882 | test('should not read master report when working with feature tag', async () => {
883 | // Mock that feature tag report exists but master doesn't
884 | mockExistsSync.mockImplementation((filepath) => {
885 | return filepath.endsWith('task-complexity-report_feature-auth.json');
886 | });
887 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport));
888 |
889 | const options = {
890 | file: 'tasks/tasks.json',
891 | threshold: '5',
892 | projectRoot,
893 | tag: 'feature-auth'
894 | };
895 |
896 | await analyzeTaskComplexity(options, {
897 | projectRoot,
898 | mcpLog: {
899 | info: jest.fn(),
900 | warn: jest.fn(),
901 | error: jest.fn(),
902 | debug: jest.fn(),
903 | success: jest.fn()
904 | }
905 | });
906 |
907 | // Should check for feature tag report
908 | expect(mockExistsSync).toHaveBeenCalledWith(
909 | path.join(
910 | projectRoot,
911 | '.taskmaster/reports',
912 | 'task-complexity-report_feature-auth.json'
913 | )
914 | );
915 |
916 | // Should NOT check for master report
917 | expect(mockExistsSync).not.toHaveBeenCalledWith(
918 | path.join(
919 | projectRoot,
920 | '.taskmaster/reports',
921 | 'task-complexity-report.json'
922 | )
923 | );
924 | });
925 | });
926 |
927 | describe('Expand Task Tag Isolation', () => {
928 | test('should use tag-specific complexity report for expansion', async () => {
929 | // Mock existing feature tag report
930 | mockExistsSync.mockImplementation((filepath) => {
931 | return filepath.endsWith('task-complexity-report_feature-auth.json');
932 | });
933 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport));
934 |
935 | const tasksPath = path.join(projectRoot, 'tasks/tasks.json');
936 | const taskId = 1;
937 | const numSubtasks = 3;
938 |
939 | await expandTask(
940 | tasksPath,
941 | taskId,
942 | numSubtasks,
943 | false, // useResearch
944 | '', // additionalContext
945 | {
946 | projectRoot,
947 | tag: 'feature-auth',
948 | complexityReportPath: path.join(
949 | projectRoot,
950 | '.taskmaster/reports',
951 | 'task-complexity-report_feature-auth.json'
952 | ),
953 | mcpLog: {
954 | info: jest.fn(),
955 | warn: jest.fn(),
956 | error: jest.fn(),
957 | debug: jest.fn(),
958 | success: jest.fn()
959 | }
960 | },
961 | false // force
962 | );
963 |
964 | // Should read from feature tag report
965 | expect(readJSON).toHaveBeenCalledWith(
966 | path.join(
967 | projectRoot,
968 | '.taskmaster/reports',
969 | 'task-complexity-report_feature-auth.json'
970 | )
971 | );
972 | });
973 |
974 | test('should use master complexity report for master tag expansion', async () => {
975 | // Mock existing master report
976 | mockExistsSync.mockImplementation((filepath) => {
977 | return filepath.endsWith('task-complexity-report.json');
978 | });
979 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport));
980 |
981 | const tasksPath = path.join(projectRoot, 'tasks/tasks.json');
982 | const taskId = 1;
983 | const numSubtasks = 3;
984 |
985 | await expandTask(
986 | tasksPath,
987 | taskId,
988 | numSubtasks,
989 | false, // useResearch
990 | '', // additionalContext
991 | {
992 | projectRoot,
993 | tag: 'master',
994 | complexityReportPath: path.join(
995 | projectRoot,
996 | '.taskmaster/reports',
997 | 'task-complexity-report.json'
998 | ),
999 | mcpLog: {
1000 | info: jest.fn(),
1001 | warn: jest.fn(),
1002 | error: jest.fn(),
1003 | debug: jest.fn(),
1004 | success: jest.fn()
1005 | }
1006 | },
1007 | false // force
1008 | );
1009 |
1010 | // Should read from master report
1011 | expect(readJSON).toHaveBeenCalledWith(
1012 | path.join(
1013 | projectRoot,
1014 | '.taskmaster/reports',
1015 | 'task-complexity-report.json'
1016 | )
1017 | );
1018 | });
1019 | });
1020 |
1021 | describe('Cross-Tag Contamination Prevention', () => {
1022 | test('should maintain separate reports for different tags', async () => {
1023 | // Create different complexity reports for different tags
1024 | const masterReport = {
1025 | ...sampleComplexityReport,
1026 | complexityAnalysis: [
1027 | {
1028 | taskId: 1,
1029 | taskTitle: 'Master Task 1',
1030 | complexityScore: 8,
1031 | recommendedSubtasks: 5,
1032 | expansionPrompt: 'Master expansion',
1033 | reasoning: 'Master task reasoning'
1034 | }
1035 | ]
1036 | };
1037 |
1038 | const featureReport = {
1039 | ...sampleComplexityReport,
1040 | complexityAnalysis: [
1041 | {
1042 | taskId: 1,
1043 | taskTitle: 'Feature Task 1',
1044 | complexityScore: 6,
1045 | recommendedSubtasks: 3,
1046 | expansionPrompt: 'Feature expansion',
1047 | reasoning: 'Feature task reasoning'
1048 | }
1049 | ]
1050 | };
1051 |
1052 | // Mock file system to return different reports for different paths
1053 | mockExistsSync.mockImplementation((filepath) => {
1054 | return filepath.includes('task-complexity-report');
1055 | });
1056 |
1057 | mockReadFileSync.mockImplementation((filepath) => {
1058 | if (filepath.includes('task-complexity-report_feature-auth.json')) {
1059 | return JSON.stringify(featureReport);
1060 | } else if (filepath.includes('task-complexity-report.json')) {
1061 | return JSON.stringify(masterReport);
1062 | }
1063 | return '{}';
1064 | });
1065 |
1066 | // Analyze master tag
1067 | const masterOptions = {
1068 | file: 'tasks/tasks.json',
1069 | threshold: '5',
1070 | projectRoot,
1071 | tag: 'master'
1072 | };
1073 |
1074 | await analyzeTaskComplexity(masterOptions, {
1075 | projectRoot,
1076 | mcpLog: {
1077 | info: jest.fn(),
1078 | warn: jest.fn(),
1079 | error: jest.fn(),
1080 | debug: jest.fn(),
1081 | success: jest.fn()
1082 | }
1083 | });
1084 |
1085 | // Verify that master report was written to master location
1086 | expect(mockWriteFileSync).toHaveBeenCalledWith(
1087 | path.join(
1088 | projectRoot,
1089 | '.taskmaster/reports',
1090 | 'task-complexity-report.json'
1091 | ),
1092 | expect.stringContaining('"taskTitle": "Test Task"'),
1093 | 'utf8'
1094 | );
1095 |
1096 | // Clear mocks
1097 | jest.clearAllMocks();
1098 | readJSON.mockReturnValue(sampleTasks);
1099 |
1100 | // Analyze feature tag
1101 | const featureOptions = {
1102 | file: 'tasks/tasks.json',
1103 | threshold: '5',
1104 | projectRoot,
1105 | tag: 'feature-auth'
1106 | };
1107 |
1108 | await analyzeTaskComplexity(featureOptions, {
1109 | projectRoot,
1110 | mcpLog: {
1111 | info: jest.fn(),
1112 | warn: jest.fn(),
1113 | error: jest.fn(),
1114 | debug: jest.fn(),
1115 | success: jest.fn()
1116 | }
1117 | });
1118 |
1119 | // Verify that feature report was written to feature location
1120 | expect(mockWriteFileSync).toHaveBeenCalledWith(
1121 | path.join(
1122 | projectRoot,
1123 | '.taskmaster/reports',
1124 | 'task-complexity-report_feature-auth.json'
1125 | ),
1126 | expect.stringContaining('"taskTitle": "Test Task"'),
1127 | 'utf8'
1128 | );
1129 | });
1130 | });
1131 |
1132 | describe('Edge Cases', () => {
1133 | test('should handle empty tag gracefully', async () => {
1134 | const options = {
1135 | file: 'tasks/tasks.json',
1136 | threshold: '5',
1137 | projectRoot,
1138 | tag: ''
1139 | };
1140 |
1141 | await analyzeTaskComplexity(options, {
1142 | projectRoot,
1143 | mcpLog: {
1144 | info: jest.fn(),
1145 | warn: jest.fn(),
1146 | error: jest.fn(),
1147 | debug: jest.fn(),
1148 | success: jest.fn()
1149 | }
1150 | });
1151 |
1152 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith(
1153 | undefined,
1154 | expect.objectContaining({
1155 | tag: '',
1156 | projectRoot
1157 | }),
1158 | expect.any(Function)
1159 | );
1160 | });
1161 |
1162 | test('should handle null tag gracefully', async () => {
1163 | const options = {
1164 | file: 'tasks/tasks.json',
1165 | threshold: '5',
1166 | projectRoot,
1167 | tag: null
1168 | };
1169 |
1170 | await analyzeTaskComplexity(options, {
1171 | projectRoot,
1172 | mcpLog: {
1173 | info: jest.fn(),
1174 | warn: jest.fn(),
1175 | error: jest.fn(),
1176 | debug: jest.fn(),
1177 | success: jest.fn()
1178 | }
1179 | });
1180 |
1181 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith(
1182 | undefined,
1183 | expect.objectContaining({
1184 | tag: null,
1185 | projectRoot
1186 | }),
1187 | expect.any(Function)
1188 | );
1189 | });
1190 |
1191 | test('should handle special characters in tag names', async () => {
1192 | const options = {
1193 | file: 'tasks/tasks.json',
1194 | threshold: '5',
1195 | projectRoot,
1196 | tag: 'feature/user-auth-v2'
1197 | };
1198 |
1199 | await analyzeTaskComplexity(options, {
1200 | projectRoot,
1201 | mcpLog: {
1202 | info: jest.fn(),
1203 | warn: jest.fn(),
1204 | error: jest.fn(),
1205 | debug: jest.fn(),
1206 | success: jest.fn()
1207 | }
1208 | });
1209 |
1210 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith(
1211 | undefined,
1212 | expect.objectContaining({
1213 | tag: 'feature/user-auth-v2',
1214 | projectRoot
1215 | }),
1216 | expect.any(Function)
1217 | );
1218 |
1219 | expect(mockWriteFileSync).toHaveBeenCalledWith(
1220 | path.join(
1221 | projectRoot,
1222 | '.taskmaster/reports',
1223 | 'task-complexity-report_feature-user-auth-v2.json'
1224 | ),
1225 | expect.any(String),
1226 | 'utf8'
1227 | );
1228 | });
1229 | });
1230 | });
1231 |
```