This is page 39 of 52. 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 │ ├── agents │ │ ├── task-checker.md │ │ ├── task-executor.md │ │ └── task-orchestrator.md │ ├── commands │ │ ├── dedupe.md │ │ └── tm │ │ ├── add-dependency │ │ │ └── add-dependency.md │ │ ├── add-subtask │ │ │ ├── add-subtask.md │ │ │ └── convert-task-to-subtask.md │ │ ├── add-task │ │ │ └── add-task.md │ │ ├── analyze-complexity │ │ │ └── analyze-complexity.md │ │ ├── complexity-report │ │ │ └── complexity-report.md │ │ ├── expand │ │ │ ├── expand-all-tasks.md │ │ │ └── expand-task.md │ │ ├── fix-dependencies │ │ │ └── fix-dependencies.md │ │ ├── generate │ │ │ └── generate-tasks.md │ │ ├── help.md │ │ ├── init │ │ │ ├── init-project-quick.md │ │ │ └── init-project.md │ │ ├── learn.md │ │ ├── list │ │ │ ├── list-tasks-by-status.md │ │ │ ├── list-tasks-with-subtasks.md │ │ │ └── list-tasks.md │ │ ├── models │ │ │ ├── setup-models.md │ │ │ └── view-models.md │ │ ├── next │ │ │ └── next-task.md │ │ ├── parse-prd │ │ │ ├── parse-prd-with-research.md │ │ │ └── parse-prd.md │ │ ├── remove-dependency │ │ │ └── remove-dependency.md │ │ ├── remove-subtask │ │ │ └── remove-subtask.md │ │ ├── remove-subtasks │ │ │ ├── remove-all-subtasks.md │ │ │ └── remove-subtasks.md │ │ ├── remove-task │ │ │ └── remove-task.md │ │ ├── set-status │ │ │ ├── to-cancelled.md │ │ │ ├── to-deferred.md │ │ │ ├── to-done.md │ │ │ ├── to-in-progress.md │ │ │ ├── to-pending.md │ │ │ └── to-review.md │ │ ├── setup │ │ │ ├── install-taskmaster.md │ │ │ └── quick-install-taskmaster.md │ │ ├── show │ │ │ └── show-task.md │ │ ├── status │ │ │ └── project-status.md │ │ ├── sync-readme │ │ │ └── sync-readme.md │ │ ├── tm-main.md │ │ ├── update │ │ │ ├── update-single-task.md │ │ │ ├── update-task.md │ │ │ └── update-tasks-from-id.md │ │ ├── utils │ │ │ └── analyze-project.md │ │ ├── validate-dependencies │ │ │ └── validate-dependencies.md │ │ └── workflows │ │ ├── auto-implement-tasks.md │ │ ├── command-pipeline.md │ │ └── smart-workflow.md │ └── TM_COMMANDS_GUIDE.md ├── .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 │ └── 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 │ │ ├── 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 │ │ ├── test-prd.txt │ │ └── tm-core-phase-1.txt │ ├── reports │ │ ├── task-complexity-report_cc-kiro-hooks.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.txt ├── .vscode │ ├── extensions.json │ └── settings.json ├── apps │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── auth.command.ts │ │ │ │ ├── context.command.ts │ │ │ │ ├── list.command.ts │ │ │ │ ├── set-status.command.ts │ │ │ │ ├── show.command.ts │ │ │ │ └── start.command.ts │ │ │ ├── index.ts │ │ │ ├── ui │ │ │ │ ├── components │ │ │ │ │ ├── dashboard.component.ts │ │ │ │ │ ├── header.component.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── next-task.component.ts │ │ │ │ │ ├── suggested-steps.component.ts │ │ │ │ │ └── task-detail.component.ts │ │ │ │ └── index.ts │ │ │ └── utils │ │ │ ├── auto-update.ts │ │ │ └── ui.ts │ │ └── tsconfig.json │ ├── 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 │ │ │ └── task-structure.mdx │ │ ├── CHANGELOG.md │ │ ├── docs.json │ │ ├── favicon.svg │ │ ├── getting-started │ │ │ ├── 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 │ │ ├── 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 ├── assets │ ├── .windsurfrules │ ├── AGENTS.md │ ├── claude │ │ ├── agents │ │ │ ├── task-checker.md │ │ │ ├── task-executor.md │ │ │ └── task-orchestrator.md │ │ ├── commands │ │ │ └── tm │ │ │ ├── add-dependency │ │ │ │ └── add-dependency.md │ │ │ ├── add-subtask │ │ │ │ ├── add-subtask.md │ │ │ │ └── convert-task-to-subtask.md │ │ │ ├── add-task │ │ │ │ └── add-task.md │ │ │ ├── analyze-complexity │ │ │ │ └── analyze-complexity.md │ │ │ ├── clear-subtasks │ │ │ │ ├── clear-all-subtasks.md │ │ │ │ └── clear-subtasks.md │ │ │ ├── complexity-report │ │ │ │ └── complexity-report.md │ │ │ ├── expand │ │ │ │ ├── expand-all-tasks.md │ │ │ │ └── expand-task.md │ │ │ ├── fix-dependencies │ │ │ │ └── fix-dependencies.md │ │ │ ├── generate │ │ │ │ └── generate-tasks.md │ │ │ ├── help.md │ │ │ ├── init │ │ │ │ ├── init-project-quick.md │ │ │ │ └── init-project.md │ │ │ ├── learn.md │ │ │ ├── list │ │ │ │ ├── list-tasks-by-status.md │ │ │ │ ├── list-tasks-with-subtasks.md │ │ │ │ └── list-tasks.md │ │ │ ├── models │ │ │ │ ├── setup-models.md │ │ │ │ └── view-models.md │ │ │ ├── next │ │ │ │ └── next-task.md │ │ │ ├── parse-prd │ │ │ │ ├── parse-prd-with-research.md │ │ │ │ └── parse-prd.md │ │ │ ├── remove-dependency │ │ │ │ └── remove-dependency.md │ │ │ ├── remove-subtask │ │ │ │ └── remove-subtask.md │ │ │ ├── remove-subtasks │ │ │ │ ├── remove-all-subtasks.md │ │ │ │ └── remove-subtasks.md │ │ │ ├── remove-task │ │ │ │ └── remove-task.md │ │ │ ├── set-status │ │ │ │ ├── to-cancelled.md │ │ │ │ ├── to-deferred.md │ │ │ │ ├── to-done.md │ │ │ │ ├── to-in-progress.md │ │ │ │ ├── to-pending.md │ │ │ │ └── to-review.md │ │ │ ├── setup │ │ │ │ ├── install-taskmaster.md │ │ │ │ └── quick-install-taskmaster.md │ │ │ ├── show │ │ │ │ └── show-task.md │ │ │ ├── status │ │ │ │ └── project-status.md │ │ │ ├── sync-readme │ │ │ │ └── sync-readme.md │ │ │ ├── tm-main.md │ │ │ ├── update │ │ │ │ ├── update-single-task.md │ │ │ │ ├── update-task.md │ │ │ │ └── update-tasks-from-id.md │ │ │ ├── utils │ │ │ │ └── analyze-project.md │ │ │ ├── validate-dependencies │ │ │ │ └── validate-dependencies.md │ │ │ └── workflows │ │ │ ├── auto-implement-tasks.md │ │ │ ├── command-pipeline.md │ │ │ └── smart-workflow.md │ │ └── TM_COMMANDS_GUIDE.md │ ├── config.json │ ├── env.example │ ├── example_prd.txt │ ├── 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.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 │ ├── CLI-COMMANDER-PATTERN.md │ ├── command-reference.md │ ├── configuration.md │ ├── contributor-docs │ │ └── testing-roo-integration.md │ ├── cross-tag-task-movement.md │ ├── examples │ │ └── claude-code-usage.md │ ├── examples.md │ ├── licensing.md │ ├── mcp-provider-guide.md │ ├── mcp-provider.md │ ├── migration-guide.md │ ├── models.md │ ├── providers │ │ └── gemini-cli.md │ ├── README.md │ ├── scripts │ │ └── models-json-to-markdown.js │ ├── task-structure.md │ └── tutorial.md ├── images │ └── 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 │ │ │ ├── list-tasks.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 │ │ │ ├── show-task.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 │ ├── get-task.js │ ├── get-tasks.js │ ├── index.js │ ├── initialize-project.js │ ├── list-tags.js │ ├── models.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.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 │ ├── build-config │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ └── tsdown.base.ts │ │ └── tsconfig.json │ └── tm-core │ ├── .gitignore │ ├── CHANGELOG.md │ ├── docs │ │ └── listTasks-architecture.md │ ├── package.json │ ├── POC-STATUS.md │ ├── README.md │ ├── src │ │ ├── auth │ │ │ ├── auth-manager.test.ts │ │ │ ├── auth-manager.ts │ │ │ ├── config.ts │ │ │ ├── credential-store.test.ts │ │ │ ├── credential-store.ts │ │ │ ├── index.ts │ │ │ ├── oauth-service.ts │ │ │ ├── supabase-session-storage.ts │ │ │ └── types.ts │ │ ├── clients │ │ │ ├── index.ts │ │ │ └── supabase-client.ts │ │ ├── config │ │ │ ├── config-manager.spec.ts │ │ │ ├── config-manager.ts │ │ │ ├── index.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 │ │ ├── constants │ │ │ └── index.ts │ │ ├── entities │ │ │ └── task.entity.ts │ │ ├── errors │ │ │ ├── index.ts │ │ │ └── task-master-error.ts │ │ ├── executors │ │ │ ├── base-executor.ts │ │ │ ├── claude-executor.ts │ │ │ ├── executor-factory.ts │ │ │ ├── executor-service.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── ai-provider.interface.ts │ │ │ ├── configuration.interface.ts │ │ │ ├── index.ts │ │ │ └── storage.interface.ts │ │ ├── logger │ │ │ ├── factory.ts │ │ │ ├── index.ts │ │ │ └── logger.ts │ │ ├── mappers │ │ │ └── TaskMapper.ts │ │ ├── parser │ │ │ └── index.ts │ │ ├── providers │ │ │ ├── ai │ │ │ │ ├── base-provider.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── supabase-task-repository.ts │ │ │ └── task-repository.interface.ts │ │ ├── services │ │ │ ├── index.ts │ │ │ ├── organization.service.ts │ │ │ ├── task-execution-service.ts │ │ │ └── task-service.ts │ │ ├── storage │ │ │ ├── api-storage.ts │ │ │ ├── file-storage │ │ │ │ ├── file-operations.ts │ │ │ │ ├── file-storage.ts │ │ │ │ ├── format-handler.ts │ │ │ │ ├── index.ts │ │ │ │ └── path-resolver.ts │ │ │ ├── index.ts │ │ │ └── storage-factory.ts │ │ ├── subpath-exports.test.ts │ │ ├── task-master-core.ts │ │ ├── types │ │ │ ├── database.types.ts │ │ │ ├── index.ts │ │ │ └── legacy.ts │ │ └── utils │ │ ├── id-generator.ts │ │ └── index.ts │ ├── tests │ │ ├── integration │ │ │ └── list-tasks.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 │ ├── dev.js │ ├── init.js │ ├── modules │ │ ├── ai-services-unified.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 ├── src │ ├── ai-providers │ │ ├── anthropic.js │ │ ├── azure.js │ │ ├── base-provider.js │ │ ├── bedrock.js │ │ ├── claude-code.js │ │ ├── custom-sdk │ │ │ ├── claude-code │ │ │ │ ├── errors.js │ │ │ │ ├── index.js │ │ │ │ ├── json-extractor.js │ │ │ │ ├── language-model.js │ │ │ │ ├── message-converter.js │ │ │ │ └── types.js │ │ │ └── grok-cli │ │ │ ├── errors.js │ │ │ ├── index.js │ │ │ ├── json-extractor.js │ │ │ ├── language-model.js │ │ │ ├── message-converter.js │ │ │ └── types.js │ │ ├── gemini-cli.js │ │ ├── google-vertex.js │ │ ├── google.js │ │ ├── grok-cli.js │ │ ├── groq.js │ │ ├── index.js │ │ ├── ollama.js │ │ ├── openai.js │ │ ├── openrouter.js │ │ ├── perplexity.js │ │ └── xai.js │ ├── constants │ │ ├── commands.js │ │ ├── paths.js │ │ ├── profiles.js │ │ ├── providers.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 │ ├── 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 │ ├── fixture │ │ └── test-tasks.json │ ├── fixtures │ │ ├── .taskmasterconfig │ │ ├── sample-claude-response.js │ │ ├── sample-prd.txt │ │ └── sample-tasks.js │ ├── integration │ │ ├── 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 │ ├── 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 │ │ ├── claude-code.test.js │ │ ├── custom-sdk │ │ │ └── claude-code │ │ │ └── language-model.test.js │ │ ├── gemini-cli.test.js │ │ ├── mcp-components.test.js │ │ └── openai.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 │ ├── 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 │ ├── providers │ │ └── provider-registry.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 │ │ │ ├── move-task-cross-tag.test.js │ │ │ ├── move-task.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 ``` # Files -------------------------------------------------------------------------------- /scripts/modules/task-manager/research.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * research.js 3 | * Core research functionality for AI-powered queries with project context 4 | */ 5 | 6 | import fs from 'fs'; 7 | import path from 'path'; 8 | import chalk from 'chalk'; 9 | import boxen from 'boxen'; 10 | import inquirer from 'inquirer'; 11 | import { highlight } from 'cli-highlight'; 12 | import { ContextGatherer } from '../utils/contextGatherer.js'; 13 | import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; 14 | import { generateTextService } from '../ai-services-unified.js'; 15 | import { getPromptManager } from '../prompt-manager.js'; 16 | import { 17 | log as consoleLog, 18 | findProjectRoot, 19 | readJSON, 20 | flattenTasksWithSubtasks 21 | } from '../utils.js'; 22 | import { 23 | displayAiUsageSummary, 24 | startLoadingIndicator, 25 | stopLoadingIndicator 26 | } from '../ui.js'; 27 | 28 | /** 29 | * Perform AI-powered research with project context 30 | * @param {string} query - Research query/prompt 31 | * @param {Object} options - Research options 32 | * @param {Array<string>} [options.taskIds] - Task/subtask IDs for context 33 | * @param {Array<string>} [options.filePaths] - File paths for context 34 | * @param {string} [options.customContext] - Additional custom context 35 | * @param {boolean} [options.includeProjectTree] - Include project file tree 36 | * @param {string} [options.detailLevel] - Detail level: 'low', 'medium', 'high' 37 | * @param {string} [options.projectRoot] - Project root directory 38 | * @param {string} [options.tag] - Tag for the task 39 | * @param {boolean} [options.saveToFile] - Whether to save results to file (MCP mode) 40 | * @param {Object} [context] - Execution context 41 | * @param {Object} [context.session] - MCP session object 42 | * @param {Object} [context.mcpLog] - MCP logger object 43 | * @param {string} [context.commandName] - Command name for telemetry 44 | * @param {string} [context.outputType] - Output type ('cli' or 'mcp') 45 | * @param {string} [outputFormat] - Output format ('text' or 'json') 46 | * @param {boolean} [allowFollowUp] - Whether to allow follow-up questions (default: true) 47 | * @returns {Promise<Object>} Research results with telemetry data 48 | */ 49 | async function performResearch( 50 | query, 51 | options = {}, 52 | context = {}, 53 | outputFormat = 'text', 54 | allowFollowUp = true 55 | ) { 56 | const { 57 | taskIds = [], 58 | filePaths = [], 59 | customContext = '', 60 | includeProjectTree = false, 61 | detailLevel = 'medium', 62 | projectRoot: providedProjectRoot, 63 | tag, 64 | saveToFile = false 65 | } = options; 66 | 67 | const { 68 | session, 69 | mcpLog, 70 | commandName = 'research', 71 | outputType = 'cli' 72 | } = context; 73 | const isMCP = !!mcpLog; 74 | 75 | // Determine project root 76 | const projectRoot = providedProjectRoot || findProjectRoot(); 77 | if (!projectRoot) { 78 | throw new Error('Could not determine project root directory'); 79 | } 80 | 81 | // Create consistent logger 82 | const logFn = isMCP 83 | ? mcpLog 84 | : { 85 | info: (...args) => consoleLog('info', ...args), 86 | warn: (...args) => consoleLog('warn', ...args), 87 | error: (...args) => consoleLog('error', ...args), 88 | debug: (...args) => consoleLog('debug', ...args), 89 | success: (...args) => consoleLog('success', ...args) 90 | }; 91 | 92 | // Show UI banner for CLI mode 93 | if (outputFormat === 'text') { 94 | console.log( 95 | boxen(chalk.cyan.bold(`🔍 AI Research Query`), { 96 | padding: 1, 97 | borderColor: 'cyan', 98 | borderStyle: 'round', 99 | margin: { top: 1, bottom: 1 } 100 | }) 101 | ); 102 | } 103 | 104 | try { 105 | // Initialize context gatherer 106 | const contextGatherer = new ContextGatherer(projectRoot, tag); 107 | 108 | // Auto-discover relevant tasks using fuzzy search to supplement provided tasks 109 | let finalTaskIds = [...taskIds]; // Start with explicitly provided tasks 110 | let autoDiscoveredIds = []; 111 | 112 | try { 113 | const tasksPath = path.join( 114 | projectRoot, 115 | '.taskmaster', 116 | 'tasks', 117 | 'tasks.json' 118 | ); 119 | const tasksData = await readJSON(tasksPath, projectRoot, tag); 120 | 121 | if (tasksData && tasksData.tasks && tasksData.tasks.length > 0) { 122 | // Flatten tasks to include subtasks for fuzzy search 123 | const flattenedTasks = flattenTasksWithSubtasks(tasksData.tasks); 124 | const fuzzySearch = new FuzzyTaskSearch(flattenedTasks, 'research'); 125 | const searchResults = fuzzySearch.findRelevantTasks(query, { 126 | maxResults: 8, 127 | includeRecent: true, 128 | includeCategoryMatches: true 129 | }); 130 | 131 | autoDiscoveredIds = fuzzySearch.getTaskIds(searchResults); 132 | 133 | // Remove any auto-discovered tasks that were already explicitly provided 134 | const uniqueAutoDiscovered = autoDiscoveredIds.filter( 135 | (id) => !finalTaskIds.includes(id) 136 | ); 137 | 138 | // Add unique auto-discovered tasks to the final list 139 | finalTaskIds = [...finalTaskIds, ...uniqueAutoDiscovered]; 140 | 141 | if (outputFormat === 'text' && finalTaskIds.length > 0) { 142 | // Sort task IDs numerically for better display 143 | const sortedTaskIds = finalTaskIds 144 | .map((id) => parseInt(id)) 145 | .sort((a, b) => a - b) 146 | .map((id) => id.toString()); 147 | 148 | // Show different messages based on whether tasks were explicitly provided 149 | if (taskIds.length > 0) { 150 | const sortedProvidedIds = taskIds 151 | .map((id) => parseInt(id)) 152 | .sort((a, b) => a - b) 153 | .map((id) => id.toString()); 154 | 155 | console.log( 156 | chalk.gray('Provided tasks: ') + 157 | chalk.cyan(sortedProvidedIds.join(', ')) 158 | ); 159 | 160 | if (uniqueAutoDiscovered.length > 0) { 161 | const sortedAutoIds = uniqueAutoDiscovered 162 | .map((id) => parseInt(id)) 163 | .sort((a, b) => a - b) 164 | .map((id) => id.toString()); 165 | 166 | console.log( 167 | chalk.gray('+ Auto-discovered related tasks: ') + 168 | chalk.cyan(sortedAutoIds.join(', ')) 169 | ); 170 | } 171 | } else { 172 | console.log( 173 | chalk.gray('Auto-discovered relevant tasks: ') + 174 | chalk.cyan(sortedTaskIds.join(', ')) 175 | ); 176 | } 177 | } 178 | } 179 | } catch (error) { 180 | // Silently continue without auto-discovered tasks if there's an error 181 | logFn.debug(`Could not auto-discover tasks: ${error.message}`); 182 | } 183 | 184 | const contextResult = await contextGatherer.gather({ 185 | tasks: finalTaskIds, 186 | files: filePaths, 187 | customContext, 188 | includeProjectTree, 189 | format: 'research', // Use research format for AI consumption 190 | includeTokenCounts: true 191 | }); 192 | 193 | const gatheredContext = contextResult.context; 194 | const tokenBreakdown = contextResult.tokenBreakdown; 195 | 196 | // Load prompts using PromptManager 197 | const promptManager = getPromptManager(); 198 | 199 | const promptParams = { 200 | query: query, 201 | gatheredContext: gatheredContext || '', 202 | detailLevel: detailLevel, 203 | projectInfo: { 204 | root: projectRoot, 205 | taskCount: finalTaskIds.length, 206 | fileCount: filePaths.length 207 | } 208 | }; 209 | 210 | // Load prompts - the research template handles detail level internally 211 | const { systemPrompt, userPrompt } = await promptManager.loadPrompt( 212 | 'research', 213 | promptParams 214 | ); 215 | 216 | // Count tokens for system and user prompts 217 | const systemPromptTokens = contextGatherer.countTokens(systemPrompt); 218 | const userPromptTokens = contextGatherer.countTokens(userPrompt); 219 | const totalInputTokens = systemPromptTokens + userPromptTokens; 220 | 221 | if (outputFormat === 'text') { 222 | // Display detailed token breakdown in a clean box 223 | displayDetailedTokenBreakdown( 224 | tokenBreakdown, 225 | systemPromptTokens, 226 | userPromptTokens 227 | ); 228 | } 229 | 230 | // Only log detailed info in debug mode or MCP 231 | if (outputFormat !== 'text') { 232 | logFn.info( 233 | `Calling AI service with research role, context size: ${tokenBreakdown.total} tokens (${gatheredContext.length} characters)` 234 | ); 235 | } 236 | 237 | // Start loading indicator for CLI mode 238 | let loadingIndicator = null; 239 | if (outputFormat === 'text') { 240 | loadingIndicator = startLoadingIndicator('Researching with AI...\n'); 241 | } 242 | 243 | let aiResult; 244 | try { 245 | // Call AI service with research role 246 | aiResult = await generateTextService({ 247 | role: 'research', // Always use research role for research command 248 | session, 249 | projectRoot, 250 | systemPrompt, 251 | prompt: userPrompt, 252 | commandName, 253 | outputType 254 | }); 255 | } catch (error) { 256 | if (loadingIndicator) { 257 | stopLoadingIndicator(loadingIndicator); 258 | } 259 | throw error; 260 | } finally { 261 | if (loadingIndicator) { 262 | stopLoadingIndicator(loadingIndicator); 263 | } 264 | } 265 | 266 | const researchResult = aiResult.mainResult; 267 | const telemetryData = aiResult.telemetryData; 268 | const tagInfo = aiResult.tagInfo; 269 | 270 | // Format and display results 271 | // Initialize interactive save tracking 272 | let interactiveSaveInfo = { interactiveSaveOccurred: false }; 273 | 274 | if (outputFormat === 'text') { 275 | displayResearchResults( 276 | researchResult, 277 | query, 278 | detailLevel, 279 | tokenBreakdown 280 | ); 281 | 282 | // Display AI usage telemetry for CLI users 283 | if (telemetryData) { 284 | displayAiUsageSummary(telemetryData, 'cli'); 285 | } 286 | 287 | // Offer follow-up question option (only for initial CLI queries, not MCP) 288 | if (allowFollowUp && !isMCP) { 289 | interactiveSaveInfo = await handleFollowUpQuestions( 290 | options, 291 | context, 292 | outputFormat, 293 | projectRoot, 294 | logFn, 295 | query, 296 | researchResult 297 | ); 298 | } 299 | } 300 | 301 | // Handle MCP save-to-file request 302 | if (saveToFile && isMCP) { 303 | const conversationHistory = [ 304 | { 305 | question: query, 306 | answer: researchResult, 307 | type: 'initial', 308 | timestamp: new Date().toISOString() 309 | } 310 | ]; 311 | 312 | const savedFilePath = await handleSaveToFile( 313 | conversationHistory, 314 | projectRoot, 315 | context, 316 | logFn 317 | ); 318 | 319 | // Add saved file path to return data 320 | return { 321 | query, 322 | result: researchResult, 323 | contextSize: gatheredContext.length, 324 | contextTokens: tokenBreakdown.total, 325 | tokenBreakdown, 326 | systemPromptTokens, 327 | userPromptTokens, 328 | totalInputTokens, 329 | detailLevel, 330 | telemetryData, 331 | tagInfo, 332 | savedFilePath, 333 | interactiveSaveOccurred: false // MCP save-to-file doesn't count as interactive save 334 | }; 335 | } 336 | 337 | logFn.success('Research query completed successfully'); 338 | 339 | return { 340 | query, 341 | result: researchResult, 342 | contextSize: gatheredContext.length, 343 | contextTokens: tokenBreakdown.total, 344 | tokenBreakdown, 345 | systemPromptTokens, 346 | userPromptTokens, 347 | totalInputTokens, 348 | detailLevel, 349 | telemetryData, 350 | tagInfo, 351 | interactiveSaveOccurred: 352 | interactiveSaveInfo?.interactiveSaveOccurred || false 353 | }; 354 | } catch (error) { 355 | logFn.error(`Research query failed: ${error.message}`); 356 | 357 | if (outputFormat === 'text') { 358 | console.error(chalk.red(`\n❌ Research failed: ${error.message}`)); 359 | } 360 | 361 | throw error; 362 | } 363 | } 364 | 365 | /** 366 | * Display detailed token breakdown for context and prompts 367 | * @param {Object} tokenBreakdown - Token breakdown from context gatherer 368 | * @param {number} systemPromptTokens - System prompt token count 369 | * @param {number} userPromptTokens - User prompt token count 370 | */ 371 | function displayDetailedTokenBreakdown( 372 | tokenBreakdown, 373 | systemPromptTokens, 374 | userPromptTokens 375 | ) { 376 | const parts = []; 377 | 378 | // Custom context 379 | if (tokenBreakdown.customContext) { 380 | parts.push( 381 | chalk.cyan('Custom: ') + 382 | chalk.yellow(tokenBreakdown.customContext.tokens.toLocaleString()) 383 | ); 384 | } 385 | 386 | // Tasks breakdown 387 | if (tokenBreakdown.tasks && tokenBreakdown.tasks.length > 0) { 388 | const totalTaskTokens = tokenBreakdown.tasks.reduce( 389 | (sum, task) => sum + task.tokens, 390 | 0 391 | ); 392 | const taskDetails = tokenBreakdown.tasks 393 | .map((task) => { 394 | const titleDisplay = 395 | task.title.length > 30 396 | ? task.title.substring(0, 30) + '...' 397 | : task.title; 398 | return ` ${chalk.gray(task.id)} ${chalk.white(titleDisplay)} ${chalk.yellow(task.tokens.toLocaleString())} tokens`; 399 | }) 400 | .join('\n'); 401 | 402 | parts.push( 403 | chalk.cyan('Tasks: ') + 404 | chalk.yellow(totalTaskTokens.toLocaleString()) + 405 | chalk.gray(` (${tokenBreakdown.tasks.length} items)`) + 406 | '\n' + 407 | taskDetails 408 | ); 409 | } 410 | 411 | // Files breakdown 412 | if (tokenBreakdown.files && tokenBreakdown.files.length > 0) { 413 | const totalFileTokens = tokenBreakdown.files.reduce( 414 | (sum, file) => sum + file.tokens, 415 | 0 416 | ); 417 | const fileDetails = tokenBreakdown.files 418 | .map((file) => { 419 | const pathDisplay = 420 | file.path.length > 40 421 | ? '...' + file.path.substring(file.path.length - 37) 422 | : file.path; 423 | return ` ${chalk.gray(pathDisplay)} ${chalk.yellow(file.tokens.toLocaleString())} tokens ${chalk.gray(`(${file.sizeKB}KB)`)}`; 424 | }) 425 | .join('\n'); 426 | 427 | parts.push( 428 | chalk.cyan('Files: ') + 429 | chalk.yellow(totalFileTokens.toLocaleString()) + 430 | chalk.gray(` (${tokenBreakdown.files.length} files)`) + 431 | '\n' + 432 | fileDetails 433 | ); 434 | } 435 | 436 | // Project tree 437 | if (tokenBreakdown.projectTree) { 438 | parts.push( 439 | chalk.cyan('Project Tree: ') + 440 | chalk.yellow(tokenBreakdown.projectTree.tokens.toLocaleString()) + 441 | chalk.gray( 442 | ` (${tokenBreakdown.projectTree.fileCount} files, ${tokenBreakdown.projectTree.dirCount} dirs)` 443 | ) 444 | ); 445 | } 446 | 447 | // Prompts breakdown 448 | const totalPromptTokens = systemPromptTokens + userPromptTokens; 449 | const promptDetails = [ 450 | ` ${chalk.gray('System:')} ${chalk.yellow(systemPromptTokens.toLocaleString())} tokens`, 451 | ` ${chalk.gray('User:')} ${chalk.yellow(userPromptTokens.toLocaleString())} tokens` 452 | ].join('\n'); 453 | 454 | parts.push( 455 | chalk.cyan('Prompts: ') + 456 | chalk.yellow(totalPromptTokens.toLocaleString()) + 457 | chalk.gray(' (generated)') + 458 | '\n' + 459 | promptDetails 460 | ); 461 | 462 | // Display the breakdown in a clean box 463 | if (parts.length > 0) { 464 | const content = parts.join('\n\n'); 465 | const tokenBox = boxen(content, { 466 | title: chalk.blue.bold('Context Analysis'), 467 | titleAlignment: 'left', 468 | padding: { top: 1, bottom: 1, left: 2, right: 2 }, 469 | margin: { top: 0, bottom: 1 }, 470 | borderStyle: 'single', 471 | borderColor: 'blue' 472 | }); 473 | console.log(tokenBox); 474 | } 475 | } 476 | 477 | /** 478 | * Process research result text to highlight code blocks 479 | * @param {string} text - Raw research result text 480 | * @returns {string} Processed text with highlighted code blocks 481 | */ 482 | function processCodeBlocks(text) { 483 | // Regex to match code blocks with optional language specification 484 | const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; 485 | 486 | return text.replace(codeBlockRegex, (match, language, code) => { 487 | try { 488 | // Default to javascript if no language specified 489 | const lang = language || 'javascript'; 490 | 491 | // Highlight the code using cli-highlight 492 | const highlightedCode = highlight(code.trim(), { 493 | language: lang, 494 | ignoreIllegals: true // Don't fail on unrecognized syntax 495 | }); 496 | 497 | // Add a subtle border around code blocks 498 | const codeBox = boxen(highlightedCode, { 499 | padding: { top: 0, bottom: 0, left: 1, right: 1 }, 500 | margin: { top: 0, bottom: 0 }, 501 | borderStyle: 'single', 502 | borderColor: 'dim' 503 | }); 504 | 505 | return '\n' + codeBox + '\n'; 506 | } catch (error) { 507 | // If highlighting fails, return the original code block with basic formatting 508 | return ( 509 | '\n' + 510 | chalk.gray('```' + (language || '')) + 511 | '\n' + 512 | chalk.white(code.trim()) + 513 | '\n' + 514 | chalk.gray('```') + 515 | '\n' 516 | ); 517 | } 518 | }); 519 | } 520 | 521 | /** 522 | * Display research results in formatted output 523 | * @param {string} result - AI research result 524 | * @param {string} query - Original query 525 | * @param {string} detailLevel - Detail level used 526 | * @param {Object} tokenBreakdown - Detailed token usage 527 | */ 528 | function displayResearchResults(result, query, detailLevel, tokenBreakdown) { 529 | // Header with query info 530 | const header = boxen( 531 | chalk.green.bold('Research Results') + 532 | '\n\n' + 533 | chalk.gray('Query: ') + 534 | chalk.white(query) + 535 | '\n' + 536 | chalk.gray('Detail Level: ') + 537 | chalk.cyan(detailLevel), 538 | { 539 | padding: { top: 1, bottom: 1, left: 2, right: 2 }, 540 | margin: { top: 1, bottom: 0 }, 541 | borderStyle: 'round', 542 | borderColor: 'green' 543 | } 544 | ); 545 | console.log(header); 546 | 547 | // Process the result to highlight code blocks 548 | const processedResult = processCodeBlocks(result); 549 | 550 | // Main research content in a clean box 551 | const contentBox = boxen(processedResult, { 552 | padding: { top: 1, bottom: 1, left: 2, right: 2 }, 553 | margin: { top: 0, bottom: 1 }, 554 | borderStyle: 'single', 555 | borderColor: 'gray' 556 | }); 557 | console.log(contentBox); 558 | 559 | // Success footer 560 | console.log(chalk.green('✅ Research completed')); 561 | } 562 | 563 | /** 564 | * Handle follow-up questions and save functionality in interactive mode 565 | * @param {Object} originalOptions - Original research options 566 | * @param {Object} context - Execution context 567 | * @param {string} outputFormat - Output format 568 | * @param {string} projectRoot - Project root directory 569 | * @param {Object} logFn - Logger function 570 | * @param {string} initialQuery - Initial query for context 571 | * @param {string} initialResult - Initial AI result for context 572 | */ 573 | async function handleFollowUpQuestions( 574 | originalOptions, 575 | context, 576 | outputFormat, 577 | projectRoot, 578 | logFn, 579 | initialQuery, 580 | initialResult 581 | ) { 582 | let interactiveSaveOccurred = false; 583 | 584 | try { 585 | // Import required modules for saving 586 | const { readJSON } = await import('../utils.js'); 587 | const updateTaskById = (await import('./update-task-by-id.js')).default; 588 | const { updateSubtaskById } = await import('./update-subtask-by-id.js'); 589 | 590 | // Initialize conversation history with the initial Q&A 591 | const conversationHistory = [ 592 | { 593 | question: initialQuery, 594 | answer: initialResult, 595 | type: 'initial', 596 | timestamp: new Date().toISOString() 597 | } 598 | ]; 599 | 600 | while (true) { 601 | // Get user choice 602 | const { action } = await inquirer.prompt([ 603 | { 604 | type: 'list', 605 | name: 'action', 606 | message: 'What would you like to do next?', 607 | choices: [ 608 | { name: 'Ask a follow-up question', value: 'followup' }, 609 | { name: 'Save to file', value: 'savefile' }, 610 | { name: 'Save to task/subtask', value: 'save' }, 611 | { name: 'Quit', value: 'quit' } 612 | ], 613 | pageSize: 4 614 | } 615 | ]); 616 | 617 | if (action === 'quit') { 618 | break; 619 | } 620 | 621 | if (action === 'savefile') { 622 | // Handle save to file functionality 623 | await handleSaveToFile( 624 | conversationHistory, 625 | projectRoot, 626 | context, 627 | logFn 628 | ); 629 | continue; 630 | } 631 | 632 | if (action === 'save') { 633 | // Handle save functionality 634 | const saveResult = await handleSaveToTask( 635 | conversationHistory, 636 | projectRoot, 637 | context, 638 | logFn 639 | ); 640 | if (saveResult) { 641 | interactiveSaveOccurred = true; 642 | } 643 | continue; 644 | } 645 | 646 | if (action === 'followup') { 647 | // Get the follow-up question 648 | const { followUpQuery } = await inquirer.prompt([ 649 | { 650 | type: 'input', 651 | name: 'followUpQuery', 652 | message: 'Enter your follow-up question:', 653 | validate: (input) => { 654 | if (!input || input.trim().length === 0) { 655 | return 'Please enter a valid question.'; 656 | } 657 | return true; 658 | } 659 | } 660 | ]); 661 | 662 | if (!followUpQuery || followUpQuery.trim().length === 0) { 663 | continue; 664 | } 665 | 666 | console.log('\n' + chalk.gray('─'.repeat(60)) + '\n'); 667 | 668 | // Build cumulative conversation context from all previous exchanges 669 | const conversationContext = 670 | buildConversationContext(conversationHistory); 671 | 672 | // Create enhanced options for follow-up with full conversation context 673 | const followUpOptions = { 674 | ...originalOptions, 675 | taskIds: [], // Clear task IDs to allow fresh fuzzy search 676 | customContext: 677 | conversationContext + 678 | (originalOptions.customContext 679 | ? `\n\n--- Original Context ---\n${originalOptions.customContext}` 680 | : '') 681 | }; 682 | 683 | // Perform follow-up research 684 | const followUpResult = await performResearch( 685 | followUpQuery.trim(), 686 | followUpOptions, 687 | context, 688 | outputFormat, 689 | false // allowFollowUp = false for nested calls 690 | ); 691 | 692 | // Add this exchange to the conversation history 693 | conversationHistory.push({ 694 | question: followUpQuery.trim(), 695 | answer: followUpResult.result, 696 | type: 'followup', 697 | timestamp: new Date().toISOString() 698 | }); 699 | } 700 | } 701 | } catch (error) { 702 | // If there's an error with inquirer (e.g., non-interactive terminal), 703 | // silently continue without follow-up functionality 704 | logFn.debug(`Follow-up questions not available: ${error.message}`); 705 | } 706 | 707 | return { interactiveSaveOccurred }; 708 | } 709 | 710 | /** 711 | * Handle saving conversation to a task or subtask 712 | * @param {Array} conversationHistory - Array of conversation exchanges 713 | * @param {string} projectRoot - Project root directory 714 | * @param {Object} context - Execution context 715 | * @param {Object} logFn - Logger function 716 | */ 717 | async function handleSaveToTask( 718 | conversationHistory, 719 | projectRoot, 720 | context, 721 | logFn 722 | ) { 723 | try { 724 | // Import required modules 725 | const { readJSON } = await import('../utils.js'); 726 | const updateTaskById = (await import('./update-task-by-id.js')).default; 727 | const { updateSubtaskById } = await import('./update-subtask-by-id.js'); 728 | 729 | // Get task ID from user 730 | const { taskId } = await inquirer.prompt([ 731 | { 732 | type: 'input', 733 | name: 'taskId', 734 | message: 'Enter task ID (e.g., "15" for task or "15.2" for subtask):', 735 | validate: (input) => { 736 | if (!input || input.trim().length === 0) { 737 | return 'Please enter a task ID.'; 738 | } 739 | 740 | const trimmedInput = input.trim(); 741 | // Validate format: number or number.number 742 | if (!/^\d+(\.\d+)?$/.test(trimmedInput)) { 743 | return 'Invalid format. Use "15" for task or "15.2" for subtask.'; 744 | } 745 | 746 | return true; 747 | } 748 | } 749 | ]); 750 | 751 | const trimmedTaskId = taskId.trim(); 752 | 753 | // Format conversation thread for saving 754 | const conversationThread = formatConversationForSaving(conversationHistory); 755 | 756 | // Determine if it's a task or subtask 757 | const isSubtask = trimmedTaskId.includes('.'); 758 | 759 | // Try to save - first validate the ID exists 760 | const tasksPath = path.join( 761 | projectRoot, 762 | '.taskmaster', 763 | 'tasks', 764 | 'tasks.json' 765 | ); 766 | 767 | if (!fs.existsSync(tasksPath)) { 768 | console.log( 769 | chalk.red('❌ Tasks file not found. Please run task-master init first.') 770 | ); 771 | return; 772 | } 773 | 774 | const data = readJSON(tasksPath, projectRoot, context.tag); 775 | if (!data || !data.tasks) { 776 | console.log(chalk.red('❌ No valid tasks found.')); 777 | return; 778 | } 779 | 780 | if (isSubtask) { 781 | // Validate subtask exists 782 | const [parentId, subtaskId] = trimmedTaskId 783 | .split('.') 784 | .map((id) => parseInt(id, 10)); 785 | const parentTask = data.tasks.find((t) => t.id === parentId); 786 | 787 | if (!parentTask) { 788 | console.log(chalk.red(`❌ Parent task ${parentId} not found.`)); 789 | return; 790 | } 791 | 792 | if ( 793 | !parentTask.subtasks || 794 | !parentTask.subtasks.find((st) => st.id === subtaskId) 795 | ) { 796 | console.log(chalk.red(`❌ Subtask ${trimmedTaskId} not found.`)); 797 | return; 798 | } 799 | 800 | // Save to subtask using updateSubtaskById 801 | console.log(chalk.blue('💾 Saving research conversation to subtask...')); 802 | 803 | await updateSubtaskById( 804 | tasksPath, 805 | trimmedTaskId, 806 | conversationThread, 807 | false, // useResearch = false for simple append 808 | context, 809 | 'text' 810 | ); 811 | 812 | console.log( 813 | chalk.green( 814 | `✅ Research conversation saved to subtask ${trimmedTaskId}` 815 | ) 816 | ); 817 | } else { 818 | // Validate task exists 819 | const taskIdNum = parseInt(trimmedTaskId, 10); 820 | const task = data.tasks.find((t) => t.id === taskIdNum); 821 | 822 | if (!task) { 823 | console.log(chalk.red(`❌ Task ${trimmedTaskId} not found.`)); 824 | return; 825 | } 826 | 827 | // Save to task using updateTaskById with append mode 828 | console.log(chalk.blue('💾 Saving research conversation to task...')); 829 | 830 | await updateTaskById( 831 | tasksPath, 832 | taskIdNum, 833 | conversationThread, 834 | false, // useResearch = false for simple append 835 | context, 836 | 'text', 837 | true // appendMode = true 838 | ); 839 | 840 | console.log( 841 | chalk.green(`✅ Research conversation saved to task ${trimmedTaskId}`) 842 | ); 843 | } 844 | 845 | return true; // Indicate successful save 846 | } catch (error) { 847 | console.log(chalk.red(`❌ Error saving conversation: ${error.message}`)); 848 | logFn.error(`Error saving conversation: ${error.message}`); 849 | return false; // Indicate failed save 850 | } 851 | } 852 | 853 | /** 854 | * Handle saving conversation to a file in .taskmaster/docs/research/ 855 | * @param {Array} conversationHistory - Array of conversation exchanges 856 | * @param {string} projectRoot - Project root directory 857 | * @param {Object} context - Execution context 858 | * @param {Object} logFn - Logger function 859 | * @returns {Promise<string>} Path to saved file 860 | */ 861 | async function handleSaveToFile( 862 | conversationHistory, 863 | projectRoot, 864 | context, 865 | logFn 866 | ) { 867 | try { 868 | // Create research directory if it doesn't exist 869 | const researchDir = path.join( 870 | projectRoot, 871 | '.taskmaster', 872 | 'docs', 873 | 'research' 874 | ); 875 | if (!fs.existsSync(researchDir)) { 876 | fs.mkdirSync(researchDir, { recursive: true }); 877 | } 878 | 879 | // Generate filename from first query and timestamp 880 | const firstQuery = conversationHistory[0]?.question || 'research-query'; 881 | const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format 882 | 883 | // Create a slug from the query (remove special chars, limit length) 884 | const querySlug = firstQuery 885 | .toLowerCase() 886 | .replace(/[^a-z0-9\s-]/g, '') // Remove special characters 887 | .replace(/\s+/g, '-') // Replace spaces with hyphens 888 | .replace(/-+/g, '-') // Replace multiple hyphens with single 889 | .substring(0, 50) // Limit length 890 | .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens 891 | 892 | const filename = `${timestamp}_${querySlug}.md`; 893 | const filePath = path.join(researchDir, filename); 894 | 895 | // Format conversation for file 896 | const fileContent = formatConversationForFile( 897 | conversationHistory, 898 | firstQuery 899 | ); 900 | 901 | // Write file 902 | fs.writeFileSync(filePath, fileContent, 'utf8'); 903 | 904 | const relativePath = path.relative(projectRoot, filePath); 905 | console.log( 906 | chalk.green(`✅ Research saved to: ${chalk.cyan(relativePath)}`) 907 | ); 908 | 909 | logFn.success(`Research conversation saved to ${relativePath}`); 910 | 911 | return filePath; 912 | } catch (error) { 913 | console.log(chalk.red(`❌ Error saving research file: ${error.message}`)); 914 | logFn.error(`Error saving research file: ${error.message}`); 915 | throw error; 916 | } 917 | } 918 | 919 | /** 920 | * Format conversation history for saving to a file 921 | * @param {Array} conversationHistory - Array of conversation exchanges 922 | * @param {string} initialQuery - The initial query for metadata 923 | * @returns {string} Formatted file content 924 | */ 925 | function formatConversationForFile(conversationHistory, initialQuery) { 926 | const timestamp = new Date().toISOString(); 927 | const date = new Date().toLocaleDateString(); 928 | const time = new Date().toLocaleTimeString(); 929 | 930 | // Create metadata header 931 | let content = `--- 932 | title: Research Session 933 | query: "${initialQuery}" 934 | date: ${date} 935 | time: ${time} 936 | timestamp: ${timestamp} 937 | exchanges: ${conversationHistory.length} 938 | --- 939 | 940 | # Research Session 941 | 942 | `; 943 | 944 | // Add each conversation exchange 945 | conversationHistory.forEach((exchange, index) => { 946 | if (exchange.type === 'initial') { 947 | content += `## Initial Query\n\n**Question:** ${exchange.question}\n\n**Response:**\n\n${exchange.answer}\n\n`; 948 | } else { 949 | content += `## Follow-up ${index}\n\n**Question:** ${exchange.question}\n\n**Response:**\n\n${exchange.answer}\n\n`; 950 | } 951 | 952 | if (index < conversationHistory.length - 1) { 953 | content += '---\n\n'; 954 | } 955 | }); 956 | 957 | // Add footer 958 | content += `\n---\n\n*Generated by Task Master Research Command* \n*Timestamp: ${timestamp}*\n`; 959 | 960 | return content; 961 | } 962 | 963 | /** 964 | * Format conversation history for saving to a task/subtask 965 | * @param {Array} conversationHistory - Array of conversation exchanges 966 | * @returns {string} Formatted conversation thread 967 | */ 968 | function formatConversationForSaving(conversationHistory) { 969 | const timestamp = new Date().toISOString(); 970 | let formatted = `## Research Session - ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}\n\n`; 971 | 972 | conversationHistory.forEach((exchange, index) => { 973 | if (exchange.type === 'initial') { 974 | formatted += `**Initial Query:** ${exchange.question}\n\n`; 975 | formatted += `**Response:** ${exchange.answer}\n\n`; 976 | } else { 977 | formatted += `**Follow-up ${index}:** ${exchange.question}\n\n`; 978 | formatted += `**Response:** ${exchange.answer}\n\n`; 979 | } 980 | 981 | if (index < conversationHistory.length - 1) { 982 | formatted += '---\n\n'; 983 | } 984 | }); 985 | 986 | return formatted; 987 | } 988 | 989 | /** 990 | * Build conversation context string from conversation history 991 | * @param {Array} conversationHistory - Array of conversation exchanges 992 | * @returns {string} Formatted conversation context 993 | */ 994 | function buildConversationContext(conversationHistory) { 995 | if (conversationHistory.length === 0) { 996 | return ''; 997 | } 998 | 999 | const contextParts = ['--- Conversation History ---']; 1000 | 1001 | conversationHistory.forEach((exchange, index) => { 1002 | const questionLabel = 1003 | exchange.type === 'initial' ? 'Initial Question' : `Follow-up ${index}`; 1004 | const answerLabel = 1005 | exchange.type === 'initial' ? 'Initial Answer' : `Answer ${index}`; 1006 | 1007 | contextParts.push(`\n${questionLabel}: ${exchange.question}`); 1008 | contextParts.push(`${answerLabel}: ${exchange.answer}`); 1009 | }); 1010 | 1011 | return contextParts.join('\n'); 1012 | } 1013 | 1014 | export { performResearch }; 1015 | ``` -------------------------------------------------------------------------------- /scripts/modules/ai-services-unified.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * ai-services-unified.js 3 | * Centralized AI service layer using provider modules and config-manager. 4 | */ 5 | 6 | // Vercel AI SDK functions are NOT called directly anymore. 7 | // import { generateText, streamText, generateObject } from 'ai'; 8 | 9 | // --- Core Dependencies --- 10 | import { 11 | MODEL_MAP, 12 | getAzureBaseURL, 13 | getBaseUrlForRole, 14 | getBedrockBaseURL, 15 | getDebugFlag, 16 | getFallbackModelId, 17 | getFallbackProvider, 18 | getMainModelId, 19 | getMainProvider, 20 | getOllamaBaseURL, 21 | getParametersForRole, 22 | getResearchModelId, 23 | getResearchProvider, 24 | getResponseLanguage, 25 | getUserId, 26 | getVertexLocation, 27 | getVertexProjectId, 28 | isApiKeySet, 29 | providersWithoutApiKeys 30 | } from './config-manager.js'; 31 | import { 32 | findProjectRoot, 33 | getCurrentTag, 34 | log, 35 | resolveEnvVariable 36 | } from './utils.js'; 37 | 38 | // Import provider classes 39 | import { 40 | AnthropicAIProvider, 41 | AzureProvider, 42 | BedrockAIProvider, 43 | ClaudeCodeProvider, 44 | GeminiCliProvider, 45 | GoogleAIProvider, 46 | GrokCliProvider, 47 | GroqProvider, 48 | OllamaAIProvider, 49 | OpenAIProvider, 50 | OpenRouterAIProvider, 51 | PerplexityAIProvider, 52 | VertexAIProvider, 53 | XAIProvider 54 | } from '../../src/ai-providers/index.js'; 55 | 56 | // Import the provider registry 57 | import ProviderRegistry from '../../src/provider-registry/index.js'; 58 | 59 | // Create provider instances 60 | const PROVIDERS = { 61 | anthropic: new AnthropicAIProvider(), 62 | perplexity: new PerplexityAIProvider(), 63 | google: new GoogleAIProvider(), 64 | openai: new OpenAIProvider(), 65 | xai: new XAIProvider(), 66 | groq: new GroqProvider(), 67 | openrouter: new OpenRouterAIProvider(), 68 | ollama: new OllamaAIProvider(), 69 | bedrock: new BedrockAIProvider(), 70 | azure: new AzureProvider(), 71 | vertex: new VertexAIProvider(), 72 | 'claude-code': new ClaudeCodeProvider(), 73 | 'gemini-cli': new GeminiCliProvider(), 74 | 'grok-cli': new GrokCliProvider() 75 | }; 76 | 77 | function _getProvider(providerName) { 78 | // First check the static PROVIDERS object 79 | if (PROVIDERS[providerName]) { 80 | return PROVIDERS[providerName]; 81 | } 82 | 83 | // If not found, check the provider registry 84 | const providerRegistry = ProviderRegistry.getInstance(); 85 | if (providerRegistry.hasProvider(providerName)) { 86 | log('debug', `Provider "${providerName}" found in dynamic registry`); 87 | return providerRegistry.getProvider(providerName); 88 | } 89 | 90 | // Provider not found in either location 91 | return null; 92 | } 93 | 94 | // Helper function to get cost for a specific model 95 | function _getCostForModel(providerName, modelId) { 96 | const DEFAULT_COST = { inputCost: 0, outputCost: 0, currency: 'USD' }; 97 | 98 | if (!MODEL_MAP || !MODEL_MAP[providerName]) { 99 | log( 100 | 'warn', 101 | `Provider "${providerName}" not found in MODEL_MAP. Cannot determine cost for model ${modelId}.` 102 | ); 103 | return DEFAULT_COST; 104 | } 105 | 106 | const modelData = MODEL_MAP[providerName].find((m) => m.id === modelId); 107 | 108 | if (!modelData?.cost_per_1m_tokens) { 109 | log( 110 | 'debug', 111 | `Cost data not found for model "${modelId}" under provider "${providerName}". Assuming zero cost.` 112 | ); 113 | return DEFAULT_COST; 114 | } 115 | 116 | const costs = modelData.cost_per_1m_tokens; 117 | return { 118 | inputCost: costs.input || 0, 119 | outputCost: costs.output || 0, 120 | currency: costs.currency || 'USD' 121 | }; 122 | } 123 | 124 | /** 125 | * Calculate cost from token counts and cost per million 126 | * @param {number} inputTokens - Number of input tokens 127 | * @param {number} outputTokens - Number of output tokens 128 | * @param {number} inputCost - Cost per million input tokens 129 | * @param {number} outputCost - Cost per million output tokens 130 | * @returns {number} Total calculated cost 131 | */ 132 | function _calculateCost(inputTokens, outputTokens, inputCost, outputCost) { 133 | const calculatedCost = 134 | ((inputTokens || 0) / 1_000_000) * inputCost + 135 | ((outputTokens || 0) / 1_000_000) * outputCost; 136 | return parseFloat(calculatedCost.toFixed(6)); 137 | } 138 | 139 | // Helper function to get tag information for responses 140 | function _getTagInfo(projectRoot) { 141 | const DEFAULT_TAG_INFO = { currentTag: 'master', availableTags: ['master'] }; 142 | 143 | try { 144 | if (!projectRoot) { 145 | return DEFAULT_TAG_INFO; 146 | } 147 | 148 | const currentTag = getCurrentTag(projectRoot) || 'master'; 149 | const availableTags = _readAvailableTags(projectRoot); 150 | 151 | return { currentTag, availableTags }; 152 | } catch (error) { 153 | if (getDebugFlag()) { 154 | log('debug', `Error getting tag information: ${error.message}`); 155 | } 156 | return DEFAULT_TAG_INFO; 157 | } 158 | } 159 | 160 | // Extract method for reading available tags 161 | function _readAvailableTags(projectRoot) { 162 | const DEFAULT_TAGS = ['master']; 163 | 164 | try { 165 | const path = require('path'); 166 | const fs = require('fs'); 167 | const tasksPath = path.join( 168 | projectRoot, 169 | '.taskmaster', 170 | 'tasks', 171 | 'tasks.json' 172 | ); 173 | 174 | if (!fs.existsSync(tasksPath)) { 175 | return DEFAULT_TAGS; 176 | } 177 | 178 | const tasksData = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); 179 | if (!tasksData || typeof tasksData !== 'object') { 180 | return DEFAULT_TAGS; 181 | } 182 | 183 | // Check if it's tagged format (has tag-like keys with tasks arrays) 184 | const potentialTags = Object.keys(tasksData).filter((key) => 185 | _isValidTaggedTask(tasksData[key]) 186 | ); 187 | 188 | return potentialTags.length > 0 ? potentialTags : DEFAULT_TAGS; 189 | } catch (readError) { 190 | if (getDebugFlag()) { 191 | log( 192 | 'debug', 193 | `Could not read tasks file for available tags: ${readError.message}` 194 | ); 195 | } 196 | return DEFAULT_TAGS; 197 | } 198 | } 199 | 200 | // Helper to validate tagged task structure 201 | function _isValidTaggedTask(taskData) { 202 | return ( 203 | taskData && typeof taskData === 'object' && Array.isArray(taskData.tasks) 204 | ); 205 | } 206 | 207 | // --- Configuration for Retries --- 208 | const MAX_RETRIES = 2; 209 | const INITIAL_RETRY_DELAY_MS = 1000; 210 | 211 | // Helper function to check if an error is retryable 212 | function isRetryableError(error) { 213 | const errorMessage = error.message?.toLowerCase() || ''; 214 | return ( 215 | errorMessage.includes('rate limit') || 216 | errorMessage.includes('overloaded') || 217 | errorMessage.includes('service temporarily unavailable') || 218 | errorMessage.includes('timeout') || 219 | errorMessage.includes('network error') || 220 | error.status === 429 || 221 | error.status >= 500 222 | ); 223 | } 224 | 225 | /** 226 | * Extracts a user-friendly error message from a potentially complex AI error object. 227 | * Prioritizes nested messages and falls back to the top-level message. 228 | * @param {Error | object | any} error - The error object. 229 | * @returns {string} A concise error message. 230 | */ 231 | function _extractErrorMessage(error) { 232 | try { 233 | // Attempt 1: Look for Vercel SDK specific nested structure (common) 234 | if (error?.data?.error?.message) { 235 | return error.data.error.message; 236 | } 237 | 238 | // Attempt 2: Look for nested error message directly in the error object 239 | if (error?.error?.message) { 240 | return error.error.message; 241 | } 242 | 243 | // Attempt 3: Look for nested error message in response body if it's JSON string 244 | if (typeof error?.responseBody === 'string') { 245 | try { 246 | const body = JSON.parse(error.responseBody); 247 | if (body?.error?.message) { 248 | return body.error.message; 249 | } 250 | } catch (parseError) { 251 | // Ignore if responseBody is not valid JSON 252 | } 253 | } 254 | 255 | // Attempt 4: Use the top-level message if it exists 256 | if (typeof error?.message === 'string' && error.message) { 257 | return error.message; 258 | } 259 | 260 | // Attempt 5: Handle simple string errors 261 | if (typeof error === 'string') { 262 | return error; 263 | } 264 | 265 | // Fallback 266 | return 'An unknown AI service error occurred.'; 267 | } catch (e) { 268 | // Safety net 269 | return 'Failed to extract error message.'; 270 | } 271 | } 272 | 273 | /** 274 | * Get role configuration (provider and model) based on role type 275 | * @param {string} role - The role ('main', 'research', 'fallback') 276 | * @param {string} projectRoot - Project root path 277 | * @returns {Object|null} Configuration object with provider and modelId 278 | */ 279 | function _getRoleConfiguration(role, projectRoot) { 280 | const roleConfigs = { 281 | main: { 282 | provider: getMainProvider(projectRoot), 283 | modelId: getMainModelId(projectRoot) 284 | }, 285 | research: { 286 | provider: getResearchProvider(projectRoot), 287 | modelId: getResearchModelId(projectRoot) 288 | }, 289 | fallback: { 290 | provider: getFallbackProvider(projectRoot), 291 | modelId: getFallbackModelId(projectRoot) 292 | } 293 | }; 294 | 295 | return roleConfigs[role] || null; 296 | } 297 | 298 | /** 299 | * Get Vertex AI specific configuration 300 | * @param {string} projectRoot - Project root path 301 | * @param {Object} session - Session object 302 | * @returns {Object} Vertex AI configuration parameters 303 | */ 304 | function _getVertexConfiguration(projectRoot, session) { 305 | const projectId = 306 | getVertexProjectId(projectRoot) || 307 | resolveEnvVariable('VERTEX_PROJECT_ID', session, projectRoot); 308 | 309 | const location = 310 | getVertexLocation(projectRoot) || 311 | resolveEnvVariable('VERTEX_LOCATION', session, projectRoot) || 312 | 'us-central1'; 313 | 314 | const credentialsPath = resolveEnvVariable( 315 | 'GOOGLE_APPLICATION_CREDENTIALS', 316 | session, 317 | projectRoot 318 | ); 319 | 320 | log( 321 | 'debug', 322 | `Using Vertex AI configuration: Project ID=${projectId}, Location=${location}` 323 | ); 324 | 325 | return { 326 | projectId, 327 | location, 328 | ...(credentialsPath && { credentials: { credentialsFromEnv: true } }) 329 | }; 330 | } 331 | 332 | /** 333 | * Internal helper to resolve the API key for a given provider. 334 | * @param {string} providerName - The name of the provider (lowercase). 335 | * @param {object|null} session - Optional MCP session object. 336 | * @param {string|null} projectRoot - Optional project root path for .env fallback. 337 | * @returns {string|null} The API key or null if not found/needed. 338 | * @throws {Error} If a required API key is missing. 339 | */ 340 | function _resolveApiKey(providerName, session, projectRoot = null) { 341 | // Get provider instance 342 | const provider = _getProvider(providerName); 343 | if (!provider) { 344 | throw new Error( 345 | `Unknown provider '${providerName}' for API key resolution.` 346 | ); 347 | } 348 | 349 | // All providers must implement getRequiredApiKeyName() 350 | const envVarName = provider.getRequiredApiKeyName(); 351 | 352 | // If envVarName is null (like for MCP), return null directly 353 | if (envVarName === null) { 354 | return null; 355 | } 356 | 357 | const apiKey = resolveEnvVariable(envVarName, session, projectRoot); 358 | 359 | // Special handling for providers that can use alternative auth or no API key 360 | if (!provider.isRequiredApiKey()) { 361 | return apiKey || null; 362 | } 363 | 364 | if (!apiKey) { 365 | throw new Error( 366 | `Required API key ${envVarName} for provider '${providerName}' is not set in environment, session, or .env file.` 367 | ); 368 | } 369 | return apiKey; 370 | } 371 | 372 | /** 373 | * Internal helper to attempt a provider-specific AI API call with retries. 374 | * 375 | * @param {function} providerApiFn - The specific provider function to call (e.g., generateAnthropicText). 376 | * @param {object} callParams - Parameters object for the provider function. 377 | * @param {string} providerName - Name of the provider (for logging). 378 | * @param {string} modelId - Specific model ID (for logging). 379 | * @param {string} attemptRole - The role being attempted (for logging). 380 | * @returns {Promise<object>} The result from the successful API call. 381 | * @throws {Error} If the call fails after all retries. 382 | */ 383 | async function _attemptProviderCallWithRetries( 384 | provider, 385 | serviceType, 386 | callParams, 387 | providerName, 388 | modelId, 389 | attemptRole 390 | ) { 391 | let retries = 0; 392 | const fnName = serviceType; 393 | 394 | while (retries <= MAX_RETRIES) { 395 | try { 396 | if (getDebugFlag()) { 397 | log( 398 | 'info', 399 | `Attempt ${retries + 1}/${MAX_RETRIES + 1} calling ${fnName} (Provider: ${providerName}, Model: ${modelId}, Role: ${attemptRole})` 400 | ); 401 | } 402 | 403 | // Call the appropriate method on the provider instance 404 | const result = await provider[serviceType](callParams); 405 | 406 | if (getDebugFlag()) { 407 | log( 408 | 'info', 409 | `${fnName} succeeded for role ${attemptRole} (Provider: ${providerName}) on attempt ${retries + 1}` 410 | ); 411 | } 412 | return result; 413 | } catch (error) { 414 | log( 415 | 'warn', 416 | `Attempt ${retries + 1} failed for role ${attemptRole} (${fnName} / ${providerName}): ${error.message}` 417 | ); 418 | 419 | if (isRetryableError(error) && retries < MAX_RETRIES) { 420 | retries++; 421 | const delay = INITIAL_RETRY_DELAY_MS * 2 ** (retries - 1); 422 | log( 423 | 'info', 424 | `Something went wrong on the provider side. Retrying in ${delay / 1000}s...` 425 | ); 426 | await new Promise((resolve) => setTimeout(resolve, delay)); 427 | } else { 428 | log( 429 | 'error', 430 | `Something went wrong on the provider side. Max retries reached for role ${attemptRole} (${fnName} / ${providerName}).` 431 | ); 432 | throw error; 433 | } 434 | } 435 | } 436 | // Should not be reached due to throw in the else block 437 | throw new Error( 438 | `Exhausted all retries for role ${attemptRole} (${fnName} / ${providerName})` 439 | ); 440 | } 441 | 442 | /** 443 | * Base logic for unified service functions. 444 | * @param {string} serviceType - Type of service ('generateText', 'streamText', 'generateObject'). 445 | * @param {object} params - Original parameters passed to the service function. 446 | * @param {string} params.role - The initial client role. 447 | * @param {object} [params.session=null] - Optional MCP session object. 448 | * @param {string} [params.projectRoot] - Optional project root path. 449 | * @param {string} params.commandName - Name of the command invoking the service. 450 | * @param {string} params.outputType - 'cli' or 'mcp'. 451 | * @param {string} [params.systemPrompt] - Optional system prompt. 452 | * @param {string} [params.prompt] - The prompt for the AI. 453 | * @param {string} [params.schema] - The Zod schema for the expected object. 454 | * @param {string} [params.objectName] - Name for object/tool. 455 | * @returns {Promise<any>} Result from the underlying provider call. 456 | */ 457 | async function _unifiedServiceRunner(serviceType, params) { 458 | const { 459 | role: initialRole, 460 | session, 461 | projectRoot, 462 | systemPrompt, 463 | prompt, 464 | schema, 465 | objectName, 466 | commandName, 467 | outputType, 468 | ...restApiParams 469 | } = params; 470 | if (getDebugFlag()) { 471 | log('info', `${serviceType}Service called`, { 472 | role: initialRole, 473 | commandName, 474 | outputType, 475 | projectRoot 476 | }); 477 | } 478 | 479 | const effectiveProjectRoot = projectRoot || findProjectRoot(); 480 | const userId = getUserId(effectiveProjectRoot); 481 | 482 | let sequence; 483 | if (initialRole === 'main') { 484 | sequence = ['main', 'fallback', 'research']; 485 | } else if (initialRole === 'research') { 486 | sequence = ['research', 'fallback', 'main']; 487 | } else if (initialRole === 'fallback') { 488 | sequence = ['fallback', 'main', 'research']; 489 | } else { 490 | log( 491 | 'warn', 492 | `Unknown initial role: ${initialRole}. Defaulting to main -> fallback -> research sequence.` 493 | ); 494 | sequence = ['main', 'fallback', 'research']; 495 | } 496 | 497 | let lastError = null; 498 | let lastCleanErrorMessage = 499 | 'AI service call failed for all configured roles.'; 500 | 501 | for (const currentRole of sequence) { 502 | let providerName; 503 | let modelId; 504 | let apiKey; 505 | let roleParams; 506 | let provider; 507 | let baseURL; 508 | let providerResponse; 509 | let telemetryData = null; 510 | 511 | try { 512 | log('debug', `New AI service call with role: ${currentRole}`); 513 | 514 | const roleConfig = _getRoleConfiguration( 515 | currentRole, 516 | effectiveProjectRoot 517 | ); 518 | if (!roleConfig) { 519 | log( 520 | 'error', 521 | `Unknown role encountered in _unifiedServiceRunner: ${currentRole}` 522 | ); 523 | lastError = 524 | lastError || new Error(`Unknown AI role specified: ${currentRole}`); 525 | continue; 526 | } 527 | providerName = roleConfig.provider; 528 | modelId = roleConfig.modelId; 529 | 530 | if (!providerName || !modelId) { 531 | log( 532 | 'warn', 533 | `Skipping role '${currentRole}': Provider or Model ID not configured.` 534 | ); 535 | lastError = 536 | lastError || 537 | new Error( 538 | `Configuration missing for role '${currentRole}'. Provider: ${providerName}, Model: ${modelId}` 539 | ); 540 | continue; 541 | } 542 | 543 | // Get provider instance 544 | provider = _getProvider(providerName?.toLowerCase()); 545 | if (!provider) { 546 | log( 547 | 'warn', 548 | `Skipping role '${currentRole}': Provider '${providerName}' not supported.` 549 | ); 550 | lastError = 551 | lastError || 552 | new Error(`Unsupported provider configured: ${providerName}`); 553 | continue; 554 | } 555 | 556 | // Check API key if needed 557 | if (!providersWithoutApiKeys.includes(providerName?.toLowerCase())) { 558 | if (!isApiKeySet(providerName, session, effectiveProjectRoot)) { 559 | log( 560 | 'warn', 561 | `Skipping role '${currentRole}' (Provider: ${providerName}): API key not set or invalid.` 562 | ); 563 | lastError = 564 | lastError || 565 | new Error( 566 | `API key for provider '${providerName}' (role: ${currentRole}) is not set.` 567 | ); 568 | continue; // Skip to the next role in the sequence 569 | } 570 | } 571 | 572 | // Get base URL if configured (optional for most providers) 573 | baseURL = getBaseUrlForRole(currentRole, effectiveProjectRoot); 574 | 575 | // For Azure, use the global Azure base URL if role-specific URL is not configured 576 | if (providerName?.toLowerCase() === 'azure' && !baseURL) { 577 | baseURL = getAzureBaseURL(effectiveProjectRoot); 578 | log('debug', `Using global Azure base URL: ${baseURL}`); 579 | } else if (providerName?.toLowerCase() === 'ollama' && !baseURL) { 580 | // For Ollama, use the global Ollama base URL if role-specific URL is not configured 581 | baseURL = getOllamaBaseURL(effectiveProjectRoot); 582 | log('debug', `Using global Ollama base URL: ${baseURL}`); 583 | } else if (providerName?.toLowerCase() === 'bedrock' && !baseURL) { 584 | // For Bedrock, use the global Bedrock base URL if role-specific URL is not configured 585 | baseURL = getBedrockBaseURL(effectiveProjectRoot); 586 | log('debug', `Using global Bedrock base URL: ${baseURL}`); 587 | } 588 | 589 | // Get AI parameters for the current role 590 | roleParams = getParametersForRole(currentRole, effectiveProjectRoot); 591 | apiKey = _resolveApiKey( 592 | providerName?.toLowerCase(), 593 | session, 594 | effectiveProjectRoot 595 | ); 596 | 597 | // Prepare provider-specific configuration 598 | let providerSpecificParams = {}; 599 | 600 | // Handle Vertex AI specific configuration 601 | if (providerName?.toLowerCase() === 'vertex') { 602 | providerSpecificParams = _getVertexConfiguration( 603 | effectiveProjectRoot, 604 | session 605 | ); 606 | } 607 | 608 | const messages = []; 609 | const responseLanguage = getResponseLanguage(effectiveProjectRoot); 610 | const systemPromptWithLanguage = `${systemPrompt} \n\n Always respond in ${responseLanguage}.`; 611 | messages.push({ 612 | role: 'system', 613 | content: systemPromptWithLanguage.trim() 614 | }); 615 | 616 | // IN THE FUTURE WHEN DOING CONTEXT IMPROVEMENTS 617 | // { 618 | // type: 'text', 619 | // text: 'Large cached context here like a tasks json', 620 | // providerOptions: { 621 | // anthropic: { cacheControl: { type: 'ephemeral' } } 622 | // } 623 | // } 624 | 625 | // Example 626 | // if (params.context) { // context is a json string of a tasks object or some other stu 627 | // messages.push({ 628 | // type: 'text', 629 | // text: params.context, 630 | // providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } } 631 | // }); 632 | // } 633 | 634 | if (prompt) { 635 | messages.push({ role: 'user', content: prompt }); 636 | } else { 637 | throw new Error('User prompt content is missing.'); 638 | } 639 | 640 | const callParams = { 641 | apiKey, 642 | modelId, 643 | maxTokens: roleParams.maxTokens, 644 | temperature: roleParams.temperature, 645 | messages, 646 | ...(baseURL && { baseURL }), 647 | ...((serviceType === 'generateObject' || 648 | serviceType === 'streamObject') && { schema, objectName }), 649 | ...providerSpecificParams, 650 | ...restApiParams 651 | }; 652 | 653 | providerResponse = await _attemptProviderCallWithRetries( 654 | provider, 655 | serviceType, 656 | callParams, 657 | providerName, 658 | modelId, 659 | currentRole 660 | ); 661 | 662 | if (userId && providerResponse && providerResponse.usage) { 663 | try { 664 | telemetryData = await logAiUsage({ 665 | userId, 666 | commandName, 667 | providerName, 668 | modelId, 669 | inputTokens: providerResponse.usage.inputTokens, 670 | outputTokens: providerResponse.usage.outputTokens, 671 | outputType 672 | }); 673 | } catch (telemetryError) { 674 | // logAiUsage already logs its own errors and returns null on failure 675 | // No need to log again here, telemetryData will remain null 676 | } 677 | } else if (userId && providerResponse && !providerResponse.usage) { 678 | log( 679 | 'warn', 680 | `Cannot log telemetry for ${commandName} (${providerName}/${modelId}): AI result missing 'usage' data. (May be expected for streams)` 681 | ); 682 | } 683 | 684 | let finalMainResult; 685 | if (serviceType === 'generateText') { 686 | finalMainResult = providerResponse.text; 687 | } else if (serviceType === 'generateObject') { 688 | finalMainResult = providerResponse.object; 689 | } else if ( 690 | serviceType === 'streamText' || 691 | serviceType === 'streamObject' 692 | ) { 693 | finalMainResult = providerResponse; 694 | } else { 695 | log( 696 | 'error', 697 | `Unknown serviceType in _unifiedServiceRunner: ${serviceType}` 698 | ); 699 | finalMainResult = providerResponse; 700 | } 701 | 702 | // Get tag information for the response 703 | const tagInfo = _getTagInfo(effectiveProjectRoot); 704 | 705 | return { 706 | mainResult: finalMainResult, 707 | telemetryData: telemetryData, 708 | tagInfo: tagInfo, 709 | providerName: providerName, 710 | modelId: modelId 711 | }; 712 | } catch (error) { 713 | const cleanMessage = _extractErrorMessage(error); 714 | log( 715 | 'error', 716 | `Service call failed for role ${currentRole} (Provider: ${providerName || 'unknown'}, Model: ${modelId || 'unknown'}): ${cleanMessage}` 717 | ); 718 | lastError = error; 719 | lastCleanErrorMessage = cleanMessage; 720 | 721 | if (serviceType === 'generateObject') { 722 | const lowerCaseMessage = cleanMessage.toLowerCase(); 723 | if ( 724 | lowerCaseMessage.includes( 725 | 'no endpoints found that support tool use' 726 | ) || 727 | lowerCaseMessage.includes('does not support tool_use') || 728 | lowerCaseMessage.includes('tool use is not supported') || 729 | lowerCaseMessage.includes('tools are not supported') || 730 | lowerCaseMessage.includes('function calling is not supported') || 731 | lowerCaseMessage.includes('tool use is not supported') 732 | ) { 733 | const specificErrorMsg = `Model '${modelId || 'unknown'}' via provider '${providerName || 'unknown'}' does not support the 'tool use' required by generateObjectService. Please configure a model that supports tool/function calling for the '${currentRole}' role, or use generateTextService if structured output is not strictly required.`; 734 | log('error', `[Tool Support Error] ${specificErrorMsg}`); 735 | throw new Error(specificErrorMsg); 736 | } 737 | } 738 | } 739 | } 740 | 741 | log('error', `All roles in the sequence [${sequence.join(', ')}] failed.`); 742 | throw new Error(lastCleanErrorMessage); 743 | } 744 | 745 | /** 746 | * Unified service function for generating text. 747 | * Handles client retrieval, retries, and fallback sequence. 748 | * 749 | * @param {object} params - Parameters for the service call. 750 | * @param {string} params.role - The initial client role ('main', 'research', 'fallback'). 751 | * @param {object} [params.session=null] - Optional MCP session object. 752 | * @param {string} [params.projectRoot=null] - Optional project root path for .env fallback. 753 | * @param {string} params.prompt - The prompt for the AI. 754 | * @param {string} [params.systemPrompt] - Optional system prompt. 755 | * @param {string} params.commandName - Name of the command invoking the service. 756 | * @param {string} [params.outputType='cli'] - 'cli' or 'mcp'. 757 | * @returns {Promise<object>} Result object containing generated text and usage data. 758 | */ 759 | async function generateTextService(params) { 760 | // Ensure default outputType if not provided 761 | const defaults = { outputType: 'cli' }; 762 | const combinedParams = { ...defaults, ...params }; 763 | // TODO: Validate commandName exists? 764 | return _unifiedServiceRunner('generateText', combinedParams); 765 | } 766 | 767 | /** 768 | * Unified service function for streaming text. 769 | * Handles client retrieval, retries, and fallback sequence. 770 | * 771 | * @param {object} params - Parameters for the service call. 772 | * @param {string} params.role - The initial client role ('main', 'research', 'fallback'). 773 | * @param {object} [params.session=null] - Optional MCP session object. 774 | * @param {string} [params.projectRoot=null] - Optional project root path for .env fallback. 775 | * @param {string} params.prompt - The prompt for the AI. 776 | * @param {string} [params.systemPrompt] - Optional system prompt. 777 | * @param {string} params.commandName - Name of the command invoking the service. 778 | * @param {string} [params.outputType='cli'] - 'cli' or 'mcp'. 779 | * @returns {Promise<object>} Result object containing the stream and usage data. 780 | */ 781 | async function streamTextService(params) { 782 | const defaults = { outputType: 'cli' }; 783 | const combinedParams = { ...defaults, ...params }; 784 | // TODO: Validate commandName exists? 785 | // NOTE: Telemetry for streaming might be tricky as usage data often comes at the end. 786 | // The current implementation logs *after* the stream is returned. 787 | // We might need to adjust how usage is captured/logged for streams. 788 | return _unifiedServiceRunner('streamText', combinedParams); 789 | } 790 | 791 | /** 792 | * Unified service function for streaming structured objects. 793 | * Uses Vercel AI SDK's streamObject for proper JSON streaming. 794 | * 795 | * @param {object} params - Parameters for the service call. 796 | * @param {string} params.role - The initial client role ('main', 'research', 'fallback'). 797 | * @param {object} [params.session=null] - Optional MCP session object. 798 | * @param {string} [params.projectRoot=null] - Optional project root path for .env fallback. 799 | * @param {import('zod').ZodSchema} params.schema - The Zod schema for the expected object. 800 | * @param {string} params.prompt - The prompt for the AI. 801 | * @param {string} [params.systemPrompt] - Optional system prompt. 802 | * @param {string} params.commandName - Name of the command invoking the service. 803 | * @param {string} [params.outputType='cli'] - 'cli' or 'mcp'. 804 | * @returns {Promise<object>} Result object containing the stream and usage data. 805 | */ 806 | async function streamObjectService(params) { 807 | const defaults = { outputType: 'cli' }; 808 | const combinedParams = { ...defaults, ...params }; 809 | // Stream object requires a schema 810 | if (!combinedParams.schema) { 811 | throw new Error('streamObjectService requires a schema parameter'); 812 | } 813 | return _unifiedServiceRunner('streamObject', combinedParams); 814 | } 815 | 816 | /** 817 | * Unified service function for generating structured objects. 818 | * Handles client retrieval, retries, and fallback sequence. 819 | * 820 | * @param {object} params - Parameters for the service call. 821 | * @param {string} params.role - The initial client role ('main', 'research', 'fallback'). 822 | * @param {object} [params.session=null] - Optional MCP session object. 823 | * @param {string} [params.projectRoot=null] - Optional project root path for .env fallback. 824 | * @param {import('zod').ZodSchema} params.schema - The Zod schema for the expected object. 825 | * @param {string} params.prompt - The prompt for the AI. 826 | * @param {string} [params.systemPrompt] - Optional system prompt. 827 | * @param {string} [params.objectName='generated_object'] - Name for object/tool. 828 | * @param {number} [params.maxRetries=3] - Max retries for object generation. 829 | * @param {string} params.commandName - Name of the command invoking the service. 830 | * @param {string} [params.outputType='cli'] - 'cli' or 'mcp'. 831 | * @returns {Promise<object>} Result object containing the generated object and usage data. 832 | */ 833 | async function generateObjectService(params) { 834 | const defaults = { 835 | objectName: 'generated_object', 836 | maxRetries: 3, 837 | outputType: 'cli' 838 | }; 839 | const combinedParams = { ...defaults, ...params }; 840 | // TODO: Validate commandName exists? 841 | return _unifiedServiceRunner('generateObject', combinedParams); 842 | } 843 | 844 | // --- Telemetry Function --- 845 | /** 846 | * Logs AI usage telemetry data. 847 | * For now, it just logs to the console. Sending will be implemented later. 848 | * @param {object} params - Telemetry parameters. 849 | * @param {string} params.userId - Unique user identifier. 850 | * @param {string} params.commandName - The command that triggered the AI call. 851 | * @param {string} params.providerName - The AI provider used (e.g., 'openai'). 852 | * @param {string} params.modelId - The specific AI model ID used. 853 | * @param {number} params.inputTokens - Number of input tokens. 854 | * @param {number} params.outputTokens - Number of output tokens. 855 | */ 856 | async function logAiUsage({ 857 | userId, 858 | commandName, 859 | providerName, 860 | modelId, 861 | inputTokens, 862 | outputTokens, 863 | outputType 864 | }) { 865 | try { 866 | const isMCP = outputType === 'mcp'; 867 | const timestamp = new Date().toISOString(); 868 | const totalTokens = (inputTokens || 0) + (outputTokens || 0); 869 | 870 | // Destructure currency along with costs 871 | const { inputCost, outputCost, currency } = _getCostForModel( 872 | providerName, 873 | modelId 874 | ); 875 | 876 | const totalCost = _calculateCost( 877 | inputTokens, 878 | outputTokens, 879 | inputCost, 880 | outputCost 881 | ); 882 | 883 | const telemetryData = { 884 | timestamp, 885 | userId, 886 | commandName, 887 | modelUsed: modelId, // Consistent field name from requirements 888 | providerName, // Keep provider name for context 889 | inputTokens: inputTokens || 0, 890 | outputTokens: outputTokens || 0, 891 | totalTokens, 892 | totalCost, 893 | currency // Add currency to the telemetry data 894 | }; 895 | 896 | if (getDebugFlag()) { 897 | log('info', 'AI Usage Telemetry:', telemetryData); 898 | } 899 | 900 | // TODO (Subtask 77.2): Send telemetryData securely to the external endpoint. 901 | 902 | return telemetryData; 903 | } catch (error) { 904 | log('error', `Failed to log AI usage telemetry: ${error.message}`, { 905 | error 906 | }); 907 | // Don't re-throw; telemetry failure shouldn't block core functionality. 908 | return null; 909 | } 910 | } 911 | 912 | export { 913 | generateTextService, 914 | streamTextService, 915 | streamObjectService, 916 | generateObjectService, 917 | logAiUsage 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 the dependencies 11 | jest.unstable_mockModule('../../../../../src/utils/path-utils.js', () => ({ 12 | resolveComplexityReportOutputPath: jest.fn(), 13 | findComplexityReportPath: jest.fn(), 14 | findConfigPath: jest.fn(), 15 | findPRDPath: jest.fn(() => '/mock/project/root/.taskmaster/docs/PRD.md'), 16 | findTasksPath: jest.fn( 17 | () => '/mock/project/root/.taskmaster/tasks/tasks.json' 18 | ), 19 | findProjectRoot: jest.fn(() => '/mock/project/root'), 20 | normalizeProjectRoot: jest.fn((root) => root) 21 | })); 22 | 23 | jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ 24 | readJSON: jest.fn(), 25 | writeJSON: jest.fn(), 26 | log: jest.fn(), 27 | isSilentMode: jest.fn(() => false), 28 | enableSilentMode: jest.fn(), 29 | disableSilentMode: jest.fn(), 30 | flattenTasksWithSubtasks: jest.fn((tasks) => tasks), 31 | getTagAwareFilePath: jest.fn((basePath, tag, projectRoot) => { 32 | if (tag && tag !== 'master') { 33 | const dir = path.dirname(basePath); 34 | const ext = path.extname(basePath); 35 | const name = path.basename(basePath, ext); 36 | return path.join(projectRoot || '.', dir, `${name}_${tag}${ext}`); 37 | } 38 | return path.join(projectRoot || '.', basePath); 39 | }), 40 | findTaskById: jest.fn((tasks, taskId) => { 41 | if (!tasks || !Array.isArray(tasks)) { 42 | return { task: null, originalSubtaskCount: null, originalSubtasks: null }; 43 | } 44 | const id = parseInt(taskId, 10); 45 | const task = tasks.find((t) => t.id === id); 46 | return task 47 | ? { task, originalSubtaskCount: null, originalSubtasks: null } 48 | : { task: null, originalSubtaskCount: null, originalSubtasks: null }; 49 | }), 50 | taskExists: jest.fn((tasks, taskId) => { 51 | if (!tasks || !Array.isArray(tasks)) return false; 52 | const id = parseInt(taskId, 10); 53 | return tasks.some((t) => t.id === id); 54 | }), 55 | formatTaskId: jest.fn((id) => `Task ${id}`), 56 | findCycles: jest.fn(() => []), 57 | truncate: jest.fn((text) => text), 58 | addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })), 59 | aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {}), 60 | ensureTagMetadata: jest.fn((tagObj) => tagObj), 61 | getCurrentTag: jest.fn(() => 'master'), 62 | markMigrationForNotice: jest.fn(), 63 | performCompleteTagMigration: jest.fn(), 64 | setTasksForTag: jest.fn(), 65 | getTasksForTag: jest.fn((data, tag) => data[tag]?.tasks || []), 66 | findProjectRoot: jest.fn(() => '/mock/project/root'), 67 | readComplexityReport: jest.fn(), 68 | findTaskInComplexityReport: jest.fn(), 69 | resolveEnvVariable: jest.fn((varName) => `mock_${varName}`), 70 | isEmpty: jest.fn(() => false), 71 | normalizeProjectRoot: jest.fn((root) => root), 72 | slugifyTagForFilePath: jest.fn((tagName) => { 73 | if (!tagName || typeof tagName !== 'string') { 74 | return 'unknown-tag'; 75 | } 76 | return tagName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); 77 | }), 78 | createTagAwareFilePath: jest.fn((basePath, tag, projectRoot) => { 79 | if (tag && tag !== 'master') { 80 | const dir = path.dirname(basePath); 81 | const ext = path.extname(basePath); 82 | const name = path.basename(basePath, ext); 83 | // Use the slugified tag 84 | const slugifiedTag = tag.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); 85 | return path.join( 86 | projectRoot || '.', 87 | dir, 88 | `${name}_${slugifiedTag}${ext}` 89 | ); 90 | } 91 | return path.join(projectRoot || '.', basePath); 92 | }), 93 | traverseDependencies: jest.fn((sourceTasks, allTasks, options = {}) => []), 94 | CONFIG: { 95 | defaultSubtasks: 3 96 | } 97 | })); 98 | 99 | jest.unstable_mockModule( 100 | '../../../../../scripts/modules/ai-services-unified.js', 101 | () => ({ 102 | generateTextService: jest.fn().mockImplementation((params) => { 103 | const commandName = params?.commandName || 'default'; 104 | 105 | if (commandName === 'analyze-complexity') { 106 | // Check if this is for a specific tag test by looking at the prompt 107 | const isFeatureTag = 108 | params?.prompt?.includes('feature') || params?.role === 'feature'; 109 | const isMasterTag = 110 | params?.prompt?.includes('master') || params?.role === 'master'; 111 | 112 | let taskTitle = 'Test Task'; 113 | if (isFeatureTag) { 114 | taskTitle = 'Feature Task 1'; 115 | } else if (isMasterTag) { 116 | taskTitle = 'Master Task 1'; 117 | } 118 | 119 | return Promise.resolve({ 120 | mainResult: JSON.stringify([ 121 | { 122 | taskId: 1, 123 | taskTitle: taskTitle, 124 | complexityScore: 7, 125 | recommendedSubtasks: 4, 126 | expansionPrompt: 'Break down this task', 127 | reasoning: 'This task is moderately complex' 128 | }, 129 | { 130 | taskId: 2, 131 | taskTitle: 'Task 2', 132 | complexityScore: 5, 133 | recommendedSubtasks: 3, 134 | expansionPrompt: 'Break down this task with a focus on task 2.', 135 | reasoning: 136 | 'Automatically added due to missing analysis in AI response.' 137 | } 138 | ]), 139 | telemetryData: { 140 | timestamp: new Date().toISOString(), 141 | commandName: 'analyze-complexity', 142 | modelUsed: 'claude-3-5-sonnet', 143 | providerName: 'anthropic', 144 | inputTokens: 1000, 145 | outputTokens: 500, 146 | totalTokens: 1500, 147 | totalCost: 0.012414, 148 | currency: 'USD' 149 | } 150 | }); 151 | } else { 152 | // Default for expand-task and others 153 | return Promise.resolve({ 154 | mainResult: JSON.stringify({ 155 | subtasks: [ 156 | { 157 | id: 1, 158 | title: 'Subtask 1', 159 | description: 'First subtask', 160 | dependencies: [], 161 | details: 'Implementation details', 162 | status: 'pending', 163 | testStrategy: 'Test strategy' 164 | } 165 | ] 166 | }), 167 | telemetryData: { 168 | timestamp: new Date().toISOString(), 169 | commandName: commandName || 'expand-task', 170 | modelUsed: 'claude-3-5-sonnet', 171 | providerName: 'anthropic', 172 | inputTokens: 1000, 173 | outputTokens: 500, 174 | totalTokens: 1500, 175 | totalCost: 0.012414, 176 | currency: 'USD' 177 | } 178 | }); 179 | } 180 | }), 181 | streamTextService: jest.fn().mockResolvedValue({ 182 | mainResult: async function* () { 183 | yield '{"tasks":['; 184 | yield '{"id":1,"title":"Test Task","priority":"high"}'; 185 | yield ']}'; 186 | }, 187 | telemetryData: { 188 | timestamp: new Date().toISOString(), 189 | commandName: 'analyze-complexity', 190 | modelUsed: 'claude-3-5-sonnet', 191 | providerName: 'anthropic', 192 | inputTokens: 1000, 193 | outputTokens: 500, 194 | totalTokens: 1500, 195 | totalCost: 0.012414, 196 | currency: 'USD' 197 | } 198 | }), 199 | generateObjectService: jest.fn().mockResolvedValue({ 200 | mainResult: { 201 | object: { 202 | subtasks: [ 203 | { 204 | id: 1, 205 | title: 'Subtask 1', 206 | description: 'First subtask', 207 | dependencies: [], 208 | details: 'Implementation details', 209 | status: 'pending', 210 | testStrategy: 'Test strategy' 211 | } 212 | ] 213 | } 214 | }, 215 | telemetryData: { 216 | timestamp: new Date().toISOString(), 217 | commandName: 'expand-task', 218 | modelUsed: 'claude-3-5-sonnet', 219 | providerName: 'anthropic', 220 | inputTokens: 1000, 221 | outputTokens: 500, 222 | totalTokens: 1500, 223 | totalCost: 0.012414, 224 | currency: 'USD' 225 | } 226 | }) 227 | }) 228 | ); 229 | 230 | jest.unstable_mockModule( 231 | '../../../../../scripts/modules/config-manager.js', 232 | () => ({ 233 | // Core config access 234 | getConfig: jest.fn(() => ({ 235 | models: { main: { provider: 'anthropic', modelId: 'claude-3-5-sonnet' } }, 236 | global: { projectName: 'Test Project' } 237 | })), 238 | writeConfig: jest.fn(() => true), 239 | ConfigurationError: class extends Error {}, 240 | isConfigFilePresent: jest.fn(() => true), 241 | 242 | // Validation 243 | validateProvider: jest.fn(() => true), 244 | validateProviderModelCombination: jest.fn(() => true), 245 | VALIDATED_PROVIDERS: ['anthropic', 'openai', 'perplexity'], 246 | CUSTOM_PROVIDERS: { OLLAMA: 'ollama', BEDROCK: 'bedrock' }, 247 | ALL_PROVIDERS: ['anthropic', 'openai', 'perplexity', 'ollama', 'bedrock'], 248 | MODEL_MAP: { 249 | anthropic: [ 250 | { 251 | id: 'claude-3-5-sonnet', 252 | cost_per_1m_tokens: { input: 3, output: 15 } 253 | } 254 | ], 255 | openai: [{ id: 'gpt-4', cost_per_1m_tokens: { input: 30, output: 60 } }] 256 | }, 257 | getAvailableModels: jest.fn(() => [ 258 | { 259 | id: 'claude-3-5-sonnet', 260 | name: 'Claude 3.5 Sonnet', 261 | provider: 'anthropic' 262 | }, 263 | { id: 'gpt-4', name: 'GPT-4', provider: 'openai' } 264 | ]), 265 | 266 | // Role-specific getters 267 | getMainProvider: jest.fn(() => 'anthropic'), 268 | getMainModelId: jest.fn(() => 'claude-3-5-sonnet'), 269 | getMainMaxTokens: jest.fn(() => 4000), 270 | getMainTemperature: jest.fn(() => 0.7), 271 | getResearchProvider: jest.fn(() => 'perplexity'), 272 | getResearchModelId: jest.fn(() => 'sonar-pro'), 273 | getResearchMaxTokens: jest.fn(() => 8700), 274 | getResearchTemperature: jest.fn(() => 0.1), 275 | getFallbackProvider: jest.fn(() => 'anthropic'), 276 | getFallbackModelId: jest.fn(() => 'claude-3-5-sonnet'), 277 | getFallbackMaxTokens: jest.fn(() => 4000), 278 | getFallbackTemperature: jest.fn(() => 0.7), 279 | getBaseUrlForRole: jest.fn(() => undefined), 280 | 281 | // Global setting getters 282 | getLogLevel: jest.fn(() => 'info'), 283 | getDebugFlag: jest.fn(() => false), 284 | getDefaultNumTasks: jest.fn(() => 10), 285 | getDefaultSubtasks: jest.fn(() => 5), 286 | getDefaultPriority: jest.fn(() => 'medium'), 287 | getProjectName: jest.fn(() => 'Test Project'), 288 | getOllamaBaseURL: jest.fn(() => 'http://localhost:11434/api'), 289 | getAzureBaseURL: jest.fn(() => undefined), 290 | getBedrockBaseURL: jest.fn(() => undefined), 291 | getParametersForRole: jest.fn(() => ({ 292 | maxTokens: 4000, 293 | temperature: 0.7 294 | })), 295 | getUserId: jest.fn(() => '1234567890'), 296 | 297 | // API Key Checkers 298 | isApiKeySet: jest.fn(() => true), 299 | getMcpApiKeyStatus: jest.fn(() => true), 300 | 301 | // Additional functions 302 | getAllProviders: jest.fn(() => ['anthropic', 'openai', 'perplexity']), 303 | getVertexProjectId: jest.fn(() => undefined), 304 | getVertexLocation: jest.fn(() => undefined), 305 | hasCodebaseAnalysis: jest.fn(() => false) 306 | }) 307 | ); 308 | 309 | jest.unstable_mockModule( 310 | '../../../../../scripts/modules/prompt-manager.js', 311 | () => ({ 312 | getPromptManager: jest.fn().mockReturnValue({ 313 | loadPrompt: jest.fn().mockResolvedValue({ 314 | systemPrompt: 'Mocked system prompt', 315 | userPrompt: 'Mocked user prompt' 316 | }) 317 | }) 318 | }) 319 | ); 320 | 321 | jest.unstable_mockModule( 322 | '../../../../../scripts/modules/utils/contextGatherer.js', 323 | () => { 324 | class MockContextGatherer { 325 | constructor(projectRoot, tag) { 326 | this.projectRoot = projectRoot; 327 | this.tag = tag; 328 | this.allTasks = []; 329 | } 330 | 331 | async gather(options = {}) { 332 | return { 333 | context: 'Mock context gathered', 334 | analysisData: null, 335 | contextSections: 1, 336 | finalTaskIds: options.tasks || [] 337 | }; 338 | } 339 | } 340 | 341 | return { 342 | default: MockContextGatherer, 343 | ContextGatherer: MockContextGatherer, 344 | createContextGatherer: jest.fn( 345 | (projectRoot, tag) => new MockContextGatherer(projectRoot, tag) 346 | ) 347 | }; 348 | } 349 | ); 350 | 351 | jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ 352 | startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), 353 | stopLoadingIndicator: jest.fn(), 354 | displayAiUsageSummary: jest.fn(), 355 | displayBanner: jest.fn(), 356 | getStatusWithColor: jest.fn((status) => status), 357 | succeedLoadingIndicator: jest.fn(), 358 | failLoadingIndicator: jest.fn(), 359 | warnLoadingIndicator: jest.fn(), 360 | infoLoadingIndicator: jest.fn(), 361 | displayContextAnalysis: jest.fn(), 362 | createProgressBar: jest.fn(() => ({ 363 | start: jest.fn(), 364 | stop: jest.fn(), 365 | update: jest.fn() 366 | })), 367 | displayTable: jest.fn(), 368 | displayBox: jest.fn(), 369 | displaySuccess: jest.fn(), 370 | displayError: jest.fn(), 371 | displayWarning: jest.fn(), 372 | displayInfo: jest.fn(), 373 | displayTaskDetails: jest.fn(), 374 | displayTaskList: jest.fn(), 375 | displayComplexityReport: jest.fn(), 376 | displayNextTask: jest.fn(), 377 | displayDependencyStatus: jest.fn(), 378 | displayMigrationNotice: jest.fn(), 379 | formatDependenciesWithStatus: jest.fn((deps) => deps), 380 | formatTaskId: jest.fn((id) => `Task ${id}`), 381 | formatPriority: jest.fn((priority) => priority), 382 | formatDuration: jest.fn((duration) => duration), 383 | formatDate: jest.fn((date) => date), 384 | formatComplexityScore: jest.fn((score) => score), 385 | formatTelemetryData: jest.fn((data) => data), 386 | formatContextSummary: jest.fn((context) => context), 387 | formatTagName: jest.fn((tag) => tag), 388 | formatFilePath: jest.fn((path) => path), 389 | getComplexityWithColor: jest.fn((complexity) => complexity), 390 | getPriorityWithColor: jest.fn((priority) => priority), 391 | getTagWithColor: jest.fn((tag) => tag), 392 | getDependencyWithColor: jest.fn((dep) => dep), 393 | getTelemetryWithColor: jest.fn((data) => data), 394 | getContextWithColor: jest.fn((context) => context) 395 | })); 396 | 397 | // Mock fs module 398 | const mockWriteFileSync = jest.fn(); 399 | const mockExistsSync = jest.fn(); 400 | const mockReadFileSync = jest.fn(); 401 | const mockMkdirSync = jest.fn(); 402 | 403 | jest.unstable_mockModule('fs', () => ({ 404 | default: { 405 | existsSync: mockExistsSync, 406 | readFileSync: mockReadFileSync, 407 | writeFileSync: mockWriteFileSync, 408 | mkdirSync: mockMkdirSync 409 | }, 410 | existsSync: mockExistsSync, 411 | readFileSync: mockReadFileSync, 412 | writeFileSync: mockWriteFileSync, 413 | mkdirSync: mockMkdirSync 414 | })); 415 | 416 | // Import the mocked modules 417 | const { resolveComplexityReportOutputPath, findComplexityReportPath } = 418 | await import('../../../../../src/utils/path-utils.js'); 419 | 420 | const { readJSON, writeJSON, getTagAwareFilePath } = await import( 421 | '../../../../../scripts/modules/utils.js' 422 | ); 423 | 424 | const { generateTextService, streamTextService } = await import( 425 | '../../../../../scripts/modules/ai-services-unified.js' 426 | ); 427 | 428 | // Import the modules under test 429 | const { default: analyzeTaskComplexity } = await import( 430 | '../../../../../scripts/modules/task-manager/analyze-task-complexity.js' 431 | ); 432 | 433 | const { default: expandTask } = await import( 434 | '../../../../../scripts/modules/task-manager/expand-task.js' 435 | ); 436 | 437 | describe('Complexity Report Tag Isolation', () => { 438 | const projectRoot = '/mock/project/root'; 439 | const sampleTasks = { 440 | tasks: [ 441 | { 442 | id: 1, 443 | title: 'Task 1', 444 | description: 'First task', 445 | status: 'pending' 446 | }, 447 | { 448 | id: 2, 449 | title: 'Task 2', 450 | description: 'Second task', 451 | status: 'pending' 452 | } 453 | ] 454 | }; 455 | 456 | const sampleComplexityReport = { 457 | meta: { 458 | generatedAt: new Date().toISOString(), 459 | tasksAnalyzed: 2, 460 | totalTasks: 2, 461 | analysisCount: 2, 462 | thresholdScore: 5, 463 | projectName: 'Test Project', 464 | usedResearch: false 465 | }, 466 | complexityAnalysis: [ 467 | { 468 | taskId: 1, 469 | taskTitle: 'Task 1', 470 | complexityScore: 7, 471 | recommendedSubtasks: 4, 472 | expansionPrompt: 'Break down this task', 473 | reasoning: 'This task is moderately complex' 474 | }, 475 | { 476 | taskId: 2, 477 | taskTitle: 'Task 2', 478 | complexityScore: 5, 479 | recommendedSubtasks: 3, 480 | expansionPrompt: 'Break down this task', 481 | reasoning: 'This task is moderately complex' 482 | } 483 | ] 484 | }; 485 | 486 | beforeEach(() => { 487 | jest.clearAllMocks(); 488 | 489 | // Default mock implementations 490 | readJSON.mockReturnValue(sampleTasks); 491 | mockExistsSync.mockReturnValue(false); 492 | mockMkdirSync.mockImplementation(() => {}); 493 | 494 | // Mock resolveComplexityReportOutputPath to return tag-aware paths 495 | resolveComplexityReportOutputPath.mockImplementation( 496 | (explicitPath, args) => { 497 | const tag = args?.tag; 498 | if (explicitPath) { 499 | return explicitPath; 500 | } 501 | 502 | let filename = 'task-complexity-report.json'; 503 | if (tag && tag !== 'master') { 504 | // Use slugified tag for cross-platform compatibility 505 | const slugifiedTag = tag 506 | .replace(/[^a-zA-Z0-9_-]/g, '-') 507 | .toLowerCase(); 508 | filename = `task-complexity-report_${slugifiedTag}.json`; 509 | } 510 | 511 | return path.join(projectRoot, '.taskmaster/reports', filename); 512 | } 513 | ); 514 | 515 | // Mock findComplexityReportPath to return tag-aware paths 516 | findComplexityReportPath.mockImplementation((explicitPath, args) => { 517 | const tag = args?.tag; 518 | if (explicitPath) { 519 | return explicitPath; 520 | } 521 | 522 | let filename = 'task-complexity-report.json'; 523 | if (tag && tag !== 'master') { 524 | filename = `task-complexity-report_${tag}.json`; 525 | } 526 | 527 | return path.join(projectRoot, '.taskmaster/reports', filename); 528 | }); 529 | }); 530 | 531 | describe('Path Resolution Tag Isolation', () => { 532 | test('should resolve master tag to default filename', () => { 533 | const result = resolveComplexityReportOutputPath(null, { 534 | tag: 'master', 535 | projectRoot 536 | }); 537 | expect(result).toBe( 538 | path.join( 539 | projectRoot, 540 | '.taskmaster/reports', 541 | 'task-complexity-report.json' 542 | ) 543 | ); 544 | }); 545 | 546 | test('should resolve non-master tag to tag-specific filename', () => { 547 | const result = resolveComplexityReportOutputPath(null, { 548 | tag: 'feature-auth', 549 | projectRoot 550 | }); 551 | expect(result).toBe( 552 | path.join( 553 | projectRoot, 554 | '.taskmaster/reports', 555 | 'task-complexity-report_feature-auth.json' 556 | ) 557 | ); 558 | }); 559 | 560 | test('should resolve undefined tag to default filename', () => { 561 | const result = resolveComplexityReportOutputPath(null, { projectRoot }); 562 | expect(result).toBe( 563 | path.join( 564 | projectRoot, 565 | '.taskmaster/reports', 566 | 'task-complexity-report.json' 567 | ) 568 | ); 569 | }); 570 | 571 | test('should respect explicit path over tag-aware resolution', () => { 572 | const explicitPath = '/custom/path/report.json'; 573 | const result = resolveComplexityReportOutputPath(explicitPath, { 574 | tag: 'feature-auth', 575 | projectRoot 576 | }); 577 | expect(result).toBe(explicitPath); 578 | }); 579 | }); 580 | 581 | describe('Analysis Generation Tag Isolation', () => { 582 | test('should generate master tag report to default location', async () => { 583 | const options = { 584 | file: 'tasks/tasks.json', 585 | threshold: '5', 586 | projectRoot, 587 | tag: 'master' 588 | }; 589 | 590 | await analyzeTaskComplexity(options, { 591 | projectRoot, 592 | mcpLog: { 593 | info: jest.fn(), 594 | warn: jest.fn(), 595 | error: jest.fn(), 596 | debug: jest.fn(), 597 | success: jest.fn() 598 | } 599 | }); 600 | 601 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( 602 | undefined, 603 | expect.objectContaining({ 604 | tag: 'master', 605 | projectRoot 606 | }), 607 | expect.any(Function) 608 | ); 609 | 610 | expect(mockWriteFileSync).toHaveBeenCalledWith( 611 | path.join( 612 | projectRoot, 613 | '.taskmaster/reports', 614 | 'task-complexity-report.json' 615 | ), 616 | expect.any(String), 617 | 'utf8' 618 | ); 619 | }); 620 | 621 | test('should generate feature tag report to tag-specific location', async () => { 622 | const options = { 623 | file: 'tasks/tasks.json', 624 | threshold: '5', 625 | projectRoot, 626 | tag: 'feature-auth' 627 | }; 628 | 629 | await analyzeTaskComplexity(options, { 630 | projectRoot, 631 | mcpLog: { 632 | info: jest.fn(), 633 | warn: jest.fn(), 634 | error: jest.fn(), 635 | debug: jest.fn(), 636 | success: jest.fn() 637 | } 638 | }); 639 | 640 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( 641 | undefined, 642 | expect.objectContaining({ 643 | tag: 'feature-auth', 644 | projectRoot 645 | }), 646 | expect.any(Function) 647 | ); 648 | 649 | expect(mockWriteFileSync).toHaveBeenCalledWith( 650 | path.join( 651 | projectRoot, 652 | '.taskmaster/reports', 653 | 'task-complexity-report_feature-auth.json' 654 | ), 655 | expect.any(String), 656 | 'utf8' 657 | ); 658 | }); 659 | 660 | test('should not overwrite master report when analyzing feature tag', async () => { 661 | // First, analyze master tag 662 | const masterOptions = { 663 | file: 'tasks/tasks.json', 664 | threshold: '5', 665 | projectRoot, 666 | tag: 'master' 667 | }; 668 | 669 | await analyzeTaskComplexity(masterOptions, { 670 | projectRoot, 671 | mcpLog: { 672 | info: jest.fn(), 673 | warn: jest.fn(), 674 | error: jest.fn(), 675 | debug: jest.fn(), 676 | success: jest.fn() 677 | } 678 | }); 679 | 680 | // Clear mocks to verify separate calls 681 | jest.clearAllMocks(); 682 | readJSON.mockReturnValue(sampleTasks); 683 | 684 | // Then, analyze feature tag 685 | const featureOptions = { 686 | file: 'tasks/tasks.json', 687 | threshold: '5', 688 | projectRoot, 689 | tag: 'feature-auth' 690 | }; 691 | 692 | await analyzeTaskComplexity(featureOptions, { 693 | projectRoot, 694 | mcpLog: { 695 | info: jest.fn(), 696 | warn: jest.fn(), 697 | error: jest.fn(), 698 | debug: jest.fn(), 699 | success: jest.fn() 700 | } 701 | }); 702 | 703 | // Verify that the feature tag analysis wrote to its own file 704 | expect(mockWriteFileSync).toHaveBeenCalledWith( 705 | path.join( 706 | projectRoot, 707 | '.taskmaster/reports', 708 | 'task-complexity-report_feature-auth.json' 709 | ), 710 | expect.any(String), 711 | 'utf8' 712 | ); 713 | 714 | // Verify that it did NOT write to the master file 715 | expect(mockWriteFileSync).not.toHaveBeenCalledWith( 716 | path.join( 717 | projectRoot, 718 | '.taskmaster/reports', 719 | 'task-complexity-report.json' 720 | ), 721 | expect.any(String), 722 | 'utf8' 723 | ); 724 | }); 725 | }); 726 | 727 | describe('Report Reading Tag Isolation', () => { 728 | test('should read master tag report from default location', async () => { 729 | // Mock existing master report 730 | mockExistsSync.mockImplementation((filepath) => { 731 | return filepath.endsWith('task-complexity-report.json'); 732 | }); 733 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); 734 | 735 | const options = { 736 | file: 'tasks/tasks.json', 737 | threshold: '5', 738 | projectRoot, 739 | tag: 'master' 740 | }; 741 | 742 | await analyzeTaskComplexity(options, { 743 | projectRoot, 744 | mcpLog: { 745 | info: jest.fn(), 746 | warn: jest.fn(), 747 | error: jest.fn(), 748 | debug: jest.fn(), 749 | success: jest.fn() 750 | } 751 | }); 752 | 753 | expect(mockExistsSync).toHaveBeenCalledWith( 754 | path.join( 755 | projectRoot, 756 | '.taskmaster/reports', 757 | 'task-complexity-report.json' 758 | ) 759 | ); 760 | }); 761 | 762 | test('should read feature tag report from tag-specific location', async () => { 763 | // Mock existing feature tag report 764 | mockExistsSync.mockImplementation((filepath) => { 765 | return filepath.endsWith('task-complexity-report_feature-auth.json'); 766 | }); 767 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); 768 | 769 | const options = { 770 | file: 'tasks/tasks.json', 771 | threshold: '5', 772 | projectRoot, 773 | tag: 'feature-auth' 774 | }; 775 | 776 | await analyzeTaskComplexity(options, { 777 | projectRoot, 778 | mcpLog: { 779 | info: jest.fn(), 780 | warn: jest.fn(), 781 | error: jest.fn(), 782 | debug: jest.fn(), 783 | success: jest.fn() 784 | } 785 | }); 786 | 787 | expect(mockExistsSync).toHaveBeenCalledWith( 788 | path.join( 789 | projectRoot, 790 | '.taskmaster/reports', 791 | 'task-complexity-report_feature-auth.json' 792 | ) 793 | ); 794 | }); 795 | 796 | test('should not read master report when working with feature tag', async () => { 797 | // Mock that feature tag report exists but master doesn't 798 | mockExistsSync.mockImplementation((filepath) => { 799 | return filepath.endsWith('task-complexity-report_feature-auth.json'); 800 | }); 801 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); 802 | 803 | const options = { 804 | file: 'tasks/tasks.json', 805 | threshold: '5', 806 | projectRoot, 807 | tag: 'feature-auth' 808 | }; 809 | 810 | await analyzeTaskComplexity(options, { 811 | projectRoot, 812 | mcpLog: { 813 | info: jest.fn(), 814 | warn: jest.fn(), 815 | error: jest.fn(), 816 | debug: jest.fn(), 817 | success: jest.fn() 818 | } 819 | }); 820 | 821 | // Should check for feature tag report 822 | expect(mockExistsSync).toHaveBeenCalledWith( 823 | path.join( 824 | projectRoot, 825 | '.taskmaster/reports', 826 | 'task-complexity-report_feature-auth.json' 827 | ) 828 | ); 829 | 830 | // Should NOT check for master report 831 | expect(mockExistsSync).not.toHaveBeenCalledWith( 832 | path.join( 833 | projectRoot, 834 | '.taskmaster/reports', 835 | 'task-complexity-report.json' 836 | ) 837 | ); 838 | }); 839 | }); 840 | 841 | describe('Expand Task Tag Isolation', () => { 842 | test('should use tag-specific complexity report for expansion', async () => { 843 | // Mock existing feature tag report 844 | mockExistsSync.mockImplementation((filepath) => { 845 | return filepath.endsWith('task-complexity-report_feature-auth.json'); 846 | }); 847 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); 848 | 849 | const tasksPath = path.join(projectRoot, 'tasks/tasks.json'); 850 | const taskId = 1; 851 | const numSubtasks = 3; 852 | 853 | await expandTask( 854 | tasksPath, 855 | taskId, 856 | numSubtasks, 857 | false, // useResearch 858 | '', // additionalContext 859 | { 860 | projectRoot, 861 | tag: 'feature-auth', 862 | complexityReportPath: path.join( 863 | projectRoot, 864 | '.taskmaster/reports', 865 | 'task-complexity-report_feature-auth.json' 866 | ), 867 | mcpLog: { 868 | info: jest.fn(), 869 | warn: jest.fn(), 870 | error: jest.fn(), 871 | debug: jest.fn(), 872 | success: jest.fn() 873 | } 874 | }, 875 | false // force 876 | ); 877 | 878 | // Should read from feature tag report 879 | expect(readJSON).toHaveBeenCalledWith( 880 | path.join( 881 | projectRoot, 882 | '.taskmaster/reports', 883 | 'task-complexity-report_feature-auth.json' 884 | ) 885 | ); 886 | }); 887 | 888 | test('should use master complexity report for master tag expansion', async () => { 889 | // Mock existing master report 890 | mockExistsSync.mockImplementation((filepath) => { 891 | return filepath.endsWith('task-complexity-report.json'); 892 | }); 893 | mockReadFileSync.mockReturnValue(JSON.stringify(sampleComplexityReport)); 894 | 895 | const tasksPath = path.join(projectRoot, 'tasks/tasks.json'); 896 | const taskId = 1; 897 | const numSubtasks = 3; 898 | 899 | await expandTask( 900 | tasksPath, 901 | taskId, 902 | numSubtasks, 903 | false, // useResearch 904 | '', // additionalContext 905 | { 906 | projectRoot, 907 | tag: 'master', 908 | complexityReportPath: path.join( 909 | projectRoot, 910 | '.taskmaster/reports', 911 | 'task-complexity-report.json' 912 | ), 913 | mcpLog: { 914 | info: jest.fn(), 915 | warn: jest.fn(), 916 | error: jest.fn(), 917 | debug: jest.fn(), 918 | success: jest.fn() 919 | } 920 | }, 921 | false // force 922 | ); 923 | 924 | // Should read from master report 925 | expect(readJSON).toHaveBeenCalledWith( 926 | path.join( 927 | projectRoot, 928 | '.taskmaster/reports', 929 | 'task-complexity-report.json' 930 | ) 931 | ); 932 | }); 933 | }); 934 | 935 | describe('Cross-Tag Contamination Prevention', () => { 936 | test('should maintain separate reports for different tags', async () => { 937 | // Create different complexity reports for different tags 938 | const masterReport = { 939 | ...sampleComplexityReport, 940 | complexityAnalysis: [ 941 | { 942 | taskId: 1, 943 | taskTitle: 'Master Task 1', 944 | complexityScore: 8, 945 | recommendedSubtasks: 5, 946 | expansionPrompt: 'Master expansion', 947 | reasoning: 'Master task reasoning' 948 | } 949 | ] 950 | }; 951 | 952 | const featureReport = { 953 | ...sampleComplexityReport, 954 | complexityAnalysis: [ 955 | { 956 | taskId: 1, 957 | taskTitle: 'Feature Task 1', 958 | complexityScore: 6, 959 | recommendedSubtasks: 3, 960 | expansionPrompt: 'Feature expansion', 961 | reasoning: 'Feature task reasoning' 962 | } 963 | ] 964 | }; 965 | 966 | // Mock file system to return different reports for different paths 967 | mockExistsSync.mockImplementation((filepath) => { 968 | return filepath.includes('task-complexity-report'); 969 | }); 970 | 971 | mockReadFileSync.mockImplementation((filepath) => { 972 | if (filepath.includes('task-complexity-report_feature-auth.json')) { 973 | return JSON.stringify(featureReport); 974 | } else if (filepath.includes('task-complexity-report.json')) { 975 | return JSON.stringify(masterReport); 976 | } 977 | return '{}'; 978 | }); 979 | 980 | // Analyze master tag 981 | const masterOptions = { 982 | file: 'tasks/tasks.json', 983 | threshold: '5', 984 | projectRoot, 985 | tag: 'master' 986 | }; 987 | 988 | await analyzeTaskComplexity(masterOptions, { 989 | projectRoot, 990 | mcpLog: { 991 | info: jest.fn(), 992 | warn: jest.fn(), 993 | error: jest.fn(), 994 | debug: jest.fn(), 995 | success: jest.fn() 996 | } 997 | }); 998 | 999 | // Verify that master report was written to master location 1000 | expect(mockWriteFileSync).toHaveBeenCalledWith( 1001 | path.join( 1002 | projectRoot, 1003 | '.taskmaster/reports', 1004 | 'task-complexity-report.json' 1005 | ), 1006 | expect.stringContaining('"taskTitle": "Test Task"'), 1007 | 'utf8' 1008 | ); 1009 | 1010 | // Clear mocks 1011 | jest.clearAllMocks(); 1012 | readJSON.mockReturnValue(sampleTasks); 1013 | 1014 | // Analyze feature tag 1015 | const featureOptions = { 1016 | file: 'tasks/tasks.json', 1017 | threshold: '5', 1018 | projectRoot, 1019 | tag: 'feature-auth' 1020 | }; 1021 | 1022 | await analyzeTaskComplexity(featureOptions, { 1023 | projectRoot, 1024 | mcpLog: { 1025 | info: jest.fn(), 1026 | warn: jest.fn(), 1027 | error: jest.fn(), 1028 | debug: jest.fn(), 1029 | success: jest.fn() 1030 | } 1031 | }); 1032 | 1033 | // Verify that feature report was written to feature location 1034 | expect(mockWriteFileSync).toHaveBeenCalledWith( 1035 | path.join( 1036 | projectRoot, 1037 | '.taskmaster/reports', 1038 | 'task-complexity-report_feature-auth.json' 1039 | ), 1040 | expect.stringContaining('"taskTitle": "Test Task"'), 1041 | 'utf8' 1042 | ); 1043 | }); 1044 | }); 1045 | 1046 | describe('Edge Cases', () => { 1047 | test('should handle empty tag gracefully', async () => { 1048 | const options = { 1049 | file: 'tasks/tasks.json', 1050 | threshold: '5', 1051 | projectRoot, 1052 | tag: '' 1053 | }; 1054 | 1055 | await analyzeTaskComplexity(options, { 1056 | projectRoot, 1057 | mcpLog: { 1058 | info: jest.fn(), 1059 | warn: jest.fn(), 1060 | error: jest.fn(), 1061 | debug: jest.fn(), 1062 | success: jest.fn() 1063 | } 1064 | }); 1065 | 1066 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( 1067 | undefined, 1068 | expect.objectContaining({ 1069 | tag: '', 1070 | projectRoot 1071 | }), 1072 | expect.any(Function) 1073 | ); 1074 | }); 1075 | 1076 | test('should handle null tag gracefully', async () => { 1077 | const options = { 1078 | file: 'tasks/tasks.json', 1079 | threshold: '5', 1080 | projectRoot, 1081 | tag: null 1082 | }; 1083 | 1084 | await analyzeTaskComplexity(options, { 1085 | projectRoot, 1086 | mcpLog: { 1087 | info: jest.fn(), 1088 | warn: jest.fn(), 1089 | error: jest.fn(), 1090 | debug: jest.fn(), 1091 | success: jest.fn() 1092 | } 1093 | }); 1094 | 1095 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( 1096 | undefined, 1097 | expect.objectContaining({ 1098 | tag: null, 1099 | projectRoot 1100 | }), 1101 | expect.any(Function) 1102 | ); 1103 | }); 1104 | 1105 | test('should handle special characters in tag names', async () => { 1106 | const options = { 1107 | file: 'tasks/tasks.json', 1108 | threshold: '5', 1109 | projectRoot, 1110 | tag: 'feature/user-auth-v2' 1111 | }; 1112 | 1113 | await analyzeTaskComplexity(options, { 1114 | projectRoot, 1115 | mcpLog: { 1116 | info: jest.fn(), 1117 | warn: jest.fn(), 1118 | error: jest.fn(), 1119 | debug: jest.fn(), 1120 | success: jest.fn() 1121 | } 1122 | }); 1123 | 1124 | expect(resolveComplexityReportOutputPath).toHaveBeenCalledWith( 1125 | undefined, 1126 | expect.objectContaining({ 1127 | tag: 'feature/user-auth-v2', 1128 | projectRoot 1129 | }), 1130 | expect.any(Function) 1131 | ); 1132 | 1133 | expect(mockWriteFileSync).toHaveBeenCalledWith( 1134 | path.join( 1135 | projectRoot, 1136 | '.taskmaster/reports', 1137 | 'task-complexity-report_feature-user-auth-v2.json' 1138 | ), 1139 | expect.any(String), 1140 | 'utf8' 1141 | ); 1142 | }); 1143 | }); 1144 | }); 1145 | ```