This is page 32 of 69. Use http://codebase.md/eyaltoledano/claude-task-master?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .changeset
│ ├── config.json
│ └── README.md
├── .claude
│ ├── commands
│ │ └── dedupe.md
│ └── TM_COMMANDS_GUIDE.md
├── .claude-plugin
│ └── marketplace.json
├── .coderabbit.yaml
├── .cursor
│ ├── mcp.json
│ └── rules
│ ├── ai_providers.mdc
│ ├── ai_services.mdc
│ ├── architecture.mdc
│ ├── changeset.mdc
│ ├── commands.mdc
│ ├── context_gathering.mdc
│ ├── cursor_rules.mdc
│ ├── dependencies.mdc
│ ├── dev_workflow.mdc
│ ├── git_workflow.mdc
│ ├── glossary.mdc
│ ├── mcp.mdc
│ ├── new_features.mdc
│ ├── self_improve.mdc
│ ├── tags.mdc
│ ├── taskmaster.mdc
│ ├── tasks.mdc
│ ├── telemetry.mdc
│ ├── test_workflow.mdc
│ ├── tests.mdc
│ ├── ui.mdc
│ └── utilities.mdc
├── .cursorignore
├── .env.example
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.md
│ │ ├── enhancements---feature-requests.md
│ │ └── feedback.md
│ ├── PULL_REQUEST_TEMPLATE
│ │ ├── bugfix.md
│ │ ├── config.yml
│ │ ├── feature.md
│ │ └── integration.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── scripts
│ │ ├── auto-close-duplicates.mjs
│ │ ├── backfill-duplicate-comments.mjs
│ │ ├── check-pre-release-mode.mjs
│ │ ├── parse-metrics.mjs
│ │ ├── release.mjs
│ │ ├── tag-extension.mjs
│ │ ├── utils.mjs
│ │ └── validate-changesets.mjs
│ └── workflows
│ ├── auto-close-duplicates.yml
│ ├── backfill-duplicate-comments.yml
│ ├── ci.yml
│ ├── claude-dedupe-issues.yml
│ ├── claude-docs-trigger.yml
│ ├── claude-docs-updater.yml
│ ├── claude-issue-triage.yml
│ ├── claude.yml
│ ├── extension-ci.yml
│ ├── extension-release.yml
│ ├── log-issue-events.yml
│ ├── pre-release.yml
│ ├── release-check.yml
│ ├── release.yml
│ ├── update-models-md.yml
│ └── weekly-metrics-discord.yml
├── .gitignore
├── .kiro
│ ├── hooks
│ │ ├── tm-code-change-task-tracker.kiro.hook
│ │ ├── tm-complexity-analyzer.kiro.hook
│ │ ├── tm-daily-standup-assistant.kiro.hook
│ │ ├── tm-git-commit-task-linker.kiro.hook
│ │ ├── tm-pr-readiness-checker.kiro.hook
│ │ ├── tm-task-dependency-auto-progression.kiro.hook
│ │ └── tm-test-success-task-completer.kiro.hook
│ ├── settings
│ │ └── mcp.json
│ └── steering
│ ├── dev_workflow.md
│ ├── kiro_rules.md
│ ├── self_improve.md
│ ├── taskmaster_hooks_workflow.md
│ └── taskmaster.md
├── .manypkg.json
├── .mcp.json
├── .npmignore
├── .nvmrc
├── .taskmaster
│ ├── CLAUDE.md
│ ├── config.json
│ ├── docs
│ │ ├── autonomous-tdd-git-workflow.md
│ │ ├── MIGRATION-ROADMAP.md
│ │ ├── prd-tm-start.txt
│ │ ├── prd.txt
│ │ ├── README.md
│ │ ├── research
│ │ │ ├── 2025-06-14_how-can-i-improve-the-scope-up-and-scope-down-comm.md
│ │ │ ├── 2025-06-14_should-i-be-using-any-specific-libraries-for-this.md
│ │ │ ├── 2025-06-14_test-save-functionality.md
│ │ │ ├── 2025-06-14_test-the-fix-for-duplicate-saves-final-test.md
│ │ │ └── 2025-08-01_do-we-need-to-add-new-commands-or-can-we-just-weap.md
│ │ ├── task-template-importing-prd.txt
│ │ ├── tdd-workflow-phase-0-spike.md
│ │ ├── tdd-workflow-phase-1-core-rails.md
│ │ ├── tdd-workflow-phase-1-orchestrator.md
│ │ ├── tdd-workflow-phase-2-pr-resumability.md
│ │ ├── tdd-workflow-phase-3-extensibility-guardrails.md
│ │ ├── test-prd.txt
│ │ └── tm-core-phase-1.txt
│ ├── reports
│ │ ├── task-complexity-report_autonomous-tdd-git-workflow.json
│ │ ├── task-complexity-report_cc-kiro-hooks.json
│ │ ├── task-complexity-report_tdd-phase-1-core-rails.json
│ │ ├── task-complexity-report_tdd-workflow-phase-0.json
│ │ ├── task-complexity-report_test-prd-tag.json
│ │ ├── task-complexity-report_tm-core-phase-1.json
│ │ ├── task-complexity-report.json
│ │ └── tm-core-complexity.json
│ ├── state.json
│ ├── tasks
│ │ ├── task_001_tm-start.txt
│ │ ├── task_002_tm-start.txt
│ │ ├── task_003_tm-start.txt
│ │ ├── task_004_tm-start.txt
│ │ ├── task_007_tm-start.txt
│ │ └── tasks.json
│ └── templates
│ ├── example_prd_rpg.md
│ └── example_prd.md
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── apps
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── command-registry.ts
│ │ │ ├── commands
│ │ │ │ ├── auth.command.ts
│ │ │ │ ├── autopilot
│ │ │ │ │ ├── abort.command.ts
│ │ │ │ │ ├── commit.command.ts
│ │ │ │ │ ├── complete.command.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── next.command.ts
│ │ │ │ │ ├── resume.command.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── start.command.ts
│ │ │ │ │ └── status.command.ts
│ │ │ │ ├── briefs.command.ts
│ │ │ │ ├── context.command.ts
│ │ │ │ ├── export.command.ts
│ │ │ │ ├── list.command.ts
│ │ │ │ ├── models
│ │ │ │ │ ├── custom-providers.ts
│ │ │ │ │ ├── fetchers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompts.ts
│ │ │ │ │ ├── setup.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── next.command.ts
│ │ │ │ ├── set-status.command.ts
│ │ │ │ ├── show.command.ts
│ │ │ │ ├── start.command.ts
│ │ │ │ └── tags.command.ts
│ │ │ ├── index.ts
│ │ │ ├── lib
│ │ │ │ └── model-management.ts
│ │ │ ├── types
│ │ │ │ └── tag-management.d.ts
│ │ │ ├── ui
│ │ │ │ ├── components
│ │ │ │ │ ├── cardBox.component.ts
│ │ │ │ │ ├── dashboard.component.ts
│ │ │ │ │ ├── header.component.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── next-task.component.ts
│ │ │ │ │ ├── suggested-steps.component.ts
│ │ │ │ │ └── task-detail.component.ts
│ │ │ │ ├── display
│ │ │ │ │ ├── messages.ts
│ │ │ │ │ └── tables.ts
│ │ │ │ ├── formatters
│ │ │ │ │ ├── complexity-formatters.ts
│ │ │ │ │ ├── dependency-formatters.ts
│ │ │ │ │ ├── priority-formatters.ts
│ │ │ │ │ ├── status-formatters.spec.ts
│ │ │ │ │ └── status-formatters.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── layout
│ │ │ │ ├── helpers.spec.ts
│ │ │ │ └── helpers.ts
│ │ │ └── utils
│ │ │ ├── auth-helpers.ts
│ │ │ ├── auto-update.ts
│ │ │ ├── brief-selection.ts
│ │ │ ├── display-helpers.ts
│ │ │ ├── error-handler.ts
│ │ │ ├── index.ts
│ │ │ ├── project-root.ts
│ │ │ ├── task-status.ts
│ │ │ ├── ui.spec.ts
│ │ │ └── ui.ts
│ │ ├── tests
│ │ │ ├── integration
│ │ │ │ └── commands
│ │ │ │ └── autopilot
│ │ │ │ └── workflow.test.ts
│ │ │ └── unit
│ │ │ ├── commands
│ │ │ │ ├── autopilot
│ │ │ │ │ └── shared.test.ts
│ │ │ │ ├── list.command.spec.ts
│ │ │ │ └── show.command.spec.ts
│ │ │ └── ui
│ │ │ └── dashboard.component.spec.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── docs
│ │ ├── archive
│ │ │ ├── ai-client-utils-example.mdx
│ │ │ ├── ai-development-workflow.mdx
│ │ │ ├── command-reference.mdx
│ │ │ ├── configuration.mdx
│ │ │ ├── cursor-setup.mdx
│ │ │ ├── examples.mdx
│ │ │ └── Installation.mdx
│ │ ├── best-practices
│ │ │ ├── advanced-tasks.mdx
│ │ │ ├── configuration-advanced.mdx
│ │ │ └── index.mdx
│ │ ├── capabilities
│ │ │ ├── cli-root-commands.mdx
│ │ │ ├── index.mdx
│ │ │ ├── mcp.mdx
│ │ │ ├── rpg-method.mdx
│ │ │ └── task-structure.mdx
│ │ ├── CHANGELOG.md
│ │ ├── command-reference.mdx
│ │ ├── configuration.mdx
│ │ ├── docs.json
│ │ ├── favicon.svg
│ │ ├── getting-started
│ │ │ ├── api-keys.mdx
│ │ │ ├── contribute.mdx
│ │ │ ├── faq.mdx
│ │ │ └── quick-start
│ │ │ ├── configuration-quick.mdx
│ │ │ ├── execute-quick.mdx
│ │ │ ├── installation.mdx
│ │ │ ├── moving-forward.mdx
│ │ │ ├── prd-quick.mdx
│ │ │ ├── quick-start.mdx
│ │ │ ├── requirements.mdx
│ │ │ ├── rules-quick.mdx
│ │ │ └── tasks-quick.mdx
│ │ ├── introduction.mdx
│ │ ├── licensing.md
│ │ ├── logo
│ │ │ ├── dark.svg
│ │ │ ├── light.svg
│ │ │ └── task-master-logo.png
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── style.css
│ │ ├── tdd-workflow
│ │ │ ├── ai-agent-integration.mdx
│ │ │ └── quickstart.mdx
│ │ ├── vercel.json
│ │ └── whats-new.mdx
│ ├── extension
│ │ ├── .vscodeignore
│ │ ├── assets
│ │ │ ├── banner.png
│ │ │ ├── icon-dark.svg
│ │ │ ├── icon-light.svg
│ │ │ ├── icon.png
│ │ │ ├── screenshots
│ │ │ │ ├── kanban-board.png
│ │ │ │ └── task-details.png
│ │ │ └── sidebar-icon.svg
│ │ ├── CHANGELOG.md
│ │ ├── components.json
│ │ ├── docs
│ │ │ ├── extension-CI-setup.md
│ │ │ └── extension-development-guide.md
│ │ ├── esbuild.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── package.mjs
│ │ ├── package.publish.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── components
│ │ │ │ ├── ConfigView.tsx
│ │ │ │ ├── constants.ts
│ │ │ │ ├── TaskDetails
│ │ │ │ │ ├── AIActionsSection.tsx
│ │ │ │ │ ├── DetailsSection.tsx
│ │ │ │ │ ├── PriorityBadge.tsx
│ │ │ │ │ ├── SubtasksSection.tsx
│ │ │ │ │ ├── TaskMetadataSidebar.tsx
│ │ │ │ │ └── useTaskDetails.ts
│ │ │ │ ├── TaskDetailsView.tsx
│ │ │ │ ├── TaskMasterLogo.tsx
│ │ │ │ └── ui
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── breadcrumb.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── collapsible.tsx
│ │ │ │ ├── CollapsibleSection.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── shadcn-io
│ │ │ │ │ └── kanban
│ │ │ │ │ └── index.tsx
│ │ │ │ └── textarea.tsx
│ │ │ ├── extension.ts
│ │ │ ├── index.ts
│ │ │ ├── lib
│ │ │ │ └── utils.ts
│ │ │ ├── services
│ │ │ │ ├── config-service.ts
│ │ │ │ ├── error-handler.ts
│ │ │ │ ├── notification-preferences.ts
│ │ │ │ ├── polling-service.ts
│ │ │ │ ├── polling-strategies.ts
│ │ │ │ ├── sidebar-webview-manager.ts
│ │ │ │ ├── task-repository.ts
│ │ │ │ ├── terminal-manager.ts
│ │ │ │ └── webview-manager.ts
│ │ │ ├── test
│ │ │ │ └── extension.test.ts
│ │ │ ├── utils
│ │ │ │ ├── configManager.ts
│ │ │ │ ├── connectionManager.ts
│ │ │ │ ├── errorHandler.ts
│ │ │ │ ├── event-emitter.ts
│ │ │ │ ├── logger.ts
│ │ │ │ ├── mcpClient.ts
│ │ │ │ ├── notificationPreferences.ts
│ │ │ │ └── task-master-api
│ │ │ │ ├── cache
│ │ │ │ │ └── cache-manager.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── mcp-client.ts
│ │ │ │ ├── transformers
│ │ │ │ │ └── task-transformer.ts
│ │ │ │ └── types
│ │ │ │ └── index.ts
│ │ │ └── webview
│ │ │ ├── App.tsx
│ │ │ ├── components
│ │ │ │ ├── AppContent.tsx
│ │ │ │ ├── EmptyState.tsx
│ │ │ │ ├── ErrorBoundary.tsx
│ │ │ │ ├── PollingStatus.tsx
│ │ │ │ ├── PriorityBadge.tsx
│ │ │ │ ├── SidebarView.tsx
│ │ │ │ ├── TagDropdown.tsx
│ │ │ │ ├── TaskCard.tsx
│ │ │ │ ├── TaskEditModal.tsx
│ │ │ │ ├── TaskMasterKanban.tsx
│ │ │ │ ├── ToastContainer.tsx
│ │ │ │ └── ToastNotification.tsx
│ │ │ ├── constants
│ │ │ │ └── index.ts
│ │ │ ├── contexts
│ │ │ │ └── VSCodeContext.tsx
│ │ │ ├── hooks
│ │ │ │ ├── useTaskQueries.ts
│ │ │ │ ├── useVSCodeMessages.ts
│ │ │ │ └── useWebviewHeight.ts
│ │ │ ├── index.css
│ │ │ ├── index.tsx
│ │ │ ├── providers
│ │ │ │ └── QueryProvider.tsx
│ │ │ ├── reducers
│ │ │ │ └── appReducer.ts
│ │ │ ├── sidebar.tsx
│ │ │ ├── types
│ │ │ │ └── index.ts
│ │ │ └── utils
│ │ │ ├── logger.ts
│ │ │ └── toast.ts
│ │ └── tsconfig.json
│ └── mcp
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── shared
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ └── tools
│ │ ├── autopilot
│ │ │ ├── abort.tool.ts
│ │ │ ├── commit.tool.ts
│ │ │ ├── complete.tool.ts
│ │ │ ├── finalize.tool.ts
│ │ │ ├── index.ts
│ │ │ ├── next.tool.ts
│ │ │ ├── resume.tool.ts
│ │ │ ├── start.tool.ts
│ │ │ └── status.tool.ts
│ │ ├── README-ZOD-V3.md
│ │ └── tasks
│ │ ├── get-task.tool.ts
│ │ ├── get-tasks.tool.ts
│ │ └── index.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── assets
│ ├── .windsurfrules
│ ├── AGENTS.md
│ ├── claude
│ │ └── TM_COMMANDS_GUIDE.md
│ ├── config.json
│ ├── env.example
│ ├── example_prd_rpg.txt
│ ├── example_prd.txt
│ ├── GEMINI.md
│ ├── gitignore
│ ├── kiro-hooks
│ │ ├── tm-code-change-task-tracker.kiro.hook
│ │ ├── tm-complexity-analyzer.kiro.hook
│ │ ├── tm-daily-standup-assistant.kiro.hook
│ │ ├── tm-git-commit-task-linker.kiro.hook
│ │ ├── tm-pr-readiness-checker.kiro.hook
│ │ ├── tm-task-dependency-auto-progression.kiro.hook
│ │ └── tm-test-success-task-completer.kiro.hook
│ ├── roocode
│ │ ├── .roo
│ │ │ ├── rules-architect
│ │ │ │ └── architect-rules
│ │ │ ├── rules-ask
│ │ │ │ └── ask-rules
│ │ │ ├── rules-code
│ │ │ │ └── code-rules
│ │ │ ├── rules-debug
│ │ │ │ └── debug-rules
│ │ │ ├── rules-orchestrator
│ │ │ │ └── orchestrator-rules
│ │ │ └── rules-test
│ │ │ └── test-rules
│ │ └── .roomodes
│ ├── rules
│ │ ├── cursor_rules.mdc
│ │ ├── dev_workflow.mdc
│ │ ├── self_improve.mdc
│ │ ├── taskmaster_hooks_workflow.mdc
│ │ └── taskmaster.mdc
│ └── scripts_README.md
├── bin
│ └── task-master.js
├── biome.json
├── CHANGELOG.md
├── CLAUDE_CODE_PLUGIN.md
├── CLAUDE.md
├── context
│ ├── chats
│ │ ├── add-task-dependencies-1.md
│ │ └── max-min-tokens.txt.md
│ ├── fastmcp-core.txt
│ ├── fastmcp-docs.txt
│ ├── MCP_INTEGRATION.md
│ ├── mcp-js-sdk-docs.txt
│ ├── mcp-protocol-repo.txt
│ ├── mcp-protocol-schema-03262025.json
│ └── mcp-protocol-spec.txt
├── CONTRIBUTING.md
├── docs
│ ├── claude-code-integration.md
│ ├── CLI-COMMANDER-PATTERN.md
│ ├── command-reference.md
│ ├── configuration.md
│ ├── contributor-docs
│ │ ├── testing-roo-integration.md
│ │ └── worktree-setup.md
│ ├── cross-tag-task-movement.md
│ ├── examples
│ │ ├── claude-code-usage.md
│ │ └── codex-cli-usage.md
│ ├── examples.md
│ ├── licensing.md
│ ├── mcp-provider-guide.md
│ ├── mcp-provider.md
│ ├── migration-guide.md
│ ├── models.md
│ ├── providers
│ │ ├── codex-cli.md
│ │ └── gemini-cli.md
│ ├── README.md
│ ├── scripts
│ │ └── models-json-to-markdown.js
│ ├── task-structure.md
│ └── tutorial.md
├── images
│ ├── hamster-hiring.png
│ └── logo.png
├── index.js
├── jest.config.js
├── jest.resolver.cjs
├── LICENSE
├── llms-install.md
├── mcp-server
│ ├── server.js
│ └── src
│ ├── core
│ │ ├── __tests__
│ │ │ └── context-manager.test.js
│ │ ├── context-manager.js
│ │ ├── direct-functions
│ │ │ ├── add-dependency.js
│ │ │ ├── add-subtask.js
│ │ │ ├── add-tag.js
│ │ │ ├── add-task.js
│ │ │ ├── analyze-task-complexity.js
│ │ │ ├── cache-stats.js
│ │ │ ├── clear-subtasks.js
│ │ │ ├── complexity-report.js
│ │ │ ├── copy-tag.js
│ │ │ ├── create-tag-from-branch.js
│ │ │ ├── delete-tag.js
│ │ │ ├── expand-all-tasks.js
│ │ │ ├── expand-task.js
│ │ │ ├── fix-dependencies.js
│ │ │ ├── generate-task-files.js
│ │ │ ├── initialize-project.js
│ │ │ ├── list-tags.js
│ │ │ ├── models.js
│ │ │ ├── move-task-cross-tag.js
│ │ │ ├── move-task.js
│ │ │ ├── next-task.js
│ │ │ ├── parse-prd.js
│ │ │ ├── remove-dependency.js
│ │ │ ├── remove-subtask.js
│ │ │ ├── remove-task.js
│ │ │ ├── rename-tag.js
│ │ │ ├── research.js
│ │ │ ├── response-language.js
│ │ │ ├── rules.js
│ │ │ ├── scope-down.js
│ │ │ ├── scope-up.js
│ │ │ ├── set-task-status.js
│ │ │ ├── update-subtask-by-id.js
│ │ │ ├── update-task-by-id.js
│ │ │ ├── update-tasks.js
│ │ │ ├── use-tag.js
│ │ │ └── validate-dependencies.js
│ │ ├── task-master-core.js
│ │ └── utils
│ │ ├── env-utils.js
│ │ └── path-utils.js
│ ├── custom-sdk
│ │ ├── errors.js
│ │ ├── index.js
│ │ ├── json-extractor.js
│ │ ├── language-model.js
│ │ ├── message-converter.js
│ │ └── schema-converter.js
│ ├── index.js
│ ├── logger.js
│ ├── providers
│ │ └── mcp-provider.js
│ └── tools
│ ├── add-dependency.js
│ ├── add-subtask.js
│ ├── add-tag.js
│ ├── add-task.js
│ ├── analyze.js
│ ├── clear-subtasks.js
│ ├── complexity-report.js
│ ├── copy-tag.js
│ ├── delete-tag.js
│ ├── expand-all.js
│ ├── expand-task.js
│ ├── fix-dependencies.js
│ ├── generate.js
│ ├── get-operation-status.js
│ ├── index.js
│ ├── initialize-project.js
│ ├── list-tags.js
│ ├── models.js
│ ├── move-task.js
│ ├── next-task.js
│ ├── parse-prd.js
│ ├── README-ZOD-V3.md
│ ├── remove-dependency.js
│ ├── remove-subtask.js
│ ├── remove-task.js
│ ├── rename-tag.js
│ ├── research.js
│ ├── response-language.js
│ ├── rules.js
│ ├── scope-down.js
│ ├── scope-up.js
│ ├── set-task-status.js
│ ├── tool-registry.js
│ ├── update-subtask.js
│ ├── update-task.js
│ ├── update.js
│ ├── use-tag.js
│ ├── utils.js
│ └── validate-dependencies.js
├── mcp-test.js
├── output.json
├── package-lock.json
├── package.json
├── packages
│ ├── ai-sdk-provider-grok-cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── grok-cli-language-model.ts
│ │ │ ├── grok-cli-provider.test.ts
│ │ │ ├── grok-cli-provider.ts
│ │ │ ├── index.ts
│ │ │ ├── json-extractor.test.ts
│ │ │ ├── json-extractor.ts
│ │ │ ├── message-converter.test.ts
│ │ │ ├── message-converter.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ ├── build-config
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ └── tsdown.base.ts
│ │ └── tsconfig.json
│ ├── claude-code-plugin
│ │ ├── .claude-plugin
│ │ │ └── plugin.json
│ │ ├── .gitignore
│ │ ├── agents
│ │ │ ├── task-checker.md
│ │ │ ├── task-executor.md
│ │ │ └── task-orchestrator.md
│ │ ├── CHANGELOG.md
│ │ ├── commands
│ │ │ ├── add-dependency.md
│ │ │ ├── add-subtask.md
│ │ │ ├── add-task.md
│ │ │ ├── analyze-complexity.md
│ │ │ ├── analyze-project.md
│ │ │ ├── auto-implement-tasks.md
│ │ │ ├── command-pipeline.md
│ │ │ ├── complexity-report.md
│ │ │ ├── convert-task-to-subtask.md
│ │ │ ├── expand-all-tasks.md
│ │ │ ├── expand-task.md
│ │ │ ├── fix-dependencies.md
│ │ │ ├── generate-tasks.md
│ │ │ ├── help.md
│ │ │ ├── init-project-quick.md
│ │ │ ├── init-project.md
│ │ │ ├── install-taskmaster.md
│ │ │ ├── learn.md
│ │ │ ├── list-tasks-by-status.md
│ │ │ ├── list-tasks-with-subtasks.md
│ │ │ ├── list-tasks.md
│ │ │ ├── next-task.md
│ │ │ ├── parse-prd-with-research.md
│ │ │ ├── parse-prd.md
│ │ │ ├── project-status.md
│ │ │ ├── quick-install-taskmaster.md
│ │ │ ├── remove-all-subtasks.md
│ │ │ ├── remove-dependency.md
│ │ │ ├── remove-subtask.md
│ │ │ ├── remove-subtasks.md
│ │ │ ├── remove-task.md
│ │ │ ├── setup-models.md
│ │ │ ├── show-task.md
│ │ │ ├── smart-workflow.md
│ │ │ ├── sync-readme.md
│ │ │ ├── tm-main.md
│ │ │ ├── to-cancelled.md
│ │ │ ├── to-deferred.md
│ │ │ ├── to-done.md
│ │ │ ├── to-in-progress.md
│ │ │ ├── to-pending.md
│ │ │ ├── to-review.md
│ │ │ ├── update-single-task.md
│ │ │ ├── update-task.md
│ │ │ ├── update-tasks-from-id.md
│ │ │ ├── validate-dependencies.md
│ │ │ └── view-models.md
│ │ ├── mcp.json
│ │ └── package.json
│ ├── tm-bridge
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── add-tag-bridge.ts
│ │ │ ├── bridge-types.ts
│ │ │ ├── bridge-utils.ts
│ │ │ ├── expand-bridge.ts
│ │ │ ├── index.ts
│ │ │ ├── tags-bridge.ts
│ │ │ ├── update-bridge.ts
│ │ │ └── use-tag-bridge.ts
│ │ └── tsconfig.json
│ └── tm-core
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docs
│ │ └── listTasks-architecture.md
│ ├── package.json
│ ├── POC-STATUS.md
│ ├── README.md
│ ├── src
│ │ ├── common
│ │ │ ├── constants
│ │ │ │ ├── index.ts
│ │ │ │ ├── paths.ts
│ │ │ │ └── providers.ts
│ │ │ ├── errors
│ │ │ │ ├── index.ts
│ │ │ │ └── task-master-error.ts
│ │ │ ├── interfaces
│ │ │ │ ├── configuration.interface.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── storage.interface.ts
│ │ │ ├── logger
│ │ │ │ ├── factory.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.spec.ts
│ │ │ │ └── logger.ts
│ │ │ ├── mappers
│ │ │ │ ├── TaskMapper.test.ts
│ │ │ │ └── TaskMapper.ts
│ │ │ ├── types
│ │ │ │ ├── database.types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── legacy.ts
│ │ │ │ └── repository-types.ts
│ │ │ └── utils
│ │ │ ├── git-utils.ts
│ │ │ ├── id-generator.ts
│ │ │ ├── index.ts
│ │ │ ├── path-helpers.ts
│ │ │ ├── path-normalizer.spec.ts
│ │ │ ├── path-normalizer.ts
│ │ │ ├── project-root-finder.spec.ts
│ │ │ ├── project-root-finder.ts
│ │ │ ├── run-id-generator.spec.ts
│ │ │ └── run-id-generator.ts
│ │ ├── index.ts
│ │ ├── modules
│ │ │ ├── ai
│ │ │ │ ├── index.ts
│ │ │ │ ├── interfaces
│ │ │ │ │ └── ai-provider.interface.ts
│ │ │ │ └── providers
│ │ │ │ ├── base-provider.ts
│ │ │ │ └── index.ts
│ │ │ ├── auth
│ │ │ │ ├── auth-domain.spec.ts
│ │ │ │ ├── auth-domain.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ ├── auth-manager.spec.ts
│ │ │ │ │ └── auth-manager.ts
│ │ │ │ ├── services
│ │ │ │ │ ├── context-store.ts
│ │ │ │ │ ├── oauth-service.ts
│ │ │ │ │ ├── organization.service.ts
│ │ │ │ │ ├── supabase-session-storage.spec.ts
│ │ │ │ │ └── supabase-session-storage.ts
│ │ │ │ └── types.ts
│ │ │ ├── briefs
│ │ │ │ ├── briefs-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── brief-service.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── url-parser.ts
│ │ │ ├── commands
│ │ │ │ └── index.ts
│ │ │ ├── config
│ │ │ │ ├── config-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ ├── config-manager.spec.ts
│ │ │ │ │ └── config-manager.ts
│ │ │ │ └── services
│ │ │ │ ├── config-loader.service.spec.ts
│ │ │ │ ├── config-loader.service.ts
│ │ │ │ ├── config-merger.service.spec.ts
│ │ │ │ ├── config-merger.service.ts
│ │ │ │ ├── config-persistence.service.spec.ts
│ │ │ │ ├── config-persistence.service.ts
│ │ │ │ ├── environment-config-provider.service.spec.ts
│ │ │ │ ├── environment-config-provider.service.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runtime-state-manager.service.spec.ts
│ │ │ │ └── runtime-state-manager.service.ts
│ │ │ ├── dependencies
│ │ │ │ └── index.ts
│ │ │ ├── execution
│ │ │ │ ├── executors
│ │ │ │ │ ├── base-executor.ts
│ │ │ │ │ ├── claude-executor.ts
│ │ │ │ │ └── executor-factory.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── executor-service.ts
│ │ │ │ └── types.ts
│ │ │ ├── git
│ │ │ │ ├── adapters
│ │ │ │ │ ├── git-adapter.test.ts
│ │ │ │ │ └── git-adapter.ts
│ │ │ │ ├── git-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── services
│ │ │ │ ├── branch-name-generator.spec.ts
│ │ │ │ ├── branch-name-generator.ts
│ │ │ │ ├── commit-message-generator.test.ts
│ │ │ │ ├── commit-message-generator.ts
│ │ │ │ ├── scope-detector.test.ts
│ │ │ │ ├── scope-detector.ts
│ │ │ │ ├── template-engine.test.ts
│ │ │ │ └── template-engine.ts
│ │ │ ├── integration
│ │ │ │ ├── clients
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── supabase-client.ts
│ │ │ │ ├── integration-domain.ts
│ │ │ │ └── services
│ │ │ │ ├── export.service.ts
│ │ │ │ ├── task-expansion.service.ts
│ │ │ │ └── task-retrieval.service.ts
│ │ │ ├── reports
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ └── complexity-report-manager.ts
│ │ │ │ └── types.ts
│ │ │ ├── storage
│ │ │ │ ├── adapters
│ │ │ │ │ ├── activity-logger.ts
│ │ │ │ │ ├── api-storage.ts
│ │ │ │ │ └── file-storage
│ │ │ │ │ ├── file-operations.ts
│ │ │ │ │ ├── file-storage.ts
│ │ │ │ │ ├── format-handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── path-resolver.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── storage-factory.ts
│ │ │ │ └── utils
│ │ │ │ └── api-client.ts
│ │ │ ├── tasks
│ │ │ │ ├── entities
│ │ │ │ │ └── task.entity.ts
│ │ │ │ ├── parser
│ │ │ │ │ └── index.ts
│ │ │ │ ├── repositories
│ │ │ │ │ ├── supabase
│ │ │ │ │ │ ├── dependency-fetcher.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── supabase-repository.ts
│ │ │ │ │ └── task-repository.interface.ts
│ │ │ │ ├── services
│ │ │ │ │ ├── preflight-checker.service.ts
│ │ │ │ │ ├── tag.service.ts
│ │ │ │ │ ├── task-execution-service.ts
│ │ │ │ │ ├── task-loader.service.ts
│ │ │ │ │ └── task-service.ts
│ │ │ │ └── tasks-domain.ts
│ │ │ ├── ui
│ │ │ │ └── index.ts
│ │ │ └── workflow
│ │ │ ├── managers
│ │ │ │ ├── workflow-state-manager.spec.ts
│ │ │ │ └── workflow-state-manager.ts
│ │ │ ├── orchestrators
│ │ │ │ ├── workflow-orchestrator.test.ts
│ │ │ │ └── workflow-orchestrator.ts
│ │ │ ├── services
│ │ │ │ ├── test-result-validator.test.ts
│ │ │ │ ├── test-result-validator.ts
│ │ │ │ ├── test-result-validator.types.ts
│ │ │ │ ├── workflow-activity-logger.ts
│ │ │ │ └── workflow.service.ts
│ │ │ ├── types.ts
│ │ │ └── workflow-domain.ts
│ │ ├── subpath-exports.test.ts
│ │ ├── tm-core.ts
│ │ └── utils
│ │ └── time.utils.ts
│ ├── tests
│ │ ├── auth
│ │ │ └── auth-refresh.test.ts
│ │ ├── integration
│ │ │ ├── auth-token-refresh.test.ts
│ │ │ ├── list-tasks.test.ts
│ │ │ └── storage
│ │ │ └── activity-logger.test.ts
│ │ ├── mocks
│ │ │ └── mock-provider.ts
│ │ ├── setup.ts
│ │ └── unit
│ │ ├── base-provider.test.ts
│ │ ├── executor.test.ts
│ │ └── smoke.test.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── README-task-master.md
├── README.md
├── scripts
│ ├── create-worktree.sh
│ ├── dev.js
│ ├── init.js
│ ├── list-worktrees.sh
│ ├── modules
│ │ ├── ai-services-unified.js
│ │ ├── bridge-utils.js
│ │ ├── commands.js
│ │ ├── config-manager.js
│ │ ├── dependency-manager.js
│ │ ├── index.js
│ │ ├── prompt-manager.js
│ │ ├── supported-models.json
│ │ ├── sync-readme.js
│ │ ├── task-manager
│ │ │ ├── add-subtask.js
│ │ │ ├── add-task.js
│ │ │ ├── analyze-task-complexity.js
│ │ │ ├── clear-subtasks.js
│ │ │ ├── expand-all-tasks.js
│ │ │ ├── expand-task.js
│ │ │ ├── find-next-task.js
│ │ │ ├── generate-task-files.js
│ │ │ ├── is-task-dependent.js
│ │ │ ├── list-tasks.js
│ │ │ ├── migrate.js
│ │ │ ├── models.js
│ │ │ ├── move-task.js
│ │ │ ├── parse-prd
│ │ │ │ ├── index.js
│ │ │ │ ├── parse-prd-config.js
│ │ │ │ ├── parse-prd-helpers.js
│ │ │ │ ├── parse-prd-non-streaming.js
│ │ │ │ ├── parse-prd-streaming.js
│ │ │ │ └── parse-prd.js
│ │ │ ├── remove-subtask.js
│ │ │ ├── remove-task.js
│ │ │ ├── research.js
│ │ │ ├── response-language.js
│ │ │ ├── scope-adjustment.js
│ │ │ ├── set-task-status.js
│ │ │ ├── tag-management.js
│ │ │ ├── task-exists.js
│ │ │ ├── update-single-task-status.js
│ │ │ ├── update-subtask-by-id.js
│ │ │ ├── update-task-by-id.js
│ │ │ └── update-tasks.js
│ │ ├── task-manager.js
│ │ ├── ui.js
│ │ ├── update-config-tokens.js
│ │ ├── utils
│ │ │ ├── contextGatherer.js
│ │ │ ├── fuzzyTaskSearch.js
│ │ │ └── git-utils.js
│ │ └── utils.js
│ ├── task-complexity-report.json
│ ├── test-claude-errors.js
│ └── test-claude.js
├── sonar-project.properties
├── src
│ ├── ai-providers
│ │ ├── anthropic.js
│ │ ├── azure.js
│ │ ├── base-provider.js
│ │ ├── bedrock.js
│ │ ├── claude-code.js
│ │ ├── codex-cli.js
│ │ ├── gemini-cli.js
│ │ ├── google-vertex.js
│ │ ├── google.js
│ │ ├── grok-cli.js
│ │ ├── groq.js
│ │ ├── index.js
│ │ ├── lmstudio.js
│ │ ├── ollama.js
│ │ ├── openai-compatible.js
│ │ ├── openai.js
│ │ ├── openrouter.js
│ │ ├── perplexity.js
│ │ ├── xai.js
│ │ ├── zai-coding.js
│ │ └── zai.js
│ ├── constants
│ │ ├── commands.js
│ │ ├── paths.js
│ │ ├── profiles.js
│ │ ├── rules-actions.js
│ │ ├── task-priority.js
│ │ └── task-status.js
│ ├── profiles
│ │ ├── amp.js
│ │ ├── base-profile.js
│ │ ├── claude.js
│ │ ├── cline.js
│ │ ├── codex.js
│ │ ├── cursor.js
│ │ ├── gemini.js
│ │ ├── index.js
│ │ ├── kilo.js
│ │ ├── kiro.js
│ │ ├── opencode.js
│ │ ├── roo.js
│ │ ├── trae.js
│ │ ├── vscode.js
│ │ ├── windsurf.js
│ │ └── zed.js
│ ├── progress
│ │ ├── base-progress-tracker.js
│ │ ├── cli-progress-factory.js
│ │ ├── parse-prd-tracker.js
│ │ ├── progress-tracker-builder.js
│ │ └── tracker-ui.js
│ ├── prompts
│ │ ├── add-task.json
│ │ ├── analyze-complexity.json
│ │ ├── expand-task.json
│ │ ├── parse-prd.json
│ │ ├── README.md
│ │ ├── research.json
│ │ ├── schemas
│ │ │ ├── parameter.schema.json
│ │ │ ├── prompt-template.schema.json
│ │ │ ├── README.md
│ │ │ └── variant.schema.json
│ │ ├── update-subtask.json
│ │ ├── update-task.json
│ │ └── update-tasks.json
│ ├── provider-registry
│ │ └── index.js
│ ├── schemas
│ │ ├── add-task.js
│ │ ├── analyze-complexity.js
│ │ ├── base-schemas.js
│ │ ├── expand-task.js
│ │ ├── parse-prd.js
│ │ ├── registry.js
│ │ ├── update-subtask.js
│ │ ├── update-task.js
│ │ └── update-tasks.js
│ ├── task-master.js
│ ├── ui
│ │ ├── confirm.js
│ │ ├── indicators.js
│ │ └── parse-prd.js
│ └── utils
│ ├── asset-resolver.js
│ ├── create-mcp-config.js
│ ├── format.js
│ ├── getVersion.js
│ ├── logger-utils.js
│ ├── manage-gitignore.js
│ ├── path-utils.js
│ ├── profiles.js
│ ├── rule-transformer.js
│ ├── stream-parser.js
│ └── timeout-manager.js
├── test-clean-tags.js
├── test-config-manager.js
├── test-prd.txt
├── test-tag-functions.js
├── test-version-check-full.js
├── test-version-check.js
├── tests
│ ├── e2e
│ │ ├── e2e_helpers.sh
│ │ ├── parse_llm_output.cjs
│ │ ├── run_e2e.sh
│ │ ├── run_fallback_verification.sh
│ │ └── test_llm_analysis.sh
│ ├── fixtures
│ │ ├── .taskmasterconfig
│ │ ├── sample-claude-response.js
│ │ ├── sample-prd.txt
│ │ └── sample-tasks.js
│ ├── helpers
│ │ └── tool-counts.js
│ ├── integration
│ │ ├── claude-code-error-handling.test.js
│ │ ├── claude-code-optional.test.js
│ │ ├── cli
│ │ │ ├── commands.test.js
│ │ │ ├── complex-cross-tag-scenarios.test.js
│ │ │ └── move-cross-tag.test.js
│ │ ├── manage-gitignore.test.js
│ │ ├── mcp-server
│ │ │ └── direct-functions.test.js
│ │ ├── move-task-cross-tag.integration.test.js
│ │ ├── move-task-simple.integration.test.js
│ │ ├── profiles
│ │ │ ├── amp-init-functionality.test.js
│ │ │ ├── claude-init-functionality.test.js
│ │ │ ├── cline-init-functionality.test.js
│ │ │ ├── codex-init-functionality.test.js
│ │ │ ├── cursor-init-functionality.test.js
│ │ │ ├── gemini-init-functionality.test.js
│ │ │ ├── opencode-init-functionality.test.js
│ │ │ ├── roo-files-inclusion.test.js
│ │ │ ├── roo-init-functionality.test.js
│ │ │ ├── rules-files-inclusion.test.js
│ │ │ ├── trae-init-functionality.test.js
│ │ │ ├── vscode-init-functionality.test.js
│ │ │ └── windsurf-init-functionality.test.js
│ │ └── providers
│ │ └── temperature-support.test.js
│ ├── manual
│ │ ├── progress
│ │ │ ├── parse-prd-analysis.js
│ │ │ ├── test-parse-prd.js
│ │ │ └── TESTING_GUIDE.md
│ │ └── prompts
│ │ ├── prompt-test.js
│ │ └── README.md
│ ├── README.md
│ ├── setup.js
│ └── unit
│ ├── ai-providers
│ │ ├── base-provider.test.js
│ │ ├── claude-code.test.js
│ │ ├── codex-cli.test.js
│ │ ├── gemini-cli.test.js
│ │ ├── lmstudio.test.js
│ │ ├── mcp-components.test.js
│ │ ├── openai-compatible.test.js
│ │ ├── openai.test.js
│ │ ├── provider-registry.test.js
│ │ ├── zai-coding.test.js
│ │ ├── zai-provider.test.js
│ │ ├── zai-schema-introspection.test.js
│ │ └── zai.test.js
│ ├── ai-services-unified.test.js
│ ├── commands.test.js
│ ├── config-manager.test.js
│ ├── config-manager.test.mjs
│ ├── dependency-manager.test.js
│ ├── init.test.js
│ ├── initialize-project.test.js
│ ├── kebab-case-validation.test.js
│ ├── manage-gitignore.test.js
│ ├── mcp
│ │ └── tools
│ │ ├── __mocks__
│ │ │ └── move-task.js
│ │ ├── add-task.test.js
│ │ ├── analyze-complexity.test.js
│ │ ├── expand-all.test.js
│ │ ├── get-tasks.test.js
│ │ ├── initialize-project.test.js
│ │ ├── move-task-cross-tag-options.test.js
│ │ ├── move-task-cross-tag.test.js
│ │ ├── remove-task.test.js
│ │ └── tool-registration.test.js
│ ├── mcp-providers
│ │ ├── mcp-components.test.js
│ │ └── mcp-provider.test.js
│ ├── parse-prd.test.js
│ ├── profiles
│ │ ├── amp-integration.test.js
│ │ ├── claude-integration.test.js
│ │ ├── cline-integration.test.js
│ │ ├── codex-integration.test.js
│ │ ├── cursor-integration.test.js
│ │ ├── gemini-integration.test.js
│ │ ├── kilo-integration.test.js
│ │ ├── kiro-integration.test.js
│ │ ├── mcp-config-validation.test.js
│ │ ├── opencode-integration.test.js
│ │ ├── profile-safety-check.test.js
│ │ ├── roo-integration.test.js
│ │ ├── rule-transformer-cline.test.js
│ │ ├── rule-transformer-cursor.test.js
│ │ ├── rule-transformer-gemini.test.js
│ │ ├── rule-transformer-kilo.test.js
│ │ ├── rule-transformer-kiro.test.js
│ │ ├── rule-transformer-opencode.test.js
│ │ ├── rule-transformer-roo.test.js
│ │ ├── rule-transformer-trae.test.js
│ │ ├── rule-transformer-vscode.test.js
│ │ ├── rule-transformer-windsurf.test.js
│ │ ├── rule-transformer-zed.test.js
│ │ ├── rule-transformer.test.js
│ │ ├── selective-profile-removal.test.js
│ │ ├── subdirectory-support.test.js
│ │ ├── trae-integration.test.js
│ │ ├── vscode-integration.test.js
│ │ ├── windsurf-integration.test.js
│ │ └── zed-integration.test.js
│ ├── progress
│ │ └── base-progress-tracker.test.js
│ ├── prompt-manager.test.js
│ ├── prompts
│ │ ├── expand-task-prompt.test.js
│ │ └── prompt-migration.test.js
│ ├── scripts
│ │ └── modules
│ │ ├── commands
│ │ │ ├── move-cross-tag.test.js
│ │ │ └── README.md
│ │ ├── dependency-manager
│ │ │ ├── circular-dependencies.test.js
│ │ │ ├── cross-tag-dependencies.test.js
│ │ │ └── fix-dependencies-command.test.js
│ │ ├── task-manager
│ │ │ ├── add-subtask.test.js
│ │ │ ├── add-task.test.js
│ │ │ ├── analyze-task-complexity.test.js
│ │ │ ├── clear-subtasks.test.js
│ │ │ ├── complexity-report-tag-isolation.test.js
│ │ │ ├── expand-all-tasks.test.js
│ │ │ ├── expand-task.test.js
│ │ │ ├── find-next-task.test.js
│ │ │ ├── generate-task-files.test.js
│ │ │ ├── list-tasks.test.js
│ │ │ ├── models-baseurl.test.js
│ │ │ ├── move-task-cross-tag.test.js
│ │ │ ├── move-task.test.js
│ │ │ ├── parse-prd-schema.test.js
│ │ │ ├── parse-prd.test.js
│ │ │ ├── remove-subtask.test.js
│ │ │ ├── remove-task.test.js
│ │ │ ├── research.test.js
│ │ │ ├── scope-adjustment.test.js
│ │ │ ├── set-task-status.test.js
│ │ │ ├── setup.js
│ │ │ ├── update-single-task-status.test.js
│ │ │ ├── update-subtask-by-id.test.js
│ │ │ ├── update-task-by-id.test.js
│ │ │ └── update-tasks.test.js
│ │ ├── ui
│ │ │ └── cross-tag-error-display.test.js
│ │ └── utils-tag-aware-paths.test.js
│ ├── task-finder.test.js
│ ├── task-manager
│ │ ├── clear-subtasks.test.js
│ │ ├── move-task.test.js
│ │ ├── tag-boundary.test.js
│ │ └── tag-management.test.js
│ ├── task-master.test.js
│ ├── ui
│ │ └── indicators.test.js
│ ├── ui.test.js
│ ├── utils-strip-ansi.test.js
│ └── utils.test.js
├── tsconfig.json
├── tsdown.config.ts
├── turbo.json
└── update-task-migration-plan.md
```
# Files
--------------------------------------------------------------------------------
/packages/tm-core/tests/integration/auth-token-refresh.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview Integration tests for JWT token auto-refresh functionality
3 | *
4 | * These tests verify that expired tokens are automatically refreshed
5 | * when making API calls through AuthManager.
6 | */
7 |
8 | import fs from 'fs';
9 | import os from 'os';
10 | import path from 'path';
11 | import type { Session } from '@supabase/supabase-js';
12 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13 | import { AuthManager } from '../../src/modules/auth/managers/auth-manager.js';
14 | import { CredentialStore } from '../../src/modules/auth/services/credential-store.js';
15 | import type { AuthCredentials } from '../../src/modules/auth/types.js';
16 |
17 | describe('AuthManager - Token Auto-Refresh Integration', () => {
18 | let authManager: AuthManager;
19 | let credentialStore: CredentialStore;
20 | let tmpDir: string;
21 | let authFile: string;
22 |
23 | // Mock Supabase session that will be returned on refresh
24 | const mockRefreshedSession: Session = {
25 | access_token: 'new-access-token-xyz',
26 | refresh_token: 'new-refresh-token-xyz',
27 | token_type: 'bearer',
28 | expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
29 | expires_in: 3600,
30 | user: {
31 | id: 'test-user-id',
32 | email: '[email protected]',
33 | aud: 'authenticated',
34 | role: 'authenticated',
35 | app_metadata: {},
36 | user_metadata: {},
37 | created_at: new Date().toISOString()
38 | }
39 | };
40 |
41 | beforeEach(() => {
42 | // Reset singletons
43 | AuthManager.resetInstance();
44 | CredentialStore.resetInstance();
45 |
46 | // Create temporary directory for test isolation
47 | tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-auth-integration-'));
48 | authFile = path.join(tmpDir, 'auth.json');
49 |
50 | // Initialize AuthManager with test config (this will create CredentialStore internally)
51 | authManager = AuthManager.getInstance({
52 | configDir: tmpDir,
53 | configFile: authFile
54 | });
55 |
56 | // Get the CredentialStore instance that AuthManager created
57 | credentialStore = CredentialStore.getInstance();
58 | credentialStore.clearCredentials();
59 | });
60 |
61 | afterEach(() => {
62 | // Clean up
63 | try {
64 | credentialStore.clearCredentials();
65 | } catch {
66 | // Ignore cleanup errors
67 | }
68 | AuthManager.resetInstance();
69 | CredentialStore.resetInstance();
70 | vi.restoreAllMocks();
71 |
72 | // Remove temporary directory
73 | if (tmpDir && fs.existsSync(tmpDir)) {
74 | fs.rmSync(tmpDir, { recursive: true, force: true });
75 | }
76 | });
77 |
78 | describe('Expired Token Detection', () => {
79 | it('should return expired token for Supabase to refresh', () => {
80 | // Set up expired credentials
81 | const expiredCredentials: AuthCredentials = {
82 | token: 'expired-token',
83 | refreshToken: 'valid-refresh-token',
84 | userId: 'test-user-id',
85 | email: '[email protected]',
86 | expiresAt: new Date(Date.now() - 60000).toISOString(), // 1 minute ago
87 | savedAt: new Date().toISOString()
88 | };
89 |
90 | credentialStore.saveCredentials(expiredCredentials);
91 |
92 | authManager = AuthManager.getInstance();
93 |
94 | // Get credentials returns them even if expired
95 | const credentials = authManager.getCredentials();
96 |
97 | expect(credentials).not.toBeNull();
98 | expect(credentials?.token).toBe('expired-token');
99 | expect(credentials?.refreshToken).toBe('valid-refresh-token');
100 | });
101 |
102 | it('should return valid token', () => {
103 | // Set up valid credentials
104 | const validCredentials: AuthCredentials = {
105 | token: 'valid-token',
106 | refreshToken: 'valid-refresh-token',
107 | userId: 'test-user-id',
108 | email: '[email protected]',
109 | expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now
110 | savedAt: new Date().toISOString()
111 | };
112 |
113 | credentialStore.saveCredentials(validCredentials);
114 |
115 | authManager = AuthManager.getInstance();
116 |
117 | const credentials = authManager.getCredentials();
118 |
119 | expect(credentials?.token).toBe('valid-token');
120 | });
121 | });
122 |
123 | describe('Token Refresh Flow', () => {
124 | it('should manually refresh expired token and save new credentials', async () => {
125 | const expiredCredentials: AuthCredentials = {
126 | token: 'old-token',
127 | refreshToken: 'old-refresh-token',
128 | userId: 'test-user-id',
129 | email: '[email protected]',
130 | expiresAt: new Date(Date.now() - 60000).toISOString(),
131 | savedAt: new Date(Date.now() - 3600000).toISOString(),
132 | selectedContext: {
133 | orgId: 'test-org',
134 | briefId: 'test-brief',
135 | updatedAt: new Date().toISOString()
136 | }
137 | };
138 |
139 | credentialStore.saveCredentials(expiredCredentials);
140 |
141 | authManager = AuthManager.getInstance();
142 |
143 | vi.spyOn(
144 | authManager['supabaseClient'],
145 | 'refreshSession'
146 | ).mockResolvedValue(mockRefreshedSession);
147 |
148 | // Explicitly call refreshToken() method
149 | const refreshedCredentials = await authManager.refreshToken();
150 |
151 | expect(refreshedCredentials).not.toBeNull();
152 | expect(refreshedCredentials.token).toBe('new-access-token-xyz');
153 | expect(refreshedCredentials.refreshToken).toBe('new-refresh-token-xyz');
154 |
155 | // Verify context was preserved
156 | expect(refreshedCredentials.selectedContext?.orgId).toBe('test-org');
157 | expect(refreshedCredentials.selectedContext?.briefId).toBe('test-brief');
158 |
159 | // Verify new expiration is in the future
160 | const newExpiry = new Date(refreshedCredentials.expiresAt!).getTime();
161 | const now = Date.now();
162 | expect(newExpiry).toBeGreaterThan(now);
163 | });
164 |
165 | it('should throw error if manual refresh fails', async () => {
166 | const expiredCredentials: AuthCredentials = {
167 | token: 'expired-token',
168 | refreshToken: 'invalid-refresh-token',
169 | userId: 'test-user-id',
170 | email: '[email protected]',
171 | expiresAt: new Date(Date.now() - 60000).toISOString(),
172 | savedAt: new Date().toISOString()
173 | };
174 |
175 | credentialStore.saveCredentials(expiredCredentials);
176 |
177 | authManager = AuthManager.getInstance();
178 |
179 | // Mock refresh to fail
180 | vi.spyOn(
181 | authManager['supabaseClient'],
182 | 'refreshSession'
183 | ).mockRejectedValue(new Error('Refresh token expired'));
184 |
185 | // Explicit refreshToken() call should throw
186 | await expect(authManager.refreshToken()).rejects.toThrow();
187 | });
188 |
189 | it('should return expired credentials even without refresh token', () => {
190 | const expiredCredentials: AuthCredentials = {
191 | token: 'expired-token',
192 | // No refresh token
193 | userId: 'test-user-id',
194 | email: '[email protected]',
195 | expiresAt: new Date(Date.now() - 60000).toISOString(),
196 | savedAt: new Date().toISOString()
197 | };
198 |
199 | credentialStore.saveCredentials(expiredCredentials);
200 |
201 | authManager = AuthManager.getInstance();
202 |
203 | const credentials = authManager.getCredentials();
204 |
205 | // Credentials are returned even without refresh token
206 | expect(credentials).not.toBeNull();
207 | expect(credentials?.token).toBe('expired-token');
208 | expect(credentials?.refreshToken).toBeUndefined();
209 | });
210 |
211 | it('should return null if credentials missing expiresAt', () => {
212 | const credentialsWithoutExpiry: AuthCredentials = {
213 | token: 'test-token',
214 | refreshToken: 'refresh-token',
215 | userId: 'test-user-id',
216 | email: '[email protected]',
217 | // Missing expiresAt - invalid token
218 | savedAt: new Date().toISOString()
219 | } as any;
220 |
221 | credentialStore.saveCredentials(credentialsWithoutExpiry);
222 |
223 | authManager = AuthManager.getInstance();
224 |
225 | const credentials = authManager.getCredentials();
226 |
227 | // Tokens without valid expiration are considered invalid
228 | expect(credentials).toBeNull();
229 | });
230 | });
231 |
232 | describe('Clock Skew Tolerance', () => {
233 | it('should return credentials within 30-second expiry window', () => {
234 | // Token expires in 15 seconds (within 30-second buffer)
235 | // Supabase will handle refresh automatically
236 | const almostExpiredCredentials: AuthCredentials = {
237 | token: 'almost-expired-token',
238 | refreshToken: 'valid-refresh-token',
239 | userId: 'test-user-id',
240 | email: '[email protected]',
241 | expiresAt: new Date(Date.now() + 15000).toISOString(), // 15 seconds from now
242 | savedAt: new Date().toISOString()
243 | };
244 |
245 | credentialStore.saveCredentials(almostExpiredCredentials);
246 |
247 | authManager = AuthManager.getInstance();
248 |
249 | const credentials = authManager.getCredentials();
250 |
251 | // Credentials are returned (Supabase handles auto-refresh in background)
252 | expect(credentials).not.toBeNull();
253 | expect(credentials?.token).toBe('almost-expired-token');
254 | expect(credentials?.refreshToken).toBe('valid-refresh-token');
255 | });
256 |
257 | it('should return valid token well before expiry', () => {
258 | // Token expires in 5 minutes
259 | const validCredentials: AuthCredentials = {
260 | token: 'valid-token',
261 | refreshToken: 'valid-refresh-token',
262 | userId: 'test-user-id',
263 | email: '[email protected]',
264 | expiresAt: new Date(Date.now() + 300000).toISOString(), // 5 minutes
265 | savedAt: new Date().toISOString()
266 | };
267 |
268 | credentialStore.saveCredentials(validCredentials);
269 |
270 | authManager = AuthManager.getInstance();
271 |
272 | const credentials = authManager.getCredentials();
273 |
274 | // Valid credentials are returned as-is
275 | expect(credentials).not.toBeNull();
276 | expect(credentials?.token).toBe('valid-token');
277 | expect(credentials?.refreshToken).toBe('valid-refresh-token');
278 | });
279 | });
280 |
281 | describe('Synchronous vs Async Methods', () => {
282 | it('getCredentials should return expired credentials', () => {
283 | const expiredCredentials: AuthCredentials = {
284 | token: 'expired-token',
285 | refreshToken: 'valid-refresh-token',
286 | userId: 'test-user-id',
287 | email: '[email protected]',
288 | expiresAt: new Date(Date.now() - 60000).toISOString(),
289 | savedAt: new Date().toISOString()
290 | };
291 |
292 | credentialStore.saveCredentials(expiredCredentials);
293 |
294 | authManager = AuthManager.getInstance();
295 |
296 | // Returns credentials even if expired - Supabase will handle refresh
297 | const credentials = authManager.getCredentials();
298 |
299 | expect(credentials).not.toBeNull();
300 | expect(credentials?.token).toBe('expired-token');
301 | expect(credentials?.refreshToken).toBe('valid-refresh-token');
302 | });
303 | });
304 |
305 | describe('Multiple Concurrent Calls', () => {
306 | it('should handle concurrent getCredentials calls gracefully', () => {
307 | const expiredCredentials: AuthCredentials = {
308 | token: 'expired-token',
309 | refreshToken: 'valid-refresh-token',
310 | userId: 'test-user-id',
311 | email: '[email protected]',
312 | expiresAt: new Date(Date.now() - 60000).toISOString(),
313 | savedAt: new Date().toISOString()
314 | };
315 |
316 | credentialStore.saveCredentials(expiredCredentials);
317 |
318 | authManager = AuthManager.getInstance();
319 |
320 | // Make multiple concurrent calls (synchronous now)
321 | const creds1 = authManager.getCredentials();
322 | const creds2 = authManager.getCredentials();
323 | const creds3 = authManager.getCredentials();
324 |
325 | // All should get the same credentials (even if expired)
326 | expect(creds1?.token).toBe('expired-token');
327 | expect(creds2?.token).toBe('expired-token');
328 | expect(creds3?.token).toBe('expired-token');
329 |
330 | // All include refresh token for Supabase to use
331 | expect(creds1?.refreshToken).toBe('valid-refresh-token');
332 | expect(creds2?.refreshToken).toBe('valid-refresh-token');
333 | expect(creds3?.refreshToken).toBe('valid-refresh-token');
334 | });
335 | });
336 | });
337 |
```
--------------------------------------------------------------------------------
/apps/extension/src/components/TaskDetails/TaskMetadataSidebar.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import type React from 'react';
2 | import { useState, useEffect } from 'react';
3 | import { Button } from '@/components/ui/button';
4 | import { Loader2, Play } from 'lucide-react';
5 | import { PriorityBadge } from './PriorityBadge';
6 | import type { TaskMasterTask } from '../../webview/types';
7 | import { useVSCodeContext } from '../../webview/contexts/VSCodeContext';
8 |
9 | interface TaskMetadataSidebarProps {
10 | currentTask: TaskMasterTask;
11 | tasks: TaskMasterTask[];
12 | complexity: any;
13 | isSubtask: boolean;
14 | onStatusChange: (status: TaskMasterTask['status']) => void;
15 | onDependencyClick: (depId: string) => void;
16 | isRegenerating?: boolean;
17 | isAppending?: boolean;
18 | }
19 |
20 | export const TaskMetadataSidebar: React.FC<TaskMetadataSidebarProps> = ({
21 | currentTask,
22 | tasks,
23 | complexity,
24 | isSubtask,
25 | onStatusChange,
26 | onDependencyClick,
27 | isRegenerating = false,
28 | isAppending = false
29 | }) => {
30 | const { sendMessage } = useVSCodeContext();
31 | const [isLoadingComplexity, setIsLoadingComplexity] = useState(false);
32 | const [mcpComplexityScore, setMcpComplexityScore] = useState<
33 | number | undefined
34 | >(undefined);
35 | const [isStartingTask, setIsStartingTask] = useState(false);
36 |
37 | // Get complexity score from task
38 | const currentComplexityScore = complexity?.score;
39 |
40 | // Display logic - use MCP score if available, otherwise use current score
41 | const displayComplexityScore =
42 | mcpComplexityScore !== undefined
43 | ? mcpComplexityScore
44 | : currentComplexityScore;
45 |
46 | // Fetch complexity from MCP when needed
47 | const fetchComplexityFromMCP = async (force = false) => {
48 | if (!currentTask || (!force && currentComplexityScore !== undefined)) {
49 | return;
50 | }
51 | setIsLoadingComplexity(true);
52 | try {
53 | const complexityResult = await sendMessage({
54 | type: 'mcpRequest',
55 | tool: 'complexity_report',
56 | params: {}
57 | });
58 | if (complexityResult?.data?.report?.complexityAnalysis) {
59 | const taskComplexity =
60 | complexityResult.data.report.complexityAnalysis.tasks?.find(
61 | (t: any) => t.id === currentTask.id
62 | );
63 | if (taskComplexity) {
64 | setMcpComplexityScore(taskComplexity.complexityScore);
65 | }
66 | }
67 | } catch (error) {
68 | console.error('Failed to fetch complexity from MCP:', error);
69 | } finally {
70 | setIsLoadingComplexity(false);
71 | }
72 | };
73 |
74 | // Handle running complexity analysis for a task
75 | const handleRunComplexityAnalysis = async () => {
76 | if (!currentTask) {
77 | return;
78 | }
79 | setIsLoadingComplexity(true);
80 | try {
81 | // Run complexity analysis on this specific task
82 | await sendMessage({
83 | type: 'mcpRequest',
84 | tool: 'analyze_project_complexity',
85 | params: {
86 | ids: currentTask.id.toString(),
87 | research: false
88 | }
89 | });
90 | // After analysis, fetch the updated complexity report
91 | setTimeout(() => {
92 | fetchComplexityFromMCP(true);
93 | }, 1000);
94 | } catch (error) {
95 | console.error('Failed to run complexity analysis:', error);
96 | } finally {
97 | setIsLoadingComplexity(false);
98 | }
99 | };
100 |
101 | // Handle starting a task
102 | const handleStartTask = async () => {
103 | if (!currentTask || isStartingTask) {
104 | return;
105 | }
106 |
107 | setIsStartingTask(true);
108 |
109 | try {
110 | // Send message to extension to open terminal
111 | const result = await sendMessage({
112 | type: 'openTerminal',
113 | data: {
114 | taskId: currentTask.id,
115 | taskTitle: currentTask.title
116 | }
117 | });
118 |
119 | // Handle the response
120 | if (result && !result.success) {
121 | console.error('Terminal execution failed:', result.error);
122 | // The extension will show VS Code error notification and webview toast
123 | } else if (result && result.success) {
124 | console.log('Terminal started successfully:', result.terminalName);
125 | }
126 | } catch (error) {
127 | console.error('Failed to start task:', error);
128 | // This handles network/communication errors
129 | } finally {
130 | // Reset loading state
131 | setIsStartingTask(false);
132 | }
133 | };
134 |
135 | // Effect to handle complexity on task change
136 | useEffect(() => {
137 | if (currentTask?.id) {
138 | setMcpComplexityScore(undefined);
139 | if (currentComplexityScore === undefined) {
140 | fetchComplexityFromMCP();
141 | }
142 | }
143 | }, [currentTask?.id, currentComplexityScore]);
144 |
145 | return (
146 | <div className="md:col-span-1 border-l border-textSeparator-foreground">
147 | <div className="p-6">
148 | <div className="space-y-6">
149 | <div>
150 | <h3 className="text-sm font-medium text-vscode-foreground/70 mb-3">
151 | Properties
152 | </h3>
153 | </div>
154 |
155 | <div className="space-y-4">
156 | {/* Status */}
157 | <div className="flex items-center justify-between">
158 | <span className="text-sm text-vscode-foreground/70">Status</span>
159 | <select
160 | value={currentTask.status}
161 | onChange={(e) =>
162 | onStatusChange(e.target.value as TaskMasterTask['status'])
163 | }
164 | className="border rounded-md px-3 py-1 text-sm font-medium focus:ring-1 focus:border-vscode-focusBorder focus:ring-vscode-focusBorder"
165 | style={{
166 | backgroundColor:
167 | currentTask.status === 'pending'
168 | ? 'rgba(156, 163, 175, 0.2)'
169 | : currentTask.status === 'in-progress'
170 | ? 'rgba(245, 158, 11, 0.2)'
171 | : currentTask.status === 'review'
172 | ? 'rgba(59, 130, 246, 0.2)'
173 | : currentTask.status === 'done'
174 | ? 'rgba(34, 197, 94, 0.2)'
175 | : currentTask.status === 'deferred'
176 | ? 'rgba(239, 68, 68, 0.2)'
177 | : 'var(--vscode-input-background)',
178 | color:
179 | currentTask.status === 'pending'
180 | ? 'var(--vscode-foreground)'
181 | : currentTask.status === 'in-progress'
182 | ? '#d97706'
183 | : currentTask.status === 'review'
184 | ? '#2563eb'
185 | : currentTask.status === 'done'
186 | ? '#16a34a'
187 | : currentTask.status === 'deferred'
188 | ? '#dc2626'
189 | : 'var(--vscode-foreground)',
190 | borderColor:
191 | currentTask.status === 'pending'
192 | ? 'rgba(156, 163, 175, 0.4)'
193 | : currentTask.status === 'in-progress'
194 | ? 'rgba(245, 158, 11, 0.4)'
195 | : currentTask.status === 'review'
196 | ? 'rgba(59, 130, 246, 0.4)'
197 | : currentTask.status === 'done'
198 | ? 'rgba(34, 197, 94, 0.4)'
199 | : currentTask.status === 'deferred'
200 | ? 'rgba(239, 68, 68, 0.4)'
201 | : 'var(--vscode-input-border)'
202 | }}
203 | >
204 | <option value="pending">To do</option>
205 | <option value="in-progress">In Progress</option>
206 | <option value="review">Review</option>
207 | <option value="done">Done</option>
208 | <option value="deferred">Deferred</option>
209 | </select>
210 | </div>
211 |
212 | {/* Priority */}
213 | <div className="flex items-center justify-between">
214 | <span className="text-sm text-muted-foreground">Priority</span>
215 | <PriorityBadge priority={currentTask.priority} />
216 | </div>
217 |
218 | {/* Complexity Score */}
219 | <div className="space-y-2">
220 | <label className="text-sm font-medium text-[var(--vscode-foreground)]">
221 | Complexity Score
222 | </label>
223 | {isLoadingComplexity ? (
224 | <div className="flex items-center gap-2">
225 | <Loader2 className="w-4 h-4 animate-spin text-[var(--vscode-descriptionForeground)]" />
226 | <span className="text-sm text-[var(--vscode-descriptionForeground)]">
227 | Loading...
228 | </span>
229 | </div>
230 | ) : displayComplexityScore !== undefined ? (
231 | <div className="flex items-center gap-2">
232 | <span className="text-sm font-medium text-[var(--vscode-foreground)]">
233 | {displayComplexityScore}/10
234 | </span>
235 | <div
236 | className={`flex-1 rounded-full h-2 ${
237 | displayComplexityScore >= 7
238 | ? 'bg-red-500/20'
239 | : displayComplexityScore >= 4
240 | ? 'bg-yellow-500/20'
241 | : 'bg-green-500/20'
242 | }`}
243 | >
244 | <div
245 | className={`h-2 rounded-full transition-all duration-300 ${
246 | displayComplexityScore >= 7
247 | ? 'bg-red-500'
248 | : displayComplexityScore >= 4
249 | ? 'bg-yellow-500'
250 | : 'bg-green-500'
251 | }`}
252 | style={{
253 | width: `${(displayComplexityScore || 0) * 10}%`
254 | }}
255 | />
256 | </div>
257 | </div>
258 | ) : currentTask?.status === 'done' ||
259 | currentTask?.status === 'deferred' ||
260 | currentTask?.status === 'review' ? (
261 | <div className="text-sm text-[var(--vscode-descriptionForeground)]">
262 | N/A
263 | </div>
264 | ) : (
265 | <>
266 | <div className="text-sm text-[var(--vscode-descriptionForeground)]">
267 | No complexity score available
268 | </div>
269 | <div className="mt-3">
270 | <Button
271 | onClick={() => handleRunComplexityAnalysis()}
272 | variant="outline"
273 | size="sm"
274 | className="text-xs"
275 | disabled={isRegenerating || isAppending}
276 | >
277 | Run Complexity Analysis
278 | </Button>
279 | </div>
280 | </>
281 | )}
282 | </div>
283 | </div>
284 | <div className="border-b border-textSeparator-foreground" />
285 |
286 | {/* Dependencies */}
287 | {currentTask.dependencies && currentTask.dependencies.length > 0 && (
288 | <div>
289 | <h4 className="text-sm font-medium text-vscode-foreground/70 mb-3">
290 | Dependencies
291 | </h4>
292 | <div className="space-y-2">
293 | {currentTask.dependencies.map((depId) => {
294 | // Convert both to string for comparison since depId might be string or number
295 | const depTask = tasks.find(
296 | (t) => String(t.id) === String(depId)
297 | );
298 | const fullTitle = `Task ${depId}: ${depTask?.title || 'Unknown Task'}`;
299 | const truncatedTitle =
300 | fullTitle.length > 40
301 | ? fullTitle.substring(0, 37) + '...'
302 | : fullTitle;
303 | return (
304 | <div
305 | key={depId}
306 | className="text-sm text-link cursor-pointer hover:text-link-hover"
307 | onClick={() => onDependencyClick(depId)}
308 | title={fullTitle}
309 | >
310 | {truncatedTitle}
311 | </div>
312 | );
313 | })}
314 | </div>
315 | </div>
316 | )}
317 |
318 | {/* Divider after Dependencies */}
319 | {currentTask.dependencies && currentTask.dependencies.length > 0 && (
320 | <div className="border-b border-textSeparator-foreground" />
321 | )}
322 |
323 | {/* Start Task Button */}
324 | <div className="mt-4">
325 | <Button
326 | onClick={handleStartTask}
327 | variant="default"
328 | size="sm"
329 | className="w-full text-xs"
330 | disabled={
331 | isRegenerating ||
332 | isAppending ||
333 | isStartingTask ||
334 | currentTask?.status === 'done' ||
335 | currentTask?.status === 'in-progress'
336 | }
337 | >
338 | {isStartingTask ? (
339 | <Loader2 className="w-4 h-4 mr-2 animate-spin" />
340 | ) : (
341 | <Play className="w-4 h-4 mr-2" />
342 | )}
343 | {isStartingTask ? 'Starting...' : 'Start Task'}
344 | </Button>
345 | </div>
346 | </div>
347 | </div>
348 | </div>
349 | );
350 | };
351 |
```
--------------------------------------------------------------------------------
/scripts/modules/utils/fuzzyTaskSearch.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * fuzzyTaskSearch.js
3 | * Reusable fuzzy search utility for finding relevant tasks based on semantic similarity
4 | */
5 |
6 | import Fuse from 'fuse.js';
7 |
8 | /**
9 | * Configuration for different search contexts
10 | */
11 | const SEARCH_CONFIGS = {
12 | research: {
13 | threshold: 0.5, // More lenient for research (broader context)
14 | limit: 20,
15 | keys: [
16 | { name: 'title', weight: 2.0 },
17 | { name: 'description', weight: 1.0 },
18 | { name: 'details', weight: 0.5 },
19 | { name: 'dependencyTitles', weight: 0.5 }
20 | ]
21 | },
22 | addTask: {
23 | threshold: 0.4, // Stricter for add-task (more precise context)
24 | limit: 15,
25 | keys: [
26 | { name: 'title', weight: 2.0 },
27 | { name: 'description', weight: 1.5 },
28 | { name: 'details', weight: 0.8 },
29 | { name: 'dependencyTitles', weight: 0.5 }
30 | ]
31 | },
32 | default: {
33 | threshold: 0.4,
34 | limit: 15,
35 | keys: [
36 | { name: 'title', weight: 2.0 },
37 | { name: 'description', weight: 1.5 },
38 | { name: 'details', weight: 1.0 },
39 | { name: 'dependencyTitles', weight: 0.5 }
40 | ]
41 | }
42 | };
43 |
44 | /**
45 | * Purpose categories for pattern-based task matching
46 | */
47 | const PURPOSE_CATEGORIES = [
48 | { pattern: /(command|cli|flag)/i, label: 'CLI commands' },
49 | { pattern: /(task|subtask|add)/i, label: 'Task management' },
50 | { pattern: /(dependency|depend)/i, label: 'Dependency handling' },
51 | { pattern: /(AI|model|prompt|research)/i, label: 'AI integration' },
52 | { pattern: /(UI|display|show|interface)/i, label: 'User interface' },
53 | { pattern: /(schedule|time|cron)/i, label: 'Scheduling' },
54 | { pattern: /(config|setting|option)/i, label: 'Configuration' },
55 | { pattern: /(test|testing|spec)/i, label: 'Testing' },
56 | { pattern: /(auth|login|user)/i, label: 'Authentication' },
57 | { pattern: /(database|db|data)/i, label: 'Data management' },
58 | { pattern: /(api|endpoint|route)/i, label: 'API development' },
59 | { pattern: /(deploy|build|release)/i, label: 'Deployment' },
60 | { pattern: /(security|auth|login|user)/i, label: 'Security' },
61 | { pattern: /.*/, label: 'Other' }
62 | ];
63 |
64 | /**
65 | * Relevance score thresholds
66 | */
67 | const RELEVANCE_THRESHOLDS = {
68 | high: 0.25,
69 | medium: 0.4,
70 | low: 0.6
71 | };
72 |
73 | /**
74 | * Fuzzy search utility class for finding relevant tasks
75 | */
76 | export class FuzzyTaskSearch {
77 | constructor(tasks, searchType = 'default') {
78 | this.tasks = tasks;
79 | this.config = SEARCH_CONFIGS[searchType] || SEARCH_CONFIGS.default;
80 | this.searchableTasks = this._prepareSearchableTasks(tasks);
81 | this.fuse = new Fuse(this.searchableTasks, {
82 | includeScore: true,
83 | threshold: this.config.threshold,
84 | keys: this.config.keys,
85 | shouldSort: true,
86 | useExtendedSearch: true,
87 | limit: this.config.limit
88 | });
89 | }
90 |
91 | /**
92 | * Prepare tasks for searching by expanding dependency titles
93 | * @param {Array} tasks - Array of task objects
94 | * @returns {Array} Tasks with expanded dependency information
95 | */
96 | _prepareSearchableTasks(tasks) {
97 | return tasks.map((task) => {
98 | // Get titles of this task's dependencies if they exist
99 | const dependencyTitles =
100 | task.dependencies?.length > 0
101 | ? task.dependencies
102 | .map((depId) => {
103 | const depTask = tasks.find((t) => t.id === depId);
104 | return depTask ? depTask.title : '';
105 | })
106 | .filter((title) => title)
107 | .join(' ')
108 | : '';
109 |
110 | return {
111 | ...task,
112 | dependencyTitles
113 | };
114 | });
115 | }
116 |
117 | /**
118 | * Extract significant words from a prompt
119 | * @param {string} prompt - The search prompt
120 | * @returns {Array<string>} Array of significant words
121 | */
122 | _extractPromptWords(prompt) {
123 | return prompt
124 | .toLowerCase()
125 | .replace(/[^\w\s-]/g, ' ') // Replace non-alphanumeric chars with spaces
126 | .split(/\s+/)
127 | .filter((word) => word.length > 3); // Words at least 4 chars
128 | }
129 |
130 | /**
131 | * Find tasks related to a prompt using fuzzy search
132 | * @param {string} prompt - The search prompt
133 | * @param {Object} options - Search options
134 | * @param {number} [options.maxResults=8] - Maximum number of results to return
135 | * @param {boolean} [options.includeRecent=true] - Include recent tasks in results
136 | * @param {boolean} [options.includeCategoryMatches=true] - Include category-based matches
137 | * @returns {Object} Search results with relevance breakdown
138 | */
139 | findRelevantTasks(prompt, options = {}) {
140 | const {
141 | maxResults = 8,
142 | includeRecent = true,
143 | includeCategoryMatches = true
144 | } = options;
145 |
146 | // Extract significant words from prompt
147 | const promptWords = this._extractPromptWords(prompt);
148 |
149 | // Perform fuzzy search with full prompt
150 | const fuzzyResults = this.fuse.search(prompt);
151 |
152 | // Also search for each significant word to catch different aspects
153 | let wordResults = [];
154 | for (const word of promptWords) {
155 | if (word.length > 5) {
156 | // Only use significant words
157 | const results = this.fuse.search(word);
158 | if (results.length > 0) {
159 | wordResults.push(...results);
160 | }
161 | }
162 | }
163 |
164 | // Merge and deduplicate results
165 | const mergedResults = [...fuzzyResults];
166 |
167 | // Add word results that aren't already in fuzzyResults
168 | for (const wordResult of wordResults) {
169 | if (!mergedResults.some((r) => r.item.id === wordResult.item.id)) {
170 | mergedResults.push(wordResult);
171 | }
172 | }
173 |
174 | // Group search results by relevance
175 | const highRelevance = mergedResults
176 | .filter((result) => result.score < RELEVANCE_THRESHOLDS.high)
177 | .map((result) => ({ ...result.item, score: result.score }));
178 |
179 | const mediumRelevance = mergedResults
180 | .filter(
181 | (result) =>
182 | result.score >= RELEVANCE_THRESHOLDS.high &&
183 | result.score < RELEVANCE_THRESHOLDS.medium
184 | )
185 | .map((result) => ({ ...result.item, score: result.score }));
186 |
187 | const lowRelevance = mergedResults
188 | .filter(
189 | (result) =>
190 | result.score >= RELEVANCE_THRESHOLDS.medium &&
191 | result.score < RELEVANCE_THRESHOLDS.low
192 | )
193 | .map((result) => ({ ...result.item, score: result.score }));
194 |
195 | // Get recent tasks (newest first) if requested
196 | const recentTasks = includeRecent
197 | ? [...this.tasks].sort((a, b) => b.id - a.id).slice(0, 5)
198 | : [];
199 |
200 | // Find category-based matches if requested
201 | let categoryTasks = [];
202 | let promptCategory = null;
203 | if (includeCategoryMatches) {
204 | promptCategory = PURPOSE_CATEGORIES.find((cat) =>
205 | cat.pattern.test(prompt)
206 | );
207 | categoryTasks = promptCategory
208 | ? this.tasks
209 | .filter(
210 | (t) =>
211 | promptCategory.pattern.test(t.title) ||
212 | promptCategory.pattern.test(t.description) ||
213 | (t.details && promptCategory.pattern.test(t.details))
214 | )
215 | .slice(0, 3)
216 | : [];
217 | }
218 |
219 | // Combine all relevant tasks, prioritizing by relevance
220 | const allRelevantTasks = [...highRelevance];
221 |
222 | // Add medium relevance if not already included
223 | for (const task of mediumRelevance) {
224 | if (!allRelevantTasks.some((t) => t.id === task.id)) {
225 | allRelevantTasks.push(task);
226 | }
227 | }
228 |
229 | // Add low relevance if not already included
230 | for (const task of lowRelevance) {
231 | if (!allRelevantTasks.some((t) => t.id === task.id)) {
232 | allRelevantTasks.push(task);
233 | }
234 | }
235 |
236 | // Add category tasks if not already included
237 | for (const task of categoryTasks) {
238 | if (!allRelevantTasks.some((t) => t.id === task.id)) {
239 | allRelevantTasks.push(task);
240 | }
241 | }
242 |
243 | // Add recent tasks if not already included
244 | for (const task of recentTasks) {
245 | if (!allRelevantTasks.some((t) => t.id === task.id)) {
246 | allRelevantTasks.push(task);
247 | }
248 | }
249 |
250 | // Get top N results for final output
251 | const finalResults = allRelevantTasks.slice(0, maxResults);
252 |
253 | return {
254 | results: finalResults,
255 | breakdown: {
256 | highRelevance,
257 | mediumRelevance,
258 | lowRelevance,
259 | categoryTasks,
260 | recentTasks,
261 | promptCategory,
262 | promptWords
263 | },
264 | metadata: {
265 | totalSearched: this.tasks.length,
266 | fuzzyMatches: fuzzyResults.length,
267 | wordMatches: wordResults.length,
268 | finalCount: finalResults.length
269 | }
270 | };
271 | }
272 |
273 | /**
274 | * Get task IDs from search results
275 | * @param {Object} searchResults - Results from findRelevantTasks
276 | * @returns {Array<string>} Array of task ID strings
277 | */
278 | getTaskIds(searchResults) {
279 | return searchResults.results.map((task) => task.id.toString());
280 | }
281 |
282 | /**
283 | * Get task IDs including subtasks from search results
284 | * @param {Object} searchResults - Results from findRelevantTasks
285 | * @param {boolean} [includeSubtasks=false] - Whether to include subtask IDs
286 | * @returns {Array<string>} Array of task and subtask ID strings
287 | */
288 | getTaskIdsWithSubtasks(searchResults, includeSubtasks = false) {
289 | const taskIds = [];
290 |
291 | for (const task of searchResults.results) {
292 | taskIds.push(task.id.toString());
293 |
294 | if (includeSubtasks && task.subtasks && task.subtasks.length > 0) {
295 | for (const subtask of task.subtasks) {
296 | taskIds.push(`${task.id}.${subtask.id}`);
297 | }
298 | }
299 | }
300 |
301 | return taskIds;
302 | }
303 |
304 | /**
305 | * Format search results for display
306 | * @param {Object} searchResults - Results from findRelevantTasks
307 | * @param {Object} options - Formatting options
308 | * @returns {string} Formatted search results summary
309 | */
310 | formatSearchSummary(searchResults, options = {}) {
311 | const { includeScores = false, includeBreakdown = false } = options;
312 | const { results, breakdown, metadata } = searchResults;
313 |
314 | let summary = `Found ${results.length} relevant tasks from ${metadata.totalSearched} total tasks`;
315 |
316 | if (includeBreakdown && breakdown) {
317 | const parts = [];
318 | if (breakdown.highRelevance.length > 0)
319 | parts.push(`${breakdown.highRelevance.length} high relevance`);
320 | if (breakdown.mediumRelevance.length > 0)
321 | parts.push(`${breakdown.mediumRelevance.length} medium relevance`);
322 | if (breakdown.lowRelevance.length > 0)
323 | parts.push(`${breakdown.lowRelevance.length} low relevance`);
324 | if (breakdown.categoryTasks.length > 0)
325 | parts.push(`${breakdown.categoryTasks.length} category matches`);
326 |
327 | if (parts.length > 0) {
328 | summary += ` (${parts.join(', ')})`;
329 | }
330 |
331 | if (breakdown.promptCategory) {
332 | summary += `\nCategory detected: ${breakdown.promptCategory.label}`;
333 | }
334 | }
335 |
336 | return summary;
337 | }
338 | }
339 |
340 | /**
341 | * Factory function to create a fuzzy search instance
342 | * @param {Array} tasks - Array of task objects
343 | * @param {string} [searchType='default'] - Type of search configuration to use
344 | * @returns {FuzzyTaskSearch} Fuzzy search instance
345 | */
346 | export function createFuzzyTaskSearch(tasks, searchType = 'default') {
347 | return new FuzzyTaskSearch(tasks, searchType);
348 | }
349 |
350 | /**
351 | * Quick utility function to find relevant task IDs for a prompt
352 | * @param {Array} tasks - Array of task objects
353 | * @param {string} prompt - Search prompt
354 | * @param {Object} options - Search options
355 | * @returns {Array<string>} Array of relevant task ID strings
356 | */
357 | export function findRelevantTaskIds(tasks, prompt, options = {}) {
358 | const {
359 | searchType = 'default',
360 | maxResults = 8,
361 | includeSubtasks = false
362 | } = options;
363 |
364 | const fuzzySearch = new FuzzyTaskSearch(tasks, searchType);
365 | const results = fuzzySearch.findRelevantTasks(prompt, { maxResults });
366 |
367 | return includeSubtasks
368 | ? fuzzySearch.getTaskIdsWithSubtasks(results, true)
369 | : fuzzySearch.getTaskIds(results);
370 | }
371 |
372 | export default FuzzyTaskSearch;
373 |
```
--------------------------------------------------------------------------------
/src/ai-providers/base-provider.js:
--------------------------------------------------------------------------------
```javascript
1 | import {
2 | generateObject,
3 | generateText,
4 | streamText,
5 | streamObject,
6 | zodSchema,
7 | JSONParseError,
8 | NoObjectGeneratedError
9 | } from 'ai';
10 | import { jsonrepair } from 'jsonrepair';
11 | import { log, findProjectRoot } from '../../scripts/modules/utils.js';
12 | import { isProxyEnabled } from '../../scripts/modules/config-manager.js';
13 | import { EnvHttpProxyAgent } from 'undici';
14 |
15 | /**
16 | * Base class for all AI providers
17 | */
18 | export class BaseAIProvider {
19 | constructor() {
20 | if (this.constructor === BaseAIProvider) {
21 | throw new Error('BaseAIProvider cannot be instantiated directly');
22 | }
23 |
24 | // Each provider must set their name
25 | this.name = this.constructor.name;
26 |
27 | // Cache proxy agent to avoid creating multiple instances
28 | this._proxyAgent = null;
29 |
30 | /**
31 | * Whether this provider needs explicit schema in JSON mode
32 | * Can be overridden by subclasses
33 | * @type {boolean}
34 | */
35 | this.needsExplicitJsonSchema = false;
36 |
37 | /**
38 | * Whether this provider supports temperature parameter
39 | * Can be overridden by subclasses
40 | * @type {boolean}
41 | */
42 | this.supportsTemperature = true;
43 | }
44 |
45 | /**
46 | * Validates authentication parameters - can be overridden by providers
47 | * @param {object} params - Parameters to validate
48 | */
49 | validateAuth(params) {
50 | // Default: require API key (most providers need this)
51 | if (!params.apiKey) {
52 | throw new Error(`${this.name} API key is required`);
53 | }
54 | }
55 |
56 | /**
57 | * Creates a custom fetch function with proxy support.
58 | * Only enables proxy when TASKMASTER_ENABLE_PROXY environment variable is set to 'true'
59 | * or enableProxy is set to true in config.json.
60 | * Automatically reads http_proxy/https_proxy environment variables when enabled.
61 | * @returns {Function} Custom fetch function with proxy support, or undefined if proxy is disabled
62 | */
63 | createProxyFetch() {
64 | // Cache project root to avoid repeated lookups
65 | if (!this._projectRoot) {
66 | this._projectRoot = findProjectRoot();
67 | }
68 | const projectRoot = this._projectRoot;
69 |
70 | if (!isProxyEnabled(null, projectRoot)) {
71 | // Return undefined to use default fetch without proxy
72 | return undefined;
73 | }
74 |
75 | // Proxy is enabled, create and return proxy fetch
76 | if (!this._proxyAgent) {
77 | this._proxyAgent = new EnvHttpProxyAgent();
78 | }
79 | return (url, options = {}) => {
80 | return fetch(url, {
81 | ...options,
82 | dispatcher: this._proxyAgent
83 | });
84 | };
85 | }
86 |
87 | /**
88 | * Validates common parameters across all methods
89 | * @param {object} params - Parameters to validate
90 | */
91 | validateParams(params) {
92 | // Validate authentication (can be overridden by providers)
93 | this.validateAuth(params);
94 |
95 | // Validate required model ID
96 | if (!params.modelId) {
97 | throw new Error(`${this.name} Model ID is required`);
98 | }
99 |
100 | // Validate optional parameters
101 | this.validateOptionalParams(params);
102 | }
103 |
104 | /**
105 | * Validates optional parameters like temperature and maxTokens
106 | * @param {object} params - Parameters to validate
107 | */
108 | validateOptionalParams(params) {
109 | if (
110 | params.temperature !== undefined &&
111 | (params.temperature < 0 || params.temperature > 1)
112 | ) {
113 | throw new Error('Temperature must be between 0 and 1');
114 | }
115 | if (params.maxTokens !== undefined) {
116 | const maxTokens = Number(params.maxTokens);
117 | if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
118 | throw new Error('maxTokens must be a finite number greater than 0');
119 | }
120 | }
121 | }
122 |
123 | /**
124 | * Validates message array structure
125 | */
126 | validateMessages(messages) {
127 | if (!messages || !Array.isArray(messages) || messages.length === 0) {
128 | throw new Error('Invalid or empty messages array provided');
129 | }
130 |
131 | for (const msg of messages) {
132 | if (!msg.role || !msg.content) {
133 | throw new Error(
134 | 'Invalid message format. Each message must have role and content'
135 | );
136 | }
137 | }
138 | }
139 |
140 | /**
141 | * Common error handler
142 | */
143 | handleError(operation, error) {
144 | const errorMessage = error.message || 'Unknown error occurred';
145 | log('error', `${this.name} ${operation} failed: ${errorMessage}`, {
146 | error
147 | });
148 | throw new Error(
149 | `${this.name} API error during ${operation}: ${errorMessage}`
150 | );
151 | }
152 |
153 | /**
154 | * Creates and returns a client instance for the provider
155 | * @abstract
156 | */
157 | getClient(params) {
158 | throw new Error('getClient must be implemented by provider');
159 | }
160 |
161 | /**
162 | * Returns if the API key is required
163 | * @abstract
164 | * @returns {boolean} if the API key is required, defaults to true
165 | */
166 | isRequiredApiKey() {
167 | return true;
168 | }
169 |
170 | /**
171 | * Returns the required API key environment variable name
172 | * @abstract
173 | * @returns {string|null} The environment variable name, or null if no API key is required
174 | */
175 | getRequiredApiKeyName() {
176 | throw new Error('getRequiredApiKeyName must be implemented by provider');
177 | }
178 |
179 | /**
180 | * Prepares token limit parameter based on model requirements
181 | * @param {string} modelId - The model ID
182 | * @param {number} maxTokens - The maximum tokens value
183 | * @returns {object} Object with either maxTokens or max_completion_tokens
184 | */
185 | prepareTokenParam(modelId, maxTokens) {
186 | if (maxTokens === undefined) {
187 | return {};
188 | }
189 |
190 | // Ensure maxTokens is an integer
191 | const tokenValue = Math.floor(Number(maxTokens));
192 |
193 | return { maxOutputTokens: tokenValue };
194 | }
195 |
196 | /**
197 | * Generates text using the provider's model
198 | */
199 | async generateText(params) {
200 | try {
201 | this.validateParams(params);
202 | this.validateMessages(params.messages);
203 |
204 | log(
205 | 'debug',
206 | `Generating ${this.name} text with model: ${params.modelId}`
207 | );
208 |
209 | const client = await this.getClient(params);
210 | const result = await generateText({
211 | model: client(params.modelId),
212 | messages: params.messages,
213 | ...this.prepareTokenParam(params.modelId, params.maxTokens),
214 | ...(this.supportsTemperature && params.temperature !== undefined
215 | ? { temperature: params.temperature }
216 | : {})
217 | });
218 |
219 | log(
220 | 'debug',
221 | `${this.name} generateText completed successfully for model: ${params.modelId}`
222 | );
223 |
224 | const inputTokens =
225 | result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0;
226 | const outputTokens =
227 | result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0;
228 | const totalTokens =
229 | result.usage?.totalTokens ?? inputTokens + outputTokens;
230 |
231 | return {
232 | text: result.text,
233 | usage: {
234 | inputTokens,
235 | outputTokens,
236 | totalTokens
237 | }
238 | };
239 | } catch (error) {
240 | this.handleError('text generation', error);
241 | }
242 | }
243 |
244 | /**
245 | * Streams text using the provider's model
246 | */
247 | async streamText(params) {
248 | try {
249 | this.validateParams(params);
250 | this.validateMessages(params.messages);
251 |
252 | log('debug', `Streaming ${this.name} text with model: ${params.modelId}`);
253 |
254 | const client = await this.getClient(params);
255 | const stream = await streamText({
256 | model: client(params.modelId),
257 | messages: params.messages,
258 | ...this.prepareTokenParam(params.modelId, params.maxTokens),
259 | ...(this.supportsTemperature && params.temperature !== undefined
260 | ? { temperature: params.temperature }
261 | : {})
262 | });
263 |
264 | log(
265 | 'debug',
266 | `${this.name} streamText initiated successfully for model: ${params.modelId}`
267 | );
268 |
269 | return stream;
270 | } catch (error) {
271 | this.handleError('text streaming', error);
272 | }
273 | }
274 |
275 | /**
276 | * Streams a structured object using the provider's model
277 | */
278 | async streamObject(params) {
279 | try {
280 | this.validateParams(params);
281 | this.validateMessages(params.messages);
282 |
283 | if (!params.schema) {
284 | throw new Error('Schema is required for object streaming');
285 | }
286 |
287 | log(
288 | 'debug',
289 | `Streaming ${this.name} object with model: ${params.modelId}`
290 | );
291 |
292 | const client = await this.getClient(params);
293 | const result = await streamObject({
294 | model: client(params.modelId),
295 | messages: params.messages,
296 | schema: zodSchema(params.schema),
297 | mode: params.mode || 'auto',
298 | maxOutputTokens: params.maxTokens,
299 | ...(this.supportsTemperature && params.temperature !== undefined
300 | ? { temperature: params.temperature }
301 | : {})
302 | });
303 |
304 | log(
305 | 'debug',
306 | `${this.name} streamObject initiated successfully for model: ${params.modelId}`
307 | );
308 |
309 | // Return the stream result directly
310 | // The stream result contains partialObjectStream and other properties
311 | return result;
312 | } catch (error) {
313 | this.handleError('object streaming', error);
314 | }
315 | }
316 |
317 | /**
318 | * Generates a structured object using the provider's model
319 | */
320 | async generateObject(params) {
321 | try {
322 | this.validateParams(params);
323 | this.validateMessages(params.messages);
324 |
325 | if (!params.schema) {
326 | throw new Error('Schema is required for object generation');
327 | }
328 | if (!params.objectName) {
329 | throw new Error('Object name is required for object generation');
330 | }
331 |
332 | log(
333 | 'debug',
334 | `Generating ${this.name} object ('${params.objectName}') with model: ${params.modelId}`
335 | );
336 |
337 | const client = await this.getClient(params);
338 |
339 | const result = await generateObject({
340 | model: client(params.modelId),
341 | messages: params.messages,
342 | schema: params.schema,
343 | mode: this.needsExplicitJsonSchema ? 'json' : 'auto',
344 | schemaName: params.objectName,
345 | schemaDescription: `Generate a valid JSON object for ${params.objectName}`,
346 | maxTokens: params.maxTokens,
347 | ...(this.supportsTemperature && params.temperature !== undefined
348 | ? { temperature: params.temperature }
349 | : {})
350 | });
351 |
352 | log(
353 | 'debug',
354 | `${this.name} generateObject completed successfully for model: ${params.modelId}`
355 | );
356 |
357 | const inputTokens =
358 | result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0;
359 | const outputTokens =
360 | result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0;
361 | const totalTokens =
362 | result.usage?.totalTokens ?? inputTokens + outputTokens;
363 |
364 | return {
365 | object: result.object,
366 | usage: {
367 | inputTokens,
368 | outputTokens,
369 | totalTokens
370 | }
371 | };
372 | } catch (error) {
373 | // Check if this is a JSON parsing error that we can potentially fix
374 | if (
375 | NoObjectGeneratedError.isInstance(error) &&
376 | error.cause instanceof JSONParseError &&
377 | error.cause.text
378 | ) {
379 | log(
380 | 'warn',
381 | `${this.name} generated malformed JSON, attempting to repair...`
382 | );
383 |
384 | try {
385 | // Use jsonrepair to fix the malformed JSON
386 | const repairedJson = jsonrepair(error.cause.text);
387 | const parsed = JSON.parse(repairedJson);
388 |
389 | log('info', `Successfully repaired ${this.name} JSON output`);
390 |
391 | // Return in the expected format
392 | return {
393 | object: parsed,
394 | usage: {
395 | // Extract usage information from the error if available
396 | inputTokens:
397 | error.usage?.promptTokens || error.usage?.inputTokens || 0,
398 | outputTokens:
399 | error.usage?.completionTokens || error.usage?.outputTokens || 0,
400 | totalTokens: error.usage?.totalTokens || 0
401 | }
402 | };
403 | } catch (repairError) {
404 | log(
405 | 'error',
406 | `Failed to repair ${this.name} JSON: ${repairError.message}`
407 | );
408 | // Fall through to handleError with original error
409 | }
410 | }
411 |
412 | this.handleError('object generation', error);
413 | }
414 | }
415 | }
416 |
```
--------------------------------------------------------------------------------
/tests/integration/cli/commands.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { jest } from '@jest/globals';
2 |
3 | // --- Define mock functions ---
4 | const mockGetMainModelId = jest.fn().mockReturnValue('claude-3-opus');
5 | const mockGetResearchModelId = jest.fn().mockReturnValue('gpt-4-turbo');
6 | const mockGetFallbackModelId = jest.fn().mockReturnValue('claude-3-haiku');
7 | const mockSetMainModel = jest.fn().mockResolvedValue(true);
8 | const mockSetResearchModel = jest.fn().mockResolvedValue(true);
9 | const mockSetFallbackModel = jest.fn().mockResolvedValue(true);
10 | const mockGetAvailableModels = jest.fn().mockReturnValue([
11 | { id: 'claude-3-opus', name: 'Claude 3 Opus', provider: 'anthropic' },
12 | { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
13 | { id: 'claude-3-haiku', name: 'Claude 3 Haiku', provider: 'anthropic' },
14 | { id: 'claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' }
15 | ]);
16 |
17 | // Mock UI related functions
18 | const mockDisplayHelp = jest.fn();
19 | const mockDisplayBanner = jest.fn();
20 | const mockLog = jest.fn();
21 | const mockStartLoadingIndicator = jest.fn(() => ({ stop: jest.fn() }));
22 | const mockStopLoadingIndicator = jest.fn();
23 |
24 | // --- Setup mocks using unstable_mockModule (recommended for ES modules) ---
25 | jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({
26 | getMainModelId: mockGetMainModelId,
27 | getResearchModelId: mockGetResearchModelId,
28 | getFallbackModelId: mockGetFallbackModelId,
29 | setMainModel: mockSetMainModel,
30 | setResearchModel: mockSetResearchModel,
31 | setFallbackModel: mockSetFallbackModel,
32 | getAvailableModels: mockGetAvailableModels,
33 | VALID_PROVIDERS: ['anthropic', 'openai']
34 | }));
35 |
36 | jest.unstable_mockModule('../../../scripts/modules/ui.js', () => ({
37 | displayHelp: mockDisplayHelp,
38 | displayBanner: mockDisplayBanner,
39 | log: mockLog,
40 | startLoadingIndicator: mockStartLoadingIndicator,
41 | stopLoadingIndicator: mockStopLoadingIndicator
42 | }));
43 |
44 | // --- Mock chalk for consistent output formatting ---
45 | const mockChalk = {
46 | red: jest.fn((text) => text),
47 | yellow: jest.fn((text) => text),
48 | blue: jest.fn((text) => text),
49 | green: jest.fn((text) => text),
50 | gray: jest.fn((text) => text),
51 | dim: jest.fn((text) => text),
52 | bold: {
53 | cyan: jest.fn((text) => text),
54 | white: jest.fn((text) => text),
55 | red: jest.fn((text) => text)
56 | },
57 | cyan: {
58 | bold: jest.fn((text) => text)
59 | },
60 | white: {
61 | bold: jest.fn((text) => text)
62 | }
63 | };
64 | // Default function for chalk itself
65 | mockChalk.default = jest.fn((text) => text);
66 | // Add the methods to the function itself for dual usage
67 | Object.keys(mockChalk).forEach((key) => {
68 | if (key !== 'default') mockChalk.default[key] = mockChalk[key];
69 | });
70 |
71 | jest.unstable_mockModule('chalk', () => ({
72 | default: mockChalk.default
73 | }));
74 |
75 | // --- Import modules (AFTER mock setup) ---
76 | let configManager, ui, chalk;
77 |
78 | describe('CLI Models Command (Action Handler Test)', () => {
79 | // Setup dynamic imports before tests run
80 | beforeAll(async () => {
81 | configManager = await import('../../../scripts/modules/config-manager.js');
82 | ui = await import('../../../scripts/modules/ui.js');
83 | chalk = (await import('chalk')).default;
84 | });
85 |
86 | // --- Replicate the action handler logic from commands.js ---
87 | async function modelsAction(options) {
88 | options = options || {}; // Ensure options object exists
89 | const availableModels = configManager.getAvailableModels();
90 |
91 | const findProvider = (modelId) => {
92 | const modelInfo = availableModels.find((m) => m.id === modelId);
93 | return modelInfo?.provider;
94 | };
95 |
96 | let modelSetAction = false;
97 |
98 | try {
99 | if (options.setMain) {
100 | const modelId = options.setMain;
101 | if (typeof modelId !== 'string' || modelId.trim() === '') {
102 | console.error(
103 | chalk.red('Error: --set-main flag requires a valid model ID.')
104 | );
105 | process.exit(1);
106 | }
107 | const provider = findProvider(modelId);
108 | if (!provider) {
109 | console.error(
110 | chalk.red(
111 | `Error: Model ID "${modelId}" not found in available models.`
112 | )
113 | );
114 | process.exit(1);
115 | }
116 | if (await configManager.setMainModel(provider, modelId)) {
117 | console.log(
118 | chalk.green(`Main model set to: ${modelId} (Provider: ${provider})`)
119 | );
120 | modelSetAction = true;
121 | } else {
122 | console.error(chalk.red(`Failed to set main model.`));
123 | process.exit(1);
124 | }
125 | }
126 |
127 | if (options.setResearch) {
128 | const modelId = options.setResearch;
129 | if (typeof modelId !== 'string' || modelId.trim() === '') {
130 | console.error(
131 | chalk.red('Error: --set-research flag requires a valid model ID.')
132 | );
133 | process.exit(1);
134 | }
135 | const provider = findProvider(modelId);
136 | if (!provider) {
137 | console.error(
138 | chalk.red(
139 | `Error: Model ID "${modelId}" not found in available models.`
140 | )
141 | );
142 | process.exit(1);
143 | }
144 | if (await configManager.setResearchModel(provider, modelId)) {
145 | console.log(
146 | chalk.green(
147 | `Research model set to: ${modelId} (Provider: ${provider})`
148 | )
149 | );
150 | modelSetAction = true;
151 | } else {
152 | console.error(chalk.red(`Failed to set research model.`));
153 | process.exit(1);
154 | }
155 | }
156 |
157 | if (options.setFallback) {
158 | const modelId = options.setFallback;
159 | if (typeof modelId !== 'string' || modelId.trim() === '') {
160 | console.error(
161 | chalk.red('Error: --set-fallback flag requires a valid model ID.')
162 | );
163 | process.exit(1);
164 | }
165 | const provider = findProvider(modelId);
166 | if (!provider) {
167 | console.error(
168 | chalk.red(
169 | `Error: Model ID "${modelId}" not found in available models.`
170 | )
171 | );
172 | process.exit(1);
173 | }
174 | if (await configManager.setFallbackModel(provider, modelId)) {
175 | console.log(
176 | chalk.green(
177 | `Fallback model set to: ${modelId} (Provider: ${provider})`
178 | )
179 | );
180 | modelSetAction = true;
181 | } else {
182 | console.error(chalk.red(`Failed to set fallback model.`));
183 | process.exit(1);
184 | }
185 | }
186 |
187 | if (!modelSetAction) {
188 | const currentMain = configManager.getMainModelId();
189 | const currentResearch = configManager.getResearchModelId();
190 | const currentFallback = configManager.getFallbackModelId();
191 |
192 | if (!availableModels || availableModels.length === 0) {
193 | console.log(chalk.yellow('No models defined in configuration.'));
194 | return;
195 | }
196 |
197 | // Create a mock table for testing - avoid using Table constructor
198 | const mockTableData = [];
199 | availableModels.forEach((model) => {
200 | if (model.id.startsWith('[') && model.id.endsWith(']')) return;
201 | mockTableData.push([
202 | model.id,
203 | model.name || 'N/A',
204 | model.provider || 'N/A',
205 | model.id === currentMain ? chalk.green(' ✓') : '',
206 | model.id === currentResearch ? chalk.green(' ✓') : '',
207 | model.id === currentFallback ? chalk.green(' ✓') : ''
208 | ]);
209 | });
210 |
211 | // In a real implementation, we would use cli-table3, but for testing
212 | // we'll just log 'Mock Table Output'
213 | console.log('Mock Table Output');
214 | }
215 | } catch (error) {
216 | // Use ui.log mock if available, otherwise console.error
217 | (ui.log || console.error)(
218 | `Error processing models command: ${error.message}`,
219 | 'error'
220 | );
221 | if (error.stack) {
222 | (ui.log || console.error)(error.stack, 'debug');
223 | }
224 | throw error; // Re-throw for test failure
225 | }
226 | }
227 | // --- End of Action Handler Logic ---
228 |
229 | let originalConsoleLog;
230 | let originalConsoleError;
231 | let originalProcessExit;
232 |
233 | beforeEach(() => {
234 | // Reset all mocks
235 | jest.clearAllMocks();
236 |
237 | // Save original console methods
238 | originalConsoleLog = console.log;
239 | originalConsoleError = console.error;
240 | originalProcessExit = process.exit;
241 |
242 | // Mock console and process.exit
243 | console.log = jest.fn();
244 | console.error = jest.fn();
245 | process.exit = jest.fn((code) => {
246 | throw new Error(`process.exit(${code}) called`);
247 | });
248 | });
249 |
250 | afterEach(() => {
251 | // Restore original console methods
252 | console.log = originalConsoleLog;
253 | console.error = originalConsoleError;
254 | process.exit = originalProcessExit;
255 | });
256 |
257 | // --- Test Cases (Calling modelsAction directly) ---
258 |
259 | it('should call setMainModel with correct provider and ID', async () => {
260 | const modelId = 'claude-3-opus';
261 | const expectedProvider = 'anthropic';
262 | await modelsAction({ setMain: modelId });
263 | expect(mockSetMainModel).toHaveBeenCalledWith(expectedProvider, modelId);
264 | expect(console.log).toHaveBeenCalledWith(
265 | expect.stringContaining(`Main model set to: ${modelId}`)
266 | );
267 | expect(console.log).toHaveBeenCalledWith(
268 | expect.stringContaining(`(Provider: ${expectedProvider})`)
269 | );
270 | });
271 |
272 | it('should show an error if --set-main model ID is not found', async () => {
273 | await expect(
274 | modelsAction({ setMain: 'non-existent-model' })
275 | ).rejects.toThrow(/process.exit/); // Expect exit call
276 | expect(mockSetMainModel).not.toHaveBeenCalled();
277 | expect(console.error).toHaveBeenCalledWith(
278 | expect.stringContaining('Model ID "non-existent-model" not found')
279 | );
280 | });
281 |
282 | it('should call setResearchModel with correct provider and ID', async () => {
283 | const modelId = 'gpt-4-turbo';
284 | const expectedProvider = 'openai';
285 | await modelsAction({ setResearch: modelId });
286 | expect(mockSetResearchModel).toHaveBeenCalledWith(
287 | expectedProvider,
288 | modelId
289 | );
290 | expect(console.log).toHaveBeenCalledWith(
291 | expect.stringContaining(`Research model set to: ${modelId}`)
292 | );
293 | expect(console.log).toHaveBeenCalledWith(
294 | expect.stringContaining(`(Provider: ${expectedProvider})`)
295 | );
296 | });
297 |
298 | it('should call setFallbackModel with correct provider and ID', async () => {
299 | const modelId = 'claude-3-haiku';
300 | const expectedProvider = 'anthropic';
301 | await modelsAction({ setFallback: modelId });
302 | expect(mockSetFallbackModel).toHaveBeenCalledWith(
303 | expectedProvider,
304 | modelId
305 | );
306 | expect(console.log).toHaveBeenCalledWith(
307 | expect.stringContaining(`Fallback model set to: ${modelId}`)
308 | );
309 | expect(console.log).toHaveBeenCalledWith(
310 | expect.stringContaining(`(Provider: ${expectedProvider})`)
311 | );
312 | });
313 |
314 | it('should call all set*Model functions when all flags are used', async () => {
315 | const mainModelId = 'claude-3-opus';
316 | const researchModelId = 'gpt-4-turbo';
317 | const fallbackModelId = 'claude-3-haiku';
318 | const mainProvider = 'anthropic';
319 | const researchProvider = 'openai';
320 | const fallbackProvider = 'anthropic';
321 |
322 | await modelsAction({
323 | setMain: mainModelId,
324 | setResearch: researchModelId,
325 | setFallback: fallbackModelId
326 | });
327 | expect(mockSetMainModel).toHaveBeenCalledWith(mainProvider, mainModelId);
328 | expect(mockSetResearchModel).toHaveBeenCalledWith(
329 | researchProvider,
330 | researchModelId
331 | );
332 | expect(mockSetFallbackModel).toHaveBeenCalledWith(
333 | fallbackProvider,
334 | fallbackModelId
335 | );
336 | });
337 |
338 | it('should call specific get*ModelId and getAvailableModels and log table when run without flags', async () => {
339 | await modelsAction({}); // Call with empty options
340 |
341 | expect(mockGetMainModelId).toHaveBeenCalled();
342 | expect(mockGetResearchModelId).toHaveBeenCalled();
343 | expect(mockGetFallbackModelId).toHaveBeenCalled();
344 | expect(mockGetAvailableModels).toHaveBeenCalled();
345 |
346 | expect(console.log).toHaveBeenCalled();
347 | // Check the mocked Table.toString() was used via console.log
348 | expect(console.log).toHaveBeenCalledWith('Mock Table Output');
349 | });
350 | });
351 |
```
--------------------------------------------------------------------------------
/packages/tm-core/tests/integration/storage/activity-logger.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import os from 'os';
2 | import path from 'path';
3 | import fs from 'fs-extra';
4 | import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5 | import {
6 | filterActivityLog,
7 | logActivity,
8 | readActivityLog
9 | } from '../../../src/storage/activity-logger.js';
10 |
11 | describe('Activity Logger', () => {
12 | let testDir: string;
13 | let activityPath: string;
14 |
15 | beforeEach(async () => {
16 | // Create a unique temporary test directory
17 | const prefix = path.join(os.tmpdir(), 'activity-test-');
18 | testDir = await fs.mkdtemp(prefix);
19 | activityPath = path.join(testDir, 'activity.jsonl');
20 | });
21 |
22 | afterEach(async () => {
23 | // Clean up test directory
24 | await fs.remove(testDir);
25 | });
26 |
27 | describe('logActivity', () => {
28 | it('should create activity log file on first write', async () => {
29 | await logActivity(activityPath, {
30 | type: 'phase-start',
31 | phase: 'red',
32 | data: {}
33 | });
34 |
35 | const exists = await fs.pathExists(activityPath);
36 | expect(exists).toBe(true);
37 | });
38 |
39 | it('should append event to log file', async () => {
40 | await logActivity(activityPath, {
41 | type: 'phase-start',
42 | phase: 'red'
43 | });
44 |
45 | const content = await fs.readFile(activityPath, 'utf-8');
46 | const lines = content.trim().split(/\r?\n/);
47 |
48 | expect(lines.length).toBe(1);
49 | });
50 |
51 | it('should write valid JSONL format', async () => {
52 | await logActivity(activityPath, {
53 | type: 'test-run',
54 | result: 'pass'
55 | });
56 |
57 | const content = await fs.readFile(activityPath, 'utf-8');
58 | const line = content.trim();
59 | const parsed = JSON.parse(line);
60 |
61 | expect(parsed).toBeDefined();
62 | expect(parsed.type).toBe('test-run');
63 | });
64 |
65 | it('should include timestamp in log entry', async () => {
66 | const before = new Date().toISOString();
67 | await logActivity(activityPath, {
68 | type: 'phase-start',
69 | phase: 'red'
70 | });
71 | const after = new Date().toISOString();
72 |
73 | const logs = await readActivityLog(activityPath);
74 | expect(logs[0].timestamp).toBeDefined();
75 | expect(logs[0].timestamp >= before).toBe(true);
76 | expect(logs[0].timestamp <= after).toBe(true);
77 | });
78 |
79 | it('should append multiple events', async () => {
80 | await logActivity(activityPath, { type: 'event1' });
81 | await logActivity(activityPath, { type: 'event2' });
82 | await logActivity(activityPath, { type: 'event3' });
83 |
84 | const logs = await readActivityLog(activityPath);
85 | expect(logs.length).toBe(3);
86 | expect(logs[0].type).toBe('event1');
87 | expect(logs[1].type).toBe('event2');
88 | expect(logs[2].type).toBe('event3');
89 | });
90 |
91 | it('should preserve event data', async () => {
92 | const eventData = {
93 | type: 'git-commit',
94 | hash: 'abc123',
95 | message: 'test commit',
96 | files: ['file1.ts', 'file2.ts']
97 | };
98 |
99 | await logActivity(activityPath, eventData);
100 |
101 | const logs = await readActivityLog(activityPath);
102 | expect(logs[0].type).toBe('git-commit');
103 | expect(logs[0].hash).toBe('abc123');
104 | expect(logs[0].message).toBe('test commit');
105 | expect(logs[0].files).toEqual(['file1.ts', 'file2.ts']);
106 | });
107 |
108 | it('should handle nested objects in event data', async () => {
109 | await logActivity(activityPath, {
110 | type: 'test-results',
111 | results: {
112 | passed: 10,
113 | failed: 2,
114 | details: { coverage: 85 }
115 | }
116 | });
117 |
118 | const logs = await readActivityLog(activityPath);
119 | expect(logs[0].results.details.coverage).toBe(85);
120 | });
121 |
122 | it('should handle special characters in event data', async () => {
123 | await logActivity(activityPath, {
124 | type: 'error',
125 | message: 'Error: "Something went wrong"\nLine 2'
126 | });
127 |
128 | const logs = await readActivityLog(activityPath);
129 | expect(logs[0].message).toBe('Error: "Something went wrong"\nLine 2');
130 | });
131 |
132 | it('should create parent directory if it does not exist', async () => {
133 | const nestedPath = path.join(testDir, 'nested', 'dir', 'activity.jsonl');
134 |
135 | await logActivity(nestedPath, { type: 'test' });
136 |
137 | const exists = await fs.pathExists(nestedPath);
138 | expect(exists).toBe(true);
139 | });
140 | });
141 |
142 | describe('readActivityLog', () => {
143 | it('should read all events from log', async () => {
144 | await logActivity(activityPath, { type: 'event1' });
145 | await logActivity(activityPath, { type: 'event2' });
146 |
147 | const logs = await readActivityLog(activityPath);
148 |
149 | expect(logs.length).toBe(2);
150 | expect(logs[0].type).toBe('event1');
151 | expect(logs[1].type).toBe('event2');
152 | });
153 |
154 | it('should return empty array for non-existent file', async () => {
155 | const logs = await readActivityLog(activityPath);
156 | expect(logs).toEqual([]);
157 | });
158 |
159 | it('should parse JSONL correctly', async () => {
160 | await logActivity(activityPath, { type: 'event1', data: 'test1' });
161 | await logActivity(activityPath, { type: 'event2', data: 'test2' });
162 |
163 | const logs = await readActivityLog(activityPath);
164 |
165 | expect(logs[0].data).toBe('test1');
166 | expect(logs[1].data).toBe('test2');
167 | });
168 |
169 | it('should handle empty lines', async () => {
170 | await fs.writeFile(
171 | activityPath,
172 | '{"type":"event1"}\n\n{"type":"event2"}\n'
173 | );
174 |
175 | const logs = await readActivityLog(activityPath);
176 |
177 | expect(logs.length).toBe(2);
178 | expect(logs[0].type).toBe('event1');
179 | expect(logs[1].type).toBe('event2');
180 | });
181 |
182 | it('should throw error for invalid JSON line', async () => {
183 | await fs.writeFile(activityPath, '{"type":"event1"}\ninvalid json\n');
184 |
185 | await expect(readActivityLog(activityPath)).rejects.toThrow(
186 | /Invalid JSON/i
187 | );
188 | });
189 |
190 | it('should preserve chronological order', async () => {
191 | for (let i = 0; i < 10; i++) {
192 | await logActivity(activityPath, { type: 'event', index: i });
193 | }
194 |
195 | const logs = await readActivityLog(activityPath);
196 |
197 | for (let i = 0; i < 10; i++) {
198 | expect(logs[i].index).toBe(i);
199 | }
200 | });
201 | });
202 |
203 | describe('filterActivityLog', () => {
204 | beforeEach(async () => {
205 | // Create sample log entries
206 | await logActivity(activityPath, { type: 'phase-start', phase: 'red' });
207 | await logActivity(activityPath, { type: 'test-run', result: 'fail' });
208 | await logActivity(activityPath, { type: 'phase-start', phase: 'green' });
209 | await logActivity(activityPath, { type: 'test-run', result: 'pass' });
210 | await logActivity(activityPath, { type: 'git-commit', hash: 'abc123' });
211 | });
212 |
213 | it('should filter by event type', async () => {
214 | const filtered = await filterActivityLog(activityPath, {
215 | type: 'phase-start'
216 | });
217 |
218 | expect(filtered.length).toBe(2);
219 | expect(filtered[0].type).toBe('phase-start');
220 | expect(filtered[1].type).toBe('phase-start');
221 | });
222 |
223 | it('should filter by multiple criteria', async () => {
224 | const filtered = await filterActivityLog(activityPath, {
225 | type: 'test-run',
226 | result: 'pass'
227 | });
228 |
229 | expect(filtered.length).toBe(1);
230 | expect(filtered[0].result).toBe('pass');
231 | });
232 |
233 | it('should return all events when no filter provided', async () => {
234 | const filtered = await filterActivityLog(activityPath, {});
235 |
236 | expect(filtered.length).toBe(5);
237 | });
238 |
239 | it('should filter by timestamp range', async () => {
240 | const logs = await readActivityLog(activityPath);
241 | const midpoint = logs[2].timestamp;
242 |
243 | const filtered = await filterActivityLog(activityPath, {
244 | timestampFrom: midpoint
245 | });
246 |
247 | // Should get events from midpoint onwards (inclusive)
248 | // Expect at least 3 events, may be more due to timestamp collisions
249 | expect(filtered.length).toBeGreaterThanOrEqual(3);
250 | expect(filtered.length).toBeLessThanOrEqual(5);
251 | });
252 |
253 | it('should filter by custom predicate', async () => {
254 | const filtered = await filterActivityLog(activityPath, {
255 | predicate: (event: any) => event.phase === 'red'
256 | });
257 |
258 | expect(filtered.length).toBe(1);
259 | expect(filtered[0].phase).toBe('red');
260 | });
261 |
262 | it('should return empty array for non-matching filter', async () => {
263 | const filtered = await filterActivityLog(activityPath, {
264 | type: 'non-existent'
265 | });
266 |
267 | expect(filtered).toEqual([]);
268 | });
269 |
270 | it('should handle nested property filters', async () => {
271 | await logActivity(activityPath, {
272 | type: 'test-results',
273 | results: { coverage: 85 }
274 | });
275 |
276 | const filtered = await filterActivityLog(activityPath, {
277 | predicate: (event: any) => event.results?.coverage > 80
278 | });
279 |
280 | expect(filtered.length).toBe(1);
281 | expect(filtered[0].results.coverage).toBe(85);
282 | });
283 | });
284 |
285 | describe('Event types', () => {
286 | it('should support phase-transition events', async () => {
287 | await logActivity(activityPath, {
288 | type: 'phase-transition',
289 | from: 'red',
290 | to: 'green'
291 | });
292 |
293 | const logs = await readActivityLog(activityPath);
294 | expect(logs[0].type).toBe('phase-transition');
295 | expect(logs[0].from).toBe('red');
296 | expect(logs[0].to).toBe('green');
297 | });
298 |
299 | it('should support test-run events', async () => {
300 | await logActivity(activityPath, {
301 | type: 'test-run',
302 | result: 'pass',
303 | testsRun: 50,
304 | testsPassed: 50,
305 | testsFailed: 0,
306 | coverage: 85.5
307 | });
308 |
309 | const logs = await readActivityLog(activityPath);
310 | expect(logs[0].testsRun).toBe(50);
311 | expect(logs[0].coverage).toBe(85.5);
312 | });
313 |
314 | it('should support git-operation events', async () => {
315 | await logActivity(activityPath, {
316 | type: 'git-commit',
317 | hash: 'abc123def456',
318 | message: 'feat: add new feature',
319 | files: ['file1.ts', 'file2.ts']
320 | });
321 |
322 | const logs = await readActivityLog(activityPath);
323 | expect(logs[0].hash).toBe('abc123def456');
324 | expect(logs[0].files.length).toBe(2);
325 | });
326 |
327 | it('should support error events', async () => {
328 | await logActivity(activityPath, {
329 | type: 'error',
330 | phase: 'red',
331 | error: 'Test failed',
332 | stack: 'Error stack trace...'
333 | });
334 |
335 | const logs = await readActivityLog(activityPath);
336 | expect(logs[0].type).toBe('error');
337 | expect(logs[0].error).toBe('Test failed');
338 | });
339 | });
340 |
341 | describe('Concurrency handling', () => {
342 | it('should handle rapid concurrent writes', async () => {
343 | const writes: Promise<void>[] = [];
344 | for (let i = 0; i < 50; i++) {
345 | writes.push(logActivity(activityPath, { type: 'event', index: i }));
346 | }
347 |
348 | await Promise.all(writes);
349 |
350 | const logs = await readActivityLog(activityPath);
351 | expect(logs.length).toBe(50);
352 | });
353 |
354 | it('should maintain data integrity with concurrent writes', async () => {
355 | const writes: Promise<void>[] = [];
356 | for (let i = 0; i < 20; i++) {
357 | writes.push(
358 | logActivity(activityPath, {
359 | type: 'concurrent-test',
360 | id: i,
361 | data: `data-${i}`
362 | })
363 | );
364 | }
365 |
366 | await Promise.all(writes);
367 |
368 | const logs = await readActivityLog(activityPath);
369 |
370 | // All events should be present
371 | expect(logs.length).toBe(20);
372 | // Validate ids set
373 | const ids = new Set(logs.map((l) => l.id));
374 | expect([...ids].sort((a, b) => a - b)).toEqual([...Array(20).keys()]);
375 | // Validate shape
376 | for (const log of logs) {
377 | expect(log.type).toBe('concurrent-test');
378 | expect(typeof log.id).toBe('number');
379 | expect(log.data).toMatch(/^data-\d+$/);
380 | }
381 | });
382 | });
383 |
384 | describe('File integrity', () => {
385 | it('should maintain valid JSONL after many operations', async () => {
386 | for (let i = 0; i < 100; i++) {
387 | await logActivity(activityPath, { type: 'test', iteration: i });
388 | }
389 |
390 | const content = await fs.readFile(activityPath, 'utf-8');
391 | const lines = content.trim().split(/\r?\n/);
392 |
393 | expect(lines.length).toBe(100);
394 |
395 | // All lines should be valid JSON
396 | for (const line of lines) {
397 | expect(() => JSON.parse(line)).not.toThrow();
398 | }
399 | });
400 | });
401 | });
402 |
```
--------------------------------------------------------------------------------
/tests/unit/mcp/tools/analyze-complexity.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * Tests for the analyze_project_complexity MCP tool
3 | *
4 | * Note: This test does NOT test the actual implementation. It tests that:
5 | * 1. The tool is registered correctly with the correct parameters
6 | * 2. Arguments are passed correctly to analyzeTaskComplexityDirect
7 | * 3. The threshold parameter is properly validated
8 | * 4. Error handling works as expected
9 | *
10 | * We do NOT import the real implementation - everything is mocked
11 | */
12 |
13 | import { jest } from '@jest/globals';
14 |
15 | // Mock EVERYTHING
16 | const mockAnalyzeTaskComplexityDirect = jest.fn();
17 | jest.mock('../../../../mcp-server/src/core/task-master-core.js', () => ({
18 | analyzeTaskComplexityDirect: mockAnalyzeTaskComplexityDirect
19 | }));
20 |
21 | const mockHandleApiResult = jest.fn((result) => result);
22 | const mockGetProjectRootFromSession = jest.fn(() => '/mock/project/root');
23 | const mockCreateErrorResponse = jest.fn((msg) => ({
24 | success: false,
25 | error: { code: 'ERROR', message: msg }
26 | }));
27 |
28 | jest.mock('../../../../mcp-server/src/tools/utils.js', () => ({
29 | getProjectRootFromSession: mockGetProjectRootFromSession,
30 | handleApiResult: mockHandleApiResult,
31 | createErrorResponse: mockCreateErrorResponse,
32 | createContentResponse: jest.fn((content) => ({
33 | success: true,
34 | data: content
35 | })),
36 | executeTaskMasterCommand: jest.fn()
37 | }));
38 |
39 | // This is a more complex mock of Zod to test actual validation
40 | const createZodMock = () => {
41 | // Storage for validation rules
42 | const validationRules = {
43 | threshold: {
44 | type: 'coerce.number',
45 | min: 1,
46 | max: 10,
47 | optional: true
48 | }
49 | };
50 |
51 | // Create validator functions
52 | const validateThreshold = (value) => {
53 | if (value === undefined && validationRules.threshold.optional) {
54 | return true;
55 | }
56 |
57 | // Attempt to coerce to number (if string)
58 | const numValue = typeof value === 'string' ? Number(value) : value;
59 |
60 | // Check if it's a valid number
61 | if (isNaN(numValue)) {
62 | throw new Error(`Invalid type for parameter 'threshold'`);
63 | }
64 |
65 | // Check min/max constraints
66 | if (numValue < validationRules.threshold.min) {
67 | throw new Error(
68 | `Threshold must be at least ${validationRules.threshold.min}`
69 | );
70 | }
71 |
72 | if (numValue > validationRules.threshold.max) {
73 | throw new Error(
74 | `Threshold must be at most ${validationRules.threshold.max}`
75 | );
76 | }
77 |
78 | return true;
79 | };
80 |
81 | // Create actual validators for parameters
82 | const validators = {
83 | threshold: validateThreshold
84 | };
85 |
86 | // Main validation function for the entire object
87 | const validateObject = (obj) => {
88 | // Validate each field
89 | if (obj.threshold !== undefined) {
90 | validators.threshold(obj.threshold);
91 | }
92 |
93 | // If we get here, all validations passed
94 | return obj;
95 | };
96 |
97 | // Base object with chainable methods
98 | const zodBase = {
99 | optional: () => {
100 | return zodBase;
101 | },
102 | describe: (desc) => {
103 | return zodBase;
104 | }
105 | };
106 |
107 | // Number-specific methods
108 | const zodNumber = {
109 | ...zodBase,
110 | min: (value) => {
111 | return zodNumber;
112 | },
113 | max: (value) => {
114 | return zodNumber;
115 | }
116 | };
117 |
118 | // Main mock implementation
119 | const mockZod = {
120 | object: () => ({
121 | ...zodBase,
122 | // This parse method will be called by the tool execution
123 | parse: validateObject
124 | }),
125 | string: () => zodBase,
126 | boolean: () => zodBase,
127 | number: () => zodNumber,
128 | coerce: {
129 | number: () => zodNumber
130 | },
131 | union: (schemas) => zodBase,
132 | _def: {
133 | shape: () => ({
134 | output: {},
135 | model: {},
136 | threshold: {},
137 | file: {},
138 | research: {},
139 | projectRoot: {}
140 | })
141 | }
142 | };
143 |
144 | return mockZod;
145 | };
146 |
147 | // Create our Zod mock
148 | const mockZod = createZodMock();
149 |
150 | jest.mock('zod', () => ({
151 | z: mockZod
152 | }));
153 |
154 | // DO NOT import the real module - create a fake implementation
155 | // This is the fake implementation of registerAnalyzeTool
156 | const registerAnalyzeTool = (server) => {
157 | // Create simplified version of the tool config
158 | const toolConfig = {
159 | name: 'analyze_project_complexity',
160 | description:
161 | 'Analyze task complexity and generate expansion recommendations',
162 | parameters: mockZod.object(),
163 |
164 | // Create a simplified mock of the execute function
165 | execute: (args, context) => {
166 | const { log, session } = context;
167 |
168 | try {
169 | log.info &&
170 | log.info(
171 | `Analyzing task complexity with args: ${JSON.stringify(args)}`
172 | );
173 |
174 | // Get project root
175 | const rootFolder = mockGetProjectRootFromSession(session, log);
176 |
177 | // Call analyzeTaskComplexityDirect
178 | const result = mockAnalyzeTaskComplexityDirect(
179 | {
180 | ...args,
181 | projectRoot: rootFolder
182 | },
183 | log,
184 | { session }
185 | );
186 |
187 | // Handle result
188 | return mockHandleApiResult(result, log);
189 | } catch (error) {
190 | log.error && log.error(`Error in analyze tool: ${error.message}`);
191 | return mockCreateErrorResponse(error.message);
192 | }
193 | }
194 | };
195 |
196 | // Register the tool with the server
197 | server.addTool(toolConfig);
198 | };
199 |
200 | describe('MCP Tool: analyze_project_complexity', () => {
201 | // Create mock server
202 | let mockServer;
203 | let executeFunction;
204 |
205 | // Create mock logger
206 | const mockLogger = {
207 | debug: jest.fn(),
208 | info: jest.fn(),
209 | warn: jest.fn(),
210 | error: jest.fn()
211 | };
212 |
213 | // Test data
214 | const validArgs = {
215 | output: 'output/path/report.json',
216 | model: 'claude-3-opus-20240229',
217 | threshold: 5,
218 | research: true
219 | };
220 |
221 | // Standard responses
222 | const successResponse = {
223 | success: true,
224 | data: {
225 | message: 'Task complexity analysis complete',
226 | reportPath: '/mock/project/root/output/path/report.json',
227 | reportSummary: {
228 | taskCount: 10,
229 | highComplexityTasks: 3,
230 | mediumComplexityTasks: 5,
231 | lowComplexityTasks: 2
232 | }
233 | }
234 | };
235 |
236 | const errorResponse = {
237 | success: false,
238 | error: {
239 | code: 'ANALYZE_ERROR',
240 | message: 'Failed to analyze task complexity'
241 | }
242 | };
243 |
244 | beforeEach(() => {
245 | // Reset all mocks
246 | jest.clearAllMocks();
247 |
248 | // Create mock server
249 | mockServer = {
250 | addTool: jest.fn((config) => {
251 | executeFunction = config.execute;
252 | })
253 | };
254 |
255 | // Setup default successful response
256 | mockAnalyzeTaskComplexityDirect.mockReturnValue(successResponse);
257 |
258 | // Register the tool
259 | registerAnalyzeTool(mockServer);
260 | });
261 |
262 | test('should register the tool correctly', () => {
263 | // Verify tool was registered
264 | expect(mockServer.addTool).toHaveBeenCalledWith(
265 | expect.objectContaining({
266 | name: 'analyze_project_complexity',
267 | description:
268 | 'Analyze task complexity and generate expansion recommendations',
269 | parameters: expect.any(Object),
270 | execute: expect.any(Function)
271 | })
272 | );
273 |
274 | // Verify the tool config was passed
275 | const toolConfig = mockServer.addTool.mock.calls[0][0];
276 | expect(toolConfig).toHaveProperty('parameters');
277 | expect(toolConfig).toHaveProperty('execute');
278 | });
279 |
280 | test('should execute the tool with valid threshold as number', () => {
281 | // Setup context
282 | const mockContext = {
283 | log: mockLogger,
284 | session: { workingDirectory: '/mock/dir' }
285 | };
286 |
287 | // Test with valid numeric threshold
288 | const args = { ...validArgs, threshold: 7 };
289 | executeFunction(args, mockContext);
290 |
291 | // Verify analyzeTaskComplexityDirect was called with correct arguments
292 | expect(mockAnalyzeTaskComplexityDirect).toHaveBeenCalledWith(
293 | expect.objectContaining({
294 | threshold: 7,
295 | projectRoot: '/mock/project/root'
296 | }),
297 | mockLogger,
298 | { session: mockContext.session }
299 | );
300 |
301 | // Verify handleApiResult was called
302 | expect(mockHandleApiResult).toHaveBeenCalledWith(
303 | successResponse,
304 | mockLogger
305 | );
306 | });
307 |
308 | test('should execute the tool with valid threshold as string', () => {
309 | // Setup context
310 | const mockContext = {
311 | log: mockLogger,
312 | session: { workingDirectory: '/mock/dir' }
313 | };
314 |
315 | // Test with valid string threshold
316 | const args = { ...validArgs, threshold: '7' };
317 | executeFunction(args, mockContext);
318 |
319 | // The mock doesn't actually coerce the string, just verify that the string is passed correctly
320 | expect(mockAnalyzeTaskComplexityDirect).toHaveBeenCalledWith(
321 | expect.objectContaining({
322 | threshold: '7', // Expect string value, not coerced to number in our mock
323 | projectRoot: '/mock/project/root'
324 | }),
325 | mockLogger,
326 | { session: mockContext.session }
327 | );
328 | });
329 |
330 | test('should execute the tool with decimal threshold', () => {
331 | // Setup context
332 | const mockContext = {
333 | log: mockLogger,
334 | session: { workingDirectory: '/mock/dir' }
335 | };
336 |
337 | // Test with decimal threshold
338 | const args = { ...validArgs, threshold: 6.5 };
339 | executeFunction(args, mockContext);
340 |
341 | // Verify it was passed correctly
342 | expect(mockAnalyzeTaskComplexityDirect).toHaveBeenCalledWith(
343 | expect.objectContaining({
344 | threshold: 6.5,
345 | projectRoot: '/mock/project/root'
346 | }),
347 | mockLogger,
348 | { session: mockContext.session }
349 | );
350 | });
351 |
352 | test('should execute the tool without threshold parameter', () => {
353 | // Setup context
354 | const mockContext = {
355 | log: mockLogger,
356 | session: { workingDirectory: '/mock/dir' }
357 | };
358 |
359 | // Test without threshold (should use default)
360 | const { threshold, ...argsWithoutThreshold } = validArgs;
361 | executeFunction(argsWithoutThreshold, mockContext);
362 |
363 | // Verify threshold is undefined
364 | expect(mockAnalyzeTaskComplexityDirect).toHaveBeenCalledWith(
365 | expect.objectContaining({
366 | projectRoot: '/mock/project/root'
367 | }),
368 | mockLogger,
369 | { session: mockContext.session }
370 | );
371 |
372 | // Check threshold is not included
373 | const callArgs = mockAnalyzeTaskComplexityDirect.mock.calls[0][0];
374 | expect(callArgs).not.toHaveProperty('threshold');
375 | });
376 |
377 | test('should handle errors from analyzeTaskComplexityDirect', () => {
378 | // Setup error response
379 | mockAnalyzeTaskComplexityDirect.mockReturnValueOnce(errorResponse);
380 |
381 | // Setup context
382 | const mockContext = {
383 | log: mockLogger,
384 | session: { workingDirectory: '/mock/dir' }
385 | };
386 |
387 | // Execute the function
388 | executeFunction(validArgs, mockContext);
389 |
390 | // Verify analyzeTaskComplexityDirect was called
391 | expect(mockAnalyzeTaskComplexityDirect).toHaveBeenCalled();
392 |
393 | // Verify handleApiResult was called with error response
394 | expect(mockHandleApiResult).toHaveBeenCalledWith(errorResponse, mockLogger);
395 | });
396 |
397 | test('should handle unexpected errors', () => {
398 | // Setup error
399 | const testError = new Error('Unexpected error');
400 | mockAnalyzeTaskComplexityDirect.mockImplementationOnce(() => {
401 | throw testError;
402 | });
403 |
404 | // Setup context
405 | const mockContext = {
406 | log: mockLogger,
407 | session: { workingDirectory: '/mock/dir' }
408 | };
409 |
410 | // Execute the function
411 | executeFunction(validArgs, mockContext);
412 |
413 | // Verify error was logged
414 | expect(mockLogger.error).toHaveBeenCalledWith(
415 | 'Error in analyze tool: Unexpected error'
416 | );
417 |
418 | // Verify error response was created
419 | expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unexpected error');
420 | });
421 |
422 | test('should verify research parameter is correctly passed', () => {
423 | // Setup context
424 | const mockContext = {
425 | log: mockLogger,
426 | session: { workingDirectory: '/mock/dir' }
427 | };
428 |
429 | // Test with research=true
430 | executeFunction(
431 | {
432 | ...validArgs,
433 | research: true
434 | },
435 | mockContext
436 | );
437 |
438 | // Verify analyzeTaskComplexityDirect was called with research=true
439 | expect(mockAnalyzeTaskComplexityDirect).toHaveBeenCalledWith(
440 | expect.objectContaining({
441 | research: true
442 | }),
443 | expect.any(Object),
444 | expect.any(Object)
445 | );
446 |
447 | // Reset mocks
448 | jest.clearAllMocks();
449 |
450 | // Test with research=false
451 | executeFunction(
452 | {
453 | ...validArgs,
454 | research: false
455 | },
456 | mockContext
457 | );
458 |
459 | // Verify analyzeTaskComplexityDirect was called with research=false
460 | expect(mockAnalyzeTaskComplexityDirect).toHaveBeenCalledWith(
461 | expect.objectContaining({
462 | research: false
463 | }),
464 | expect.any(Object),
465 | expect.any(Object)
466 | );
467 | });
468 | });
469 |
```
--------------------------------------------------------------------------------
/tests/unit/mcp/tools/tool-registration.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * tool-registration.test.js
3 | * Comprehensive unit tests for the Task Master MCP tool registration system
4 | * Tests environment variable control system covering all configuration modes and edge cases
5 | */
6 |
7 | import {
8 | describe,
9 | it,
10 | expect,
11 | beforeEach,
12 | afterEach,
13 | jest
14 | } from '@jest/globals';
15 |
16 | import {
17 | EXPECTED_TOOL_COUNTS,
18 | EXPECTED_CORE_TOOLS,
19 | validateToolCounts,
20 | validateToolStructure
21 | } from '../../../helpers/tool-counts.js';
22 |
23 | import { registerTaskMasterTools } from '../../../../mcp-server/src/tools/index.js';
24 | import {
25 | toolRegistry,
26 | coreTools,
27 | standardTools
28 | } from '../../../../mcp-server/src/tools/tool-registry.js';
29 |
30 | // Derive constants from imported registry to avoid brittle magic numbers
31 | const ALL_COUNT = Object.keys(toolRegistry).length;
32 | const CORE_COUNT = coreTools.length;
33 | const STANDARD_COUNT = standardTools.length;
34 |
35 | describe('Task Master Tool Registration System', () => {
36 | let mockServer;
37 | let originalEnv;
38 |
39 | beforeEach(() => {
40 | originalEnv = process.env.TASK_MASTER_TOOLS;
41 |
42 | mockServer = {
43 | tools: [],
44 | addTool: jest.fn((tool) => {
45 | mockServer.tools.push(tool);
46 | return tool;
47 | })
48 | };
49 |
50 | delete process.env.TASK_MASTER_TOOLS;
51 | });
52 |
53 | afterEach(() => {
54 | if (originalEnv !== undefined) {
55 | process.env.TASK_MASTER_TOOLS = originalEnv;
56 | } else {
57 | delete process.env.TASK_MASTER_TOOLS;
58 | }
59 |
60 | jest.clearAllMocks();
61 | });
62 |
63 | describe('Test Environment Setup', () => {
64 | it('should have properly configured mock server', () => {
65 | expect(mockServer).toBeDefined();
66 | expect(typeof mockServer.addTool).toBe('function');
67 | expect(Array.isArray(mockServer.tools)).toBe(true);
68 | expect(mockServer.tools.length).toBe(0);
69 | });
70 |
71 | it('should have correct tool registry structure', () => {
72 | const validation = validateToolCounts();
73 | expect(validation.isValid).toBe(true);
74 |
75 | if (!validation.isValid) {
76 | console.error('Tool count validation failed:', validation);
77 | }
78 |
79 | expect(validation.actual.total).toBe(EXPECTED_TOOL_COUNTS.total);
80 | expect(validation.actual.core).toBe(EXPECTED_TOOL_COUNTS.core);
81 | expect(validation.actual.standard).toBe(EXPECTED_TOOL_COUNTS.standard);
82 | });
83 |
84 | it('should have correct core tools', () => {
85 | const structure = validateToolStructure();
86 | expect(structure.isValid).toBe(true);
87 |
88 | if (!structure.isValid) {
89 | console.error('Tool structure validation failed:', structure);
90 | }
91 |
92 | expect(coreTools).toEqual(expect.arrayContaining(EXPECTED_CORE_TOOLS));
93 | expect(coreTools.length).toBe(EXPECTED_TOOL_COUNTS.core);
94 | });
95 |
96 | it('should have correct standard tools that include all core tools', () => {
97 | const structure = validateToolStructure();
98 | expect(structure.details.coreInStandard).toBe(true);
99 | expect(standardTools.length).toBe(EXPECTED_TOOL_COUNTS.standard);
100 |
101 | coreTools.forEach((tool) => {
102 | expect(standardTools).toContain(tool);
103 | });
104 | });
105 |
106 | it('should have all expected tools in registry', () => {
107 | const expectedTools = [
108 | 'initialize_project',
109 | 'models',
110 | 'research',
111 | 'add_tag',
112 | 'delete_tag',
113 | 'get_tasks',
114 | 'next_task',
115 | 'get_task'
116 | ];
117 | expectedTools.forEach((tool) => {
118 | expect(toolRegistry).toHaveProperty(tool);
119 | });
120 | });
121 | });
122 |
123 | describe('Configuration Modes', () => {
124 | it(`should register all tools (${ALL_COUNT}) when TASK_MASTER_TOOLS is not set (default behavior)`, () => {
125 | delete process.env.TASK_MASTER_TOOLS;
126 |
127 | registerTaskMasterTools(mockServer);
128 |
129 | expect(mockServer.addTool).toHaveBeenCalledTimes(
130 | EXPECTED_TOOL_COUNTS.total
131 | );
132 | });
133 |
134 | it(`should register all tools (${ALL_COUNT}) when TASK_MASTER_TOOLS=all`, () => {
135 | process.env.TASK_MASTER_TOOLS = 'all';
136 |
137 | registerTaskMasterTools(mockServer);
138 |
139 | expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
140 | });
141 |
142 | it(`should register exactly ${CORE_COUNT} core tools when TASK_MASTER_TOOLS=core`, () => {
143 | process.env.TASK_MASTER_TOOLS = 'core';
144 |
145 | registerTaskMasterTools(mockServer, 'core');
146 |
147 | expect(mockServer.addTool).toHaveBeenCalledTimes(
148 | EXPECTED_TOOL_COUNTS.core
149 | );
150 | });
151 |
152 | it(`should register exactly ${STANDARD_COUNT} standard tools when TASK_MASTER_TOOLS=standard`, () => {
153 | process.env.TASK_MASTER_TOOLS = 'standard';
154 |
155 | registerTaskMasterTools(mockServer, 'standard');
156 |
157 | expect(mockServer.addTool).toHaveBeenCalledTimes(
158 | EXPECTED_TOOL_COUNTS.standard
159 | );
160 | });
161 |
162 | it(`should treat lean as alias for core mode (${CORE_COUNT} tools)`, () => {
163 | process.env.TASK_MASTER_TOOLS = 'lean';
164 |
165 | registerTaskMasterTools(mockServer, 'lean');
166 |
167 | expect(mockServer.addTool).toHaveBeenCalledTimes(CORE_COUNT);
168 | });
169 |
170 | it('should handle case insensitive configuration values', () => {
171 | process.env.TASK_MASTER_TOOLS = 'CORE';
172 |
173 | registerTaskMasterTools(mockServer, 'CORE');
174 |
175 | expect(mockServer.addTool).toHaveBeenCalledTimes(CORE_COUNT);
176 | });
177 | });
178 |
179 | describe('Custom Tool Selection and Edge Cases', () => {
180 | it('should register specific tools from comma-separated list', () => {
181 | process.env.TASK_MASTER_TOOLS = 'get_tasks,next_task,get_task';
182 |
183 | registerTaskMasterTools(mockServer, 'get_tasks,next_task,get_task');
184 |
185 | expect(mockServer.addTool).toHaveBeenCalledTimes(3);
186 | });
187 |
188 | it('should handle mixed valid and invalid tool names gracefully', () => {
189 | process.env.TASK_MASTER_TOOLS =
190 | 'invalid_tool,get_tasks,fake_tool,next_task';
191 |
192 | registerTaskMasterTools(
193 | mockServer,
194 | 'invalid_tool,get_tasks,fake_tool,next_task'
195 | );
196 |
197 | expect(mockServer.addTool).toHaveBeenCalledTimes(2);
198 | });
199 |
200 | it('should default to all tools with completely invalid input', () => {
201 | process.env.TASK_MASTER_TOOLS = 'completely_invalid';
202 |
203 | registerTaskMasterTools(mockServer);
204 |
205 | expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
206 | });
207 |
208 | it('should handle empty string environment variable', () => {
209 | process.env.TASK_MASTER_TOOLS = '';
210 |
211 | registerTaskMasterTools(mockServer);
212 |
213 | expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
214 | });
215 |
216 | it('should handle whitespace in comma-separated lists', () => {
217 | process.env.TASK_MASTER_TOOLS = ' get_tasks , next_task , get_task ';
218 |
219 | registerTaskMasterTools(mockServer, ' get_tasks , next_task , get_task ');
220 |
221 | expect(mockServer.addTool).toHaveBeenCalledTimes(3);
222 | });
223 |
224 | it('should ignore duplicate tools in list', () => {
225 | process.env.TASK_MASTER_TOOLS = 'get_tasks,get_tasks,next_task,get_tasks';
226 |
227 | registerTaskMasterTools(
228 | mockServer,
229 | 'get_tasks,get_tasks,next_task,get_tasks'
230 | );
231 |
232 | expect(mockServer.addTool).toHaveBeenCalledTimes(2);
233 | });
234 |
235 | it('should handle only commas and empty entries', () => {
236 | process.env.TASK_MASTER_TOOLS = ',,,';
237 |
238 | registerTaskMasterTools(mockServer);
239 |
240 | expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
241 | });
242 |
243 | it('should handle single tool selection', () => {
244 | process.env.TASK_MASTER_TOOLS = 'get_tasks';
245 |
246 | registerTaskMasterTools(mockServer, 'get_tasks');
247 |
248 | expect(mockServer.addTool).toHaveBeenCalledTimes(1);
249 | });
250 | });
251 |
252 | describe('Coverage Analysis and Integration Tests', () => {
253 | it('should provide 100% code coverage for environment control logic', () => {
254 | const testCases = [
255 | {
256 | env: undefined,
257 | expectedCount: ALL_COUNT,
258 | description: 'undefined env (all)'
259 | },
260 | {
261 | env: '',
262 | expectedCount: ALL_COUNT,
263 | description: 'empty string (all)'
264 | },
265 | { env: 'all', expectedCount: ALL_COUNT, description: 'all mode' },
266 | { env: 'core', expectedCount: CORE_COUNT, description: 'core mode' },
267 | {
268 | env: 'lean',
269 | expectedCount: CORE_COUNT,
270 | description: 'lean mode (alias)'
271 | },
272 | {
273 | env: 'standard',
274 | expectedCount: STANDARD_COUNT,
275 | description: 'standard mode'
276 | },
277 | {
278 | env: 'get_tasks,next_task',
279 | expectedCount: 2,
280 | description: 'custom list'
281 | },
282 | {
283 | env: 'invalid_tool',
284 | expectedCount: ALL_COUNT,
285 | description: 'invalid fallback'
286 | }
287 | ];
288 |
289 | testCases.forEach((testCase) => {
290 | delete process.env.TASK_MASTER_TOOLS;
291 | if (testCase.env !== undefined) {
292 | process.env.TASK_MASTER_TOOLS = testCase.env;
293 | }
294 |
295 | mockServer.tools = [];
296 | mockServer.addTool.mockClear();
297 |
298 | registerTaskMasterTools(mockServer, testCase.env || 'all');
299 |
300 | expect(mockServer.addTool).toHaveBeenCalledTimes(
301 | testCase.expectedCount
302 | );
303 | });
304 | });
305 |
306 | it('should have optimal performance characteristics', () => {
307 | const startTime = Date.now();
308 |
309 | process.env.TASK_MASTER_TOOLS = 'all';
310 |
311 | registerTaskMasterTools(mockServer);
312 |
313 | const endTime = Date.now();
314 | const executionTime = endTime - startTime;
315 |
316 | expect(executionTime).toBeLessThan(100);
317 | expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
318 | });
319 |
320 | it('should validate token reduction claims', () => {
321 | expect(coreTools.length).toBeLessThan(standardTools.length);
322 | expect(standardTools.length).toBeLessThan(
323 | Object.keys(toolRegistry).length
324 | );
325 |
326 | expect(coreTools.length).toBe(CORE_COUNT);
327 | expect(standardTools.length).toBe(STANDARD_COUNT);
328 | expect(Object.keys(toolRegistry).length).toBe(ALL_COUNT);
329 |
330 | const allToolsCount = Object.keys(toolRegistry).length;
331 | const coreReduction =
332 | ((allToolsCount - coreTools.length) / allToolsCount) * 100;
333 | const standardReduction =
334 | ((allToolsCount - standardTools.length) / allToolsCount) * 100;
335 |
336 | expect(coreReduction).toBeGreaterThan(80);
337 | expect(standardReduction).toBeGreaterThan(50);
338 | });
339 |
340 | it('should maintain referential integrity of tool registry', () => {
341 | coreTools.forEach((tool) => {
342 | expect(standardTools).toContain(tool);
343 | });
344 |
345 | standardTools.forEach((tool) => {
346 | expect(toolRegistry).toHaveProperty(tool);
347 | });
348 |
349 | Object.keys(toolRegistry).forEach((tool) => {
350 | expect(typeof toolRegistry[tool]).toBe('function');
351 | });
352 | });
353 |
354 | it('should handle concurrent registration attempts', () => {
355 | process.env.TASK_MASTER_TOOLS = 'core';
356 |
357 | registerTaskMasterTools(mockServer, 'core');
358 | registerTaskMasterTools(mockServer, 'core');
359 | registerTaskMasterTools(mockServer, 'core');
360 |
361 | expect(mockServer.addTool).toHaveBeenCalledTimes(CORE_COUNT * 3);
362 | });
363 |
364 | it('should validate all documented tool categories exist', () => {
365 | const allTools = Object.keys(toolRegistry);
366 |
367 | const projectSetupTools = allTools.filter((tool) =>
368 | ['initialize_project', 'models', 'rules', 'parse_prd'].includes(tool)
369 | );
370 | expect(projectSetupTools.length).toBeGreaterThan(0);
371 |
372 | const taskManagementTools = allTools.filter((tool) =>
373 | ['get_tasks', 'get_task', 'next_task', 'set_task_status'].includes(tool)
374 | );
375 | expect(taskManagementTools.length).toBeGreaterThan(0);
376 |
377 | const analysisTools = allTools.filter((tool) =>
378 | ['analyze_project_complexity', 'complexity_report'].includes(tool)
379 | );
380 | expect(analysisTools.length).toBeGreaterThan(0);
381 |
382 | const tagManagementTools = allTools.filter((tool) =>
383 | ['add_tag', 'delete_tag', 'list_tags', 'use_tag'].includes(tool)
384 | );
385 | expect(tagManagementTools.length).toBeGreaterThan(0);
386 | });
387 |
388 | it('should handle error conditions gracefully', () => {
389 | const problematicInputs = [
390 | 'null',
391 | 'undefined',
392 | ' ',
393 | '\n\t',
394 | 'special!@#$%^&*()characters',
395 | 'very,very,very,very,very,very,very,long,comma,separated,list,with,invalid,tools,that,should,fallback,to,all'
396 | ];
397 |
398 | problematicInputs.forEach((input) => {
399 | mockServer.tools = [];
400 | mockServer.addTool.mockClear();
401 |
402 | process.env.TASK_MASTER_TOOLS = input;
403 |
404 | expect(() => registerTaskMasterTools(mockServer)).not.toThrow();
405 |
406 | expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
407 | });
408 | });
409 | });
410 | });
411 |
```
--------------------------------------------------------------------------------
/apps/extension/src/utils/task-master-api/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * TaskMaster API
3 | * Main API class that coordinates all modules
4 | */
5 |
6 | import * as vscode from 'vscode';
7 | import { ExtensionLogger } from '../logger';
8 | import type { MCPClientManager } from '../mcpClient';
9 | import { CacheManager } from './cache/cache-manager';
10 | import { MCPClient } from './mcp-client';
11 | import { TaskTransformer } from './transformers/task-transformer';
12 | import type {
13 | AddSubtaskOptions,
14 | CacheConfig,
15 | GetTasksOptions,
16 | SubtaskData,
17 | TaskMasterApiConfig,
18 | TaskMasterApiResponse,
19 | TaskMasterTask,
20 | TaskUpdate,
21 | UpdateSubtaskOptions,
22 | UpdateTaskOptions,
23 | UpdateTaskStatusOptions
24 | } from './types';
25 |
26 | // Re-export types for backward compatibility
27 | export * from './types';
28 |
29 | export class TaskMasterApi {
30 | private mcpWrapper: MCPClient;
31 | private cache: CacheManager;
32 | private transformer: TaskTransformer;
33 | private config: TaskMasterApiConfig;
34 | private logger: ExtensionLogger;
35 |
36 | private readonly defaultCacheConfig: CacheConfig = {
37 | maxSize: 100,
38 | enableBackgroundRefresh: true,
39 | refreshInterval: 5 * 60 * 1000, // 5 minutes
40 | enableAnalytics: true,
41 | enablePrefetch: true,
42 | compressionEnabled: false,
43 | persistToDisk: false
44 | };
45 |
46 | private readonly defaultConfig: TaskMasterApiConfig = {
47 | timeout: 30000,
48 | retryAttempts: 3,
49 | cacheDuration: 5 * 60 * 1000, // 5 minutes
50 | cache: this.defaultCacheConfig
51 | };
52 |
53 | constructor(
54 | mcpClient: MCPClientManager,
55 | config?: Partial<TaskMasterApiConfig>
56 | ) {
57 | this.logger = ExtensionLogger.getInstance();
58 |
59 | // Merge config - ensure cache is always fully defined
60 | const mergedCache: CacheConfig = {
61 | maxSize: config?.cache?.maxSize ?? this.defaultCacheConfig.maxSize,
62 | enableBackgroundRefresh:
63 | config?.cache?.enableBackgroundRefresh ??
64 | this.defaultCacheConfig.enableBackgroundRefresh,
65 | refreshInterval:
66 | config?.cache?.refreshInterval ??
67 | this.defaultCacheConfig.refreshInterval,
68 | enableAnalytics:
69 | config?.cache?.enableAnalytics ??
70 | this.defaultCacheConfig.enableAnalytics,
71 | enablePrefetch:
72 | config?.cache?.enablePrefetch ?? this.defaultCacheConfig.enablePrefetch,
73 | compressionEnabled:
74 | config?.cache?.compressionEnabled ??
75 | this.defaultCacheConfig.compressionEnabled,
76 | persistToDisk:
77 | config?.cache?.persistToDisk ?? this.defaultCacheConfig.persistToDisk
78 | };
79 |
80 | this.config = {
81 | ...this.defaultConfig,
82 | ...config,
83 | cache: mergedCache
84 | };
85 |
86 | // Initialize modules
87 | this.mcpWrapper = new MCPClient(mcpClient, this.logger, {
88 | timeout: this.config.timeout,
89 | retryAttempts: this.config.retryAttempts
90 | });
91 |
92 | this.cache = new CacheManager(
93 | { ...mergedCache, cacheDuration: this.config.cacheDuration },
94 | this.logger
95 | );
96 |
97 | this.transformer = new TaskTransformer(this.logger);
98 |
99 | // Start background refresh if enabled
100 | if (this.config.cache?.enableBackgroundRefresh) {
101 | this.startBackgroundRefresh();
102 | }
103 |
104 | this.logger.log('TaskMasterApi: Initialized with modular architecture');
105 | }
106 |
107 | /**
108 | * Get tasks from TaskMaster
109 | */
110 | async getTasks(
111 | options?: GetTasksOptions
112 | ): Promise<TaskMasterApiResponse<TaskMasterTask[]>> {
113 | const startTime = Date.now();
114 | const cacheKey = `get_tasks_${JSON.stringify(options || {})}`;
115 |
116 | try {
117 | // Check cache first
118 | const cached = this.cache.get(cacheKey);
119 | if (cached) {
120 | return {
121 | success: true,
122 | data: cached,
123 | requestDuration: Date.now() - startTime
124 | };
125 | }
126 |
127 | // Prepare MCP tool arguments
128 | const mcpArgs: Record<string, unknown> = {
129 | projectRoot: options?.projectRoot || this.getWorkspaceRoot(),
130 | withSubtasks: options?.withSubtasks ?? true
131 | };
132 |
133 | if (options?.status) {
134 | mcpArgs.status = options.status;
135 | }
136 | if (options?.tag) {
137 | mcpArgs.tag = options.tag;
138 | }
139 |
140 | this.logger.log('Calling get_tasks with args:', mcpArgs);
141 |
142 | // Call MCP tool
143 | const mcpResponse = await this.mcpWrapper.callTool('get_tasks', mcpArgs);
144 |
145 | // Transform response
146 | const transformedTasks =
147 | this.transformer.transformMCPTasksResponse(mcpResponse);
148 |
149 | // Cache the result
150 | this.cache.set(cacheKey, transformedTasks);
151 |
152 | return {
153 | success: true,
154 | data: transformedTasks,
155 | requestDuration: Date.now() - startTime
156 | };
157 | } catch (error) {
158 | this.logger.error('Error getting tasks:', error);
159 | return {
160 | success: false,
161 | error: error instanceof Error ? error.message : 'Unknown error',
162 | requestDuration: Date.now() - startTime
163 | };
164 | }
165 | }
166 |
167 | /**
168 | * Update task status
169 | */
170 | async updateTaskStatus(
171 | taskId: string,
172 | status: string,
173 | options?: UpdateTaskStatusOptions
174 | ): Promise<TaskMasterApiResponse<boolean>> {
175 | const startTime = Date.now();
176 |
177 | try {
178 | const mcpArgs: Record<string, unknown> = {
179 | id: String(taskId),
180 | status: status,
181 | projectRoot: options?.projectRoot || this.getWorkspaceRoot()
182 | };
183 |
184 | this.logger.log('Calling set_task_status with args:', mcpArgs);
185 |
186 | await this.mcpWrapper.callTool('set_task_status', mcpArgs);
187 |
188 | // Clear relevant caches
189 | this.cache.clearPattern('get_tasks');
190 |
191 | return {
192 | success: true,
193 | data: true,
194 | requestDuration: Date.now() - startTime
195 | };
196 | } catch (error) {
197 | this.logger.error('Error updating task status:', error);
198 | return {
199 | success: false,
200 | error: error instanceof Error ? error.message : 'Unknown error',
201 | requestDuration: Date.now() - startTime
202 | };
203 | }
204 | }
205 |
206 | /**
207 | * Update task content
208 | */
209 | async updateTask(
210 | taskId: string,
211 | updates: TaskUpdate,
212 | options?: UpdateTaskOptions
213 | ): Promise<TaskMasterApiResponse<boolean>> {
214 | const startTime = Date.now();
215 |
216 | try {
217 | // Build update prompt
218 | const updateFields: string[] = [];
219 | if (updates.title !== undefined) {
220 | updateFields.push(`Title: ${updates.title}`);
221 | }
222 | if (updates.description !== undefined) {
223 | updateFields.push(`Description: ${updates.description}`);
224 | }
225 | if (updates.details !== undefined) {
226 | updateFields.push(`Details: ${updates.details}`);
227 | }
228 | if (updates.priority !== undefined) {
229 | updateFields.push(`Priority: ${updates.priority}`);
230 | }
231 | if (updates.testStrategy !== undefined) {
232 | updateFields.push(`Test Strategy: ${updates.testStrategy}`);
233 | }
234 | if (updates.dependencies !== undefined) {
235 | updateFields.push(`Dependencies: ${updates.dependencies.join(', ')}`);
236 | }
237 |
238 | const prompt = `Update task with the following changes:\n${updateFields.join('\n')}`;
239 |
240 | const mcpArgs: Record<string, unknown> = {
241 | id: String(taskId),
242 | prompt: prompt,
243 | projectRoot: options?.projectRoot || this.getWorkspaceRoot()
244 | };
245 |
246 | if (options?.append !== undefined) {
247 | mcpArgs.append = options.append;
248 | }
249 | if (options?.research !== undefined) {
250 | mcpArgs.research = options.research;
251 | }
252 |
253 | this.logger.log('Calling update_task with args:', mcpArgs);
254 |
255 | await this.mcpWrapper.callTool('update_task', mcpArgs);
256 |
257 | // Clear relevant caches
258 | this.cache.clearPattern('get_tasks');
259 |
260 | return {
261 | success: true,
262 | data: true,
263 | requestDuration: Date.now() - startTime
264 | };
265 | } catch (error) {
266 | this.logger.error('Error updating task:', error);
267 | return {
268 | success: false,
269 | error: error instanceof Error ? error.message : 'Unknown error',
270 | requestDuration: Date.now() - startTime
271 | };
272 | }
273 | }
274 |
275 | /**
276 | * Update subtask content
277 | */
278 | async updateSubtask(
279 | taskId: string,
280 | prompt: string,
281 | options?: UpdateSubtaskOptions
282 | ): Promise<TaskMasterApiResponse<boolean>> {
283 | const startTime = Date.now();
284 |
285 | try {
286 | const mcpArgs: Record<string, unknown> = {
287 | id: String(taskId),
288 | prompt: prompt,
289 | projectRoot: options?.projectRoot || this.getWorkspaceRoot()
290 | };
291 |
292 | if (options?.research !== undefined) {
293 | mcpArgs.research = options.research;
294 | }
295 |
296 | this.logger.log('Calling update_subtask with args:', mcpArgs);
297 |
298 | await this.mcpWrapper.callTool('update_subtask', mcpArgs);
299 |
300 | // Clear relevant caches
301 | this.cache.clearPattern('get_tasks');
302 |
303 | return {
304 | success: true,
305 | data: true,
306 | requestDuration: Date.now() - startTime
307 | };
308 | } catch (error) {
309 | this.logger.error('Error updating subtask:', error);
310 | return {
311 | success: false,
312 | error: error instanceof Error ? error.message : 'Unknown error',
313 | requestDuration: Date.now() - startTime
314 | };
315 | }
316 | }
317 |
318 | /**
319 | * Add a new subtask
320 | */
321 | async addSubtask(
322 | parentTaskId: string,
323 | subtaskData: SubtaskData,
324 | options?: AddSubtaskOptions
325 | ): Promise<TaskMasterApiResponse<boolean>> {
326 | const startTime = Date.now();
327 |
328 | try {
329 | const mcpArgs: Record<string, unknown> = {
330 | id: String(parentTaskId),
331 | title: subtaskData.title,
332 | projectRoot: options?.projectRoot || this.getWorkspaceRoot()
333 | };
334 |
335 | if (subtaskData.description) {
336 | mcpArgs.description = subtaskData.description;
337 | }
338 | if (subtaskData.dependencies && subtaskData.dependencies.length > 0) {
339 | mcpArgs.dependencies = subtaskData.dependencies.join(',');
340 | }
341 | if (subtaskData.status) {
342 | mcpArgs.status = subtaskData.status;
343 | }
344 |
345 | this.logger.log('Calling add_subtask with args:', mcpArgs);
346 |
347 | await this.mcpWrapper.callTool('add_subtask', mcpArgs);
348 |
349 | // Clear relevant caches
350 | this.cache.clearPattern('get_tasks');
351 |
352 | return {
353 | success: true,
354 | data: true,
355 | requestDuration: Date.now() - startTime
356 | };
357 | } catch (error) {
358 | this.logger.error('Error adding subtask:', error);
359 | return {
360 | success: false,
361 | error: error instanceof Error ? error.message : 'Unknown error',
362 | requestDuration: Date.now() - startTime
363 | };
364 | }
365 | }
366 |
367 | /**
368 | * Get connection status
369 | */
370 | getConnectionStatus(): { isConnected: boolean; error?: string } {
371 | const status = this.mcpWrapper.getStatus();
372 | return {
373 | isConnected: status.isRunning,
374 | error: status.error
375 | };
376 | }
377 |
378 | /**
379 | * Test connection
380 | */
381 | async testConnection(): Promise<TaskMasterApiResponse<boolean>> {
382 | const startTime = Date.now();
383 |
384 | try {
385 | const isConnected = await this.mcpWrapper.testConnection();
386 | return {
387 | success: true,
388 | data: isConnected,
389 | requestDuration: Date.now() - startTime
390 | };
391 | } catch (error) {
392 | this.logger.error('Connection test failed:', error);
393 | return {
394 | success: false,
395 | error:
396 | error instanceof Error ? error.message : 'Connection test failed',
397 | requestDuration: Date.now() - startTime
398 | };
399 | }
400 | }
401 |
402 | /**
403 | * Clear all cached data
404 | */
405 | clearCache(): void {
406 | this.cache.clear();
407 | }
408 |
409 | /**
410 | * Get cache analytics
411 | */
412 | getCacheAnalytics() {
413 | return this.cache.getAnalytics();
414 | }
415 |
416 | /**
417 | * Cleanup resources
418 | */
419 | destroy(): void {
420 | this.cache.destroy();
421 | this.logger.log('TaskMasterApi: Destroyed and cleaned up resources');
422 | }
423 |
424 | /**
425 | * Start background refresh
426 | */
427 | private startBackgroundRefresh(): void {
428 | const interval = this.config.cache?.refreshInterval || 5 * 60 * 1000;
429 | setInterval(() => {
430 | this.performBackgroundRefresh();
431 | }, interval);
432 | }
433 |
434 | /**
435 | * Perform background refresh of frequently accessed cache entries
436 | */
437 | private async performBackgroundRefresh(): Promise<void> {
438 | if (!this.config.cache?.enableBackgroundRefresh) {
439 | return;
440 | }
441 |
442 | this.logger.log('Starting background cache refresh');
443 | const candidates = this.cache.getRefreshCandidates();
444 |
445 | let refreshedCount = 0;
446 | for (const [key, entry] of candidates) {
447 | try {
448 | const optionsMatch = key.match(/get_tasks_(.+)/);
449 | if (optionsMatch) {
450 | const options = JSON.parse(optionsMatch[1]);
451 | await this.getTasks(options);
452 | refreshedCount++;
453 | this.cache.incrementRefreshes();
454 | }
455 | } catch (error) {
456 | this.logger.warn(`Background refresh failed for key ${key}:`, error);
457 | }
458 | }
459 |
460 | this.logger.log(
461 | `Background refresh completed, refreshed ${refreshedCount} entries`
462 | );
463 | }
464 |
465 | /**
466 | * Get workspace root path
467 | */
468 | private getWorkspaceRoot(): string {
469 | return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
470 | }
471 | }
472 |
```