This is page 42 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/src/modules/workflow/orchestrators/workflow-orchestrator.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { TestResultValidator } from '../services/test-result-validator.js';
2 | import type {
3 | StateTransition,
4 | SubtaskInfo,
5 | TDDPhase,
6 | WorkflowContext,
7 | WorkflowError,
8 | WorkflowEvent,
9 | WorkflowEventData,
10 | WorkflowEventListener,
11 | WorkflowEventType,
12 | WorkflowPhase,
13 | WorkflowState
14 | } from '../types.js';
15 |
16 | /**
17 | * Lightweight state machine for TDD workflow orchestration
18 | */
19 | export class WorkflowOrchestrator {
20 | private currentPhase: WorkflowPhase;
21 | private context: WorkflowContext;
22 | private readonly transitions: StateTransition[];
23 | private readonly eventListeners: Map<
24 | WorkflowEventType,
25 | Set<WorkflowEventListener>
26 | >;
27 | private persistCallback?: (state: WorkflowState) => void | Promise<void>;
28 | private autoPersistEnabled: boolean = false;
29 | private readonly phaseGuards: Map<
30 | WorkflowPhase,
31 | (context: WorkflowContext) => boolean
32 | >;
33 | private aborted: boolean = false;
34 | private testResultValidator?: TestResultValidator;
35 | private gitOperationHook?: (operation: string, data?: unknown) => void;
36 | private executeHook?: (command: string, context: WorkflowContext) => void;
37 |
38 | constructor(initialContext: WorkflowContext) {
39 | this.currentPhase = 'PREFLIGHT';
40 | this.context = { ...initialContext };
41 | this.transitions = this.defineTransitions();
42 | this.eventListeners = new Map();
43 | this.phaseGuards = new Map();
44 | }
45 |
46 | /**
47 | * Define valid state transitions
48 | */
49 | private defineTransitions(): StateTransition[] {
50 | return [
51 | {
52 | from: 'PREFLIGHT',
53 | to: 'BRANCH_SETUP',
54 | event: 'PREFLIGHT_COMPLETE'
55 | },
56 | {
57 | from: 'BRANCH_SETUP',
58 | to: 'SUBTASK_LOOP',
59 | event: 'BRANCH_CREATED'
60 | },
61 | {
62 | from: 'SUBTASK_LOOP',
63 | to: 'FINALIZE',
64 | event: 'ALL_SUBTASKS_COMPLETE'
65 | },
66 | {
67 | from: 'FINALIZE',
68 | to: 'COMPLETE',
69 | event: 'FINALIZE_COMPLETE'
70 | }
71 | ];
72 | }
73 |
74 | /**
75 | * Get current workflow phase
76 | */
77 | getCurrentPhase(): WorkflowPhase {
78 | return this.currentPhase;
79 | }
80 |
81 | /**
82 | * Get current TDD phase (only valid in SUBTASK_LOOP)
83 | */
84 | getCurrentTDDPhase(): TDDPhase | undefined {
85 | if (this.currentPhase === 'SUBTASK_LOOP') {
86 | return this.context.currentTDDPhase || 'RED';
87 | }
88 | return undefined;
89 | }
90 |
91 | /**
92 | * Get workflow context
93 | */
94 | getContext(): WorkflowContext {
95 | return { ...this.context };
96 | }
97 |
98 | /**
99 | * Transition to next state based on event
100 | */
101 | async transition(event: WorkflowEvent): Promise<void> {
102 | // Check if workflow is aborted
103 | if (this.aborted && event.type !== 'ABORT') {
104 | throw new Error('Workflow has been aborted');
105 | }
106 |
107 | // Handle special events that work across all phases
108 | if (event.type === 'ERROR') {
109 | this.handleError(event.error);
110 | await this.triggerAutoPersist();
111 | return;
112 | }
113 |
114 | if (event.type === 'ABORT') {
115 | this.aborted = true;
116 | await this.triggerAutoPersist();
117 | return;
118 | }
119 |
120 | if (event.type === 'RETRY') {
121 | this.handleRetry();
122 | await this.triggerAutoPersist();
123 | return;
124 | }
125 |
126 | // Handle TDD phase transitions within SUBTASK_LOOP
127 | if (this.currentPhase === 'SUBTASK_LOOP') {
128 | await this.handleTDDPhaseTransition(event);
129 | await this.triggerAutoPersist();
130 | return;
131 | }
132 |
133 | // Handle main workflow phase transitions
134 | const validTransition = this.transitions.find(
135 | (t) => t.from === this.currentPhase && t.event === event.type
136 | );
137 |
138 | if (!validTransition) {
139 | throw new Error(
140 | `Invalid transition: ${event.type} from ${this.currentPhase}`
141 | );
142 | }
143 |
144 | // Execute transition
145 | this.executeTransition(validTransition, event);
146 | await this.triggerAutoPersist();
147 | }
148 |
149 | /**
150 | * Handle TDD phase transitions (RED -> GREEN -> COMMIT)
151 | */
152 | private async handleTDDPhaseTransition(event: WorkflowEvent): Promise<void> {
153 | const currentTDD = this.context.currentTDDPhase || 'RED';
154 |
155 | switch (event.type) {
156 | case 'RED_PHASE_COMPLETE':
157 | if (currentTDD !== 'RED') {
158 | throw new Error(
159 | 'Invalid transition: RED_PHASE_COMPLETE from non-RED phase'
160 | );
161 | }
162 |
163 | // Validate test results are provided
164 | if (!event.testResults) {
165 | throw new Error('Test results required for RED phase transition');
166 | }
167 |
168 | // Store test results in context
169 | this.context.lastTestResults = event.testResults;
170 |
171 | // Special case: All tests passing in RED phase means feature already implemented
172 | if (event.testResults.failed === 0) {
173 | this.emit('tdd:red:completed');
174 | this.emit('tdd:feature-already-implemented', {
175 | subtaskId: this.getCurrentSubtaskId(),
176 | testResults: event.testResults
177 | });
178 |
179 | // Mark subtask as complete and move to next one
180 | const subtask =
181 | this.context.subtasks[this.context.currentSubtaskIndex];
182 | if (subtask) {
183 | subtask.status = 'completed';
184 | }
185 |
186 | this.emit('subtask:completed');
187 | this.context.currentSubtaskIndex++;
188 |
189 | // Emit progress update
190 | const progress = this.getProgress();
191 | this.emit('progress:updated', {
192 | completed: progress.completed,
193 | total: progress.total,
194 | percentage: progress.percentage
195 | });
196 |
197 | // Start next subtask or complete workflow
198 | if (this.context.currentSubtaskIndex < this.context.subtasks.length) {
199 | this.context.currentTDDPhase = 'RED';
200 | this.emit('tdd:red:started');
201 | this.emit('subtask:started');
202 | } else {
203 | // All subtasks complete, transition to FINALIZE
204 | await this.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
205 | }
206 | break;
207 | }
208 |
209 | // Normal RED phase: has failing tests, proceed to GREEN
210 | this.emit('tdd:red:completed');
211 | this.context.currentTDDPhase = 'GREEN';
212 | this.emit('tdd:green:started');
213 | break;
214 |
215 | case 'GREEN_PHASE_COMPLETE':
216 | if (currentTDD !== 'GREEN') {
217 | throw new Error(
218 | 'Invalid transition: GREEN_PHASE_COMPLETE from non-GREEN phase'
219 | );
220 | }
221 |
222 | // Validate test results are provided
223 | if (!event.testResults) {
224 | throw new Error('Test results required for GREEN phase transition');
225 | }
226 |
227 | // Validate GREEN phase has no failures
228 | if (event.testResults.failed !== 0) {
229 | throw new Error('GREEN phase must have zero failures');
230 | }
231 |
232 | // Store test results in context
233 | this.context.lastTestResults = event.testResults;
234 |
235 | this.emit('tdd:green:completed');
236 | this.context.currentTDDPhase = 'COMMIT';
237 | this.emit('tdd:commit:started');
238 | break;
239 |
240 | case 'COMMIT_COMPLETE':
241 | if (currentTDD !== 'COMMIT') {
242 | throw new Error(
243 | 'Invalid transition: COMMIT_COMPLETE from non-COMMIT phase'
244 | );
245 | }
246 | this.emit('tdd:commit:completed');
247 | // Mark current subtask as completed
248 | const currentSubtask =
249 | this.context.subtasks[this.context.currentSubtaskIndex];
250 | if (currentSubtask) {
251 | currentSubtask.status = 'completed';
252 | }
253 | break;
254 |
255 | case 'SUBTASK_COMPLETE':
256 | this.emit('subtask:completed');
257 | // Move to next subtask
258 | this.context.currentSubtaskIndex++;
259 |
260 | // Emit progress update
261 | const progress = this.getProgress();
262 | this.emit('progress:updated', {
263 | completed: progress.completed,
264 | total: progress.total,
265 | percentage: progress.percentage
266 | });
267 |
268 | if (this.context.currentSubtaskIndex < this.context.subtasks.length) {
269 | // Start next subtask with RED phase
270 | this.context.currentTDDPhase = 'RED';
271 | this.emit('tdd:red:started');
272 | this.emit('subtask:started');
273 | } else {
274 | // All subtasks complete, transition to FINALIZE
275 | await this.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
276 | }
277 | break;
278 |
279 | case 'ALL_SUBTASKS_COMPLETE':
280 | // Transition to FINALIZE phase
281 | this.emit('phase:exited');
282 | this.currentPhase = 'FINALIZE';
283 | this.context.currentTDDPhase = undefined;
284 | this.emit('phase:entered');
285 | // Note: Don't auto-transition to COMPLETE - requires explicit finalize call
286 | break;
287 |
288 | default:
289 | throw new Error(`Invalid transition: ${event.type} in SUBTASK_LOOP`);
290 | }
291 | }
292 |
293 | /**
294 | * Execute a state transition
295 | */
296 | private executeTransition(
297 | transition: StateTransition,
298 | event: WorkflowEvent
299 | ): void {
300 | // Check guard condition if present
301 | if (transition.guard && !transition.guard(this.context)) {
302 | throw new Error(
303 | `Guard condition failed for transition to ${transition.to}`
304 | );
305 | }
306 |
307 | // Check phase-specific guard if present
308 | const phaseGuard = this.phaseGuards.get(transition.to);
309 | if (phaseGuard && !phaseGuard(this.context)) {
310 | throw new Error('Guard condition failed');
311 | }
312 |
313 | // Emit phase exit event
314 | this.emit('phase:exited');
315 |
316 | // Update context based on event
317 | this.updateContext(event);
318 |
319 | // Transition to new phase
320 | this.currentPhase = transition.to;
321 |
322 | // Emit phase entry event
323 | this.emit('phase:entered');
324 |
325 | // Initialize TDD phase if entering SUBTASK_LOOP
326 | if (this.currentPhase === 'SUBTASK_LOOP') {
327 | this.context.currentTDDPhase = 'RED';
328 | this.emit('tdd:red:started');
329 | this.emit('subtask:started');
330 | }
331 | }
332 |
333 | /**
334 | * Update context based on event
335 | */
336 | private updateContext(event: WorkflowEvent): void {
337 | switch (event.type) {
338 | case 'BRANCH_CREATED':
339 | this.context.branchName = event.branchName;
340 | this.emit('git:branch:created', { branchName: event.branchName });
341 |
342 | // Trigger git operation hook
343 | if (this.gitOperationHook) {
344 | this.gitOperationHook('branch:created', {
345 | branchName: event.branchName
346 | });
347 | }
348 | break;
349 |
350 | case 'ERROR':
351 | this.context.errors.push(event.error);
352 | this.emit('error:occurred', { error: event.error });
353 | break;
354 |
355 | // Add more context updates as needed
356 | }
357 | }
358 |
359 | /**
360 | * Get current state for serialization
361 | */
362 | getState(): WorkflowState {
363 | return {
364 | phase: this.currentPhase,
365 | context: { ...this.context }
366 | };
367 | }
368 |
369 | /**
370 | * Restore state from checkpoint
371 | */
372 | restoreState(state: WorkflowState): void {
373 | this.currentPhase = state.phase;
374 | this.context = { ...state.context };
375 |
376 | // Emit workflow:resumed event
377 | this.emit('workflow:resumed', {
378 | phase: this.currentPhase,
379 | progress: this.getProgress()
380 | });
381 | }
382 |
383 | /**
384 | * Add event listener
385 | */
386 | on(eventType: WorkflowEventType, listener: WorkflowEventListener): void {
387 | if (!this.eventListeners.has(eventType)) {
388 | this.eventListeners.set(eventType, new Set());
389 | }
390 | this.eventListeners.get(eventType)!.add(listener);
391 | }
392 |
393 | /**
394 | * Remove event listener
395 | */
396 | off(eventType: WorkflowEventType, listener: WorkflowEventListener): void {
397 | const listeners = this.eventListeners.get(eventType);
398 | if (listeners) {
399 | listeners.delete(listener);
400 | }
401 | }
402 |
403 | /**
404 | * Emit workflow event
405 | */
406 | private emit(
407 | eventType: WorkflowEventType,
408 | data?: Record<string, unknown>
409 | ): void {
410 | const eventData: WorkflowEventData = {
411 | type: eventType,
412 | timestamp: new Date(),
413 | phase: this.currentPhase,
414 | tddPhase: this.context.currentTDDPhase,
415 | subtaskId: this.getCurrentSubtaskId(),
416 | data: {
417 | ...data,
418 | adapters: {
419 | testValidator: !!this.testResultValidator,
420 | gitHook: !!this.gitOperationHook,
421 | executeHook: !!this.executeHook
422 | }
423 | }
424 | };
425 |
426 | const listeners = this.eventListeners.get(eventType);
427 | if (listeners) {
428 | listeners.forEach((listener) => listener(eventData));
429 | }
430 | }
431 |
432 | /**
433 | * Get current subtask ID
434 | */
435 | private getCurrentSubtaskId(): string | undefined {
436 | const currentSubtask =
437 | this.context.subtasks[this.context.currentSubtaskIndex];
438 | return currentSubtask?.id;
439 | }
440 |
441 | /**
442 | * Register callback for state persistence
443 | */
444 | onStatePersist(
445 | callback: (state: WorkflowState) => void | Promise<void>
446 | ): void {
447 | this.persistCallback = callback;
448 | }
449 |
450 | /**
451 | * Enable auto-persistence after each transition
452 | */
453 | enableAutoPersist(
454 | callback: (state: WorkflowState) => void | Promise<void>
455 | ): void {
456 | this.persistCallback = callback;
457 | this.autoPersistEnabled = true;
458 | }
459 |
460 | /**
461 | * Disable auto-persistence
462 | */
463 | disableAutoPersist(): void {
464 | this.autoPersistEnabled = false;
465 | }
466 |
467 | /**
468 | * Manually persist current state
469 | */
470 | async persistState(): Promise<void> {
471 | if (this.persistCallback) {
472 | await this.persistCallback(this.getState());
473 | }
474 | this.emit('state:persisted');
475 | }
476 |
477 | /**
478 | * Trigger auto-persistence if enabled
479 | */
480 | private async triggerAutoPersist(): Promise<void> {
481 | if (this.autoPersistEnabled && this.persistCallback) {
482 | await this.persistCallback(this.getState());
483 | }
484 | }
485 |
486 | /**
487 | * Add a guard condition for a specific phase
488 | */
489 | addGuard(
490 | phase: WorkflowPhase,
491 | guard: (context: WorkflowContext) => boolean
492 | ): void {
493 | this.phaseGuards.set(phase, guard);
494 | }
495 |
496 | /**
497 | * Remove a guard condition for a specific phase
498 | */
499 | removeGuard(phase: WorkflowPhase): void {
500 | this.phaseGuards.delete(phase);
501 | }
502 |
503 | /**
504 | * Get current subtask being worked on
505 | */
506 | getCurrentSubtask(): SubtaskInfo | undefined {
507 | return this.context.subtasks[this.context.currentSubtaskIndex];
508 | }
509 |
510 | /**
511 | * Get workflow progress information
512 | */
513 | getProgress(): {
514 | completed: number;
515 | total: number;
516 | current: number;
517 | percentage: number;
518 | } {
519 | const completed = this.context.subtasks.filter(
520 | (st) => st.status === 'completed'
521 | ).length;
522 | const total = this.context.subtasks.length;
523 | const current = this.context.currentSubtaskIndex + 1;
524 | const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
525 |
526 | return { completed, total, current, percentage };
527 | }
528 |
529 | /**
530 | * Check if can proceed to next subtask or phase
531 | */
532 | canProceed(): boolean {
533 | if (this.currentPhase !== 'SUBTASK_LOOP') {
534 | return false;
535 | }
536 |
537 | const currentSubtask = this.getCurrentSubtask();
538 |
539 | // Can proceed if current subtask is completed (after COMMIT phase)
540 | return currentSubtask?.status === 'completed';
541 | }
542 |
543 | /**
544 | * Increment attempts for current subtask
545 | */
546 | incrementAttempts(): void {
547 | const currentSubtask = this.getCurrentSubtask();
548 | if (currentSubtask) {
549 | currentSubtask.attempts++;
550 | }
551 | }
552 |
553 | /**
554 | * Check if current subtask has exceeded max attempts
555 | */
556 | hasExceededMaxAttempts(): boolean {
557 | const currentSubtask = this.getCurrentSubtask();
558 | if (!currentSubtask || !currentSubtask.maxAttempts) {
559 | return false;
560 | }
561 |
562 | return currentSubtask.attempts > currentSubtask.maxAttempts;
563 | }
564 |
565 | /**
566 | * Handle error event
567 | */
568 | private handleError(error: WorkflowError): void {
569 | this.context.errors.push(error);
570 | this.emit('error:occurred', { error });
571 | }
572 |
573 | /**
574 | * Handle retry event
575 | */
576 | private handleRetry(): void {
577 | if (this.currentPhase === 'SUBTASK_LOOP') {
578 | // Reset to RED phase to retry current subtask
579 | this.context.currentTDDPhase = 'RED';
580 | this.emit('tdd:red:started');
581 | }
582 | }
583 |
584 | /**
585 | * Retry current subtask (resets to RED phase)
586 | */
587 | retryCurrentSubtask(): void {
588 | if (this.currentPhase === 'SUBTASK_LOOP') {
589 | this.context.currentTDDPhase = 'RED';
590 | this.emit('tdd:red:started');
591 | }
592 | }
593 |
594 | /**
595 | * Handle max attempts exceeded for current subtask
596 | */
597 | handleMaxAttemptsExceeded(): void {
598 | const currentSubtask = this.getCurrentSubtask();
599 | if (currentSubtask) {
600 | currentSubtask.status = 'failed';
601 | this.emit('subtask:failed', {
602 | subtaskId: currentSubtask.id,
603 | attempts: currentSubtask.attempts,
604 | maxAttempts: currentSubtask.maxAttempts
605 | });
606 | }
607 | }
608 |
609 | /**
610 | * Check if workflow has been aborted
611 | */
612 | isAborted(): boolean {
613 | return this.aborted;
614 | }
615 |
616 | /**
617 | * Validate if a state can be resumed from
618 | */
619 | canResumeFromState(state: WorkflowState): boolean {
620 | // Validate phase is valid
621 | const validPhases: WorkflowPhase[] = [
622 | 'PREFLIGHT',
623 | 'BRANCH_SETUP',
624 | 'SUBTASK_LOOP',
625 | 'FINALIZE',
626 | 'COMPLETE'
627 | ];
628 |
629 | if (!validPhases.includes(state.phase)) {
630 | return false;
631 | }
632 |
633 | // Validate context structure
634 | if (!state.context || typeof state.context !== 'object') {
635 | return false;
636 | }
637 |
638 | // Validate required context fields
639 | if (!state.context.taskId || !Array.isArray(state.context.subtasks)) {
640 | return false;
641 | }
642 |
643 | if (typeof state.context.currentSubtaskIndex !== 'number') {
644 | return false;
645 | }
646 |
647 | if (!Array.isArray(state.context.errors)) {
648 | return false;
649 | }
650 |
651 | // All validations passed
652 | return true;
653 | }
654 |
655 | /**
656 | * Set TestResultValidator adapter
657 | */
658 | setTestResultValidator(validator: TestResultValidator): void {
659 | this.testResultValidator = validator;
660 | this.emit('adapter:configured', { adapterType: 'test-validator' });
661 | }
662 |
663 | /**
664 | * Check if TestResultValidator is configured
665 | */
666 | hasTestResultValidator(): boolean {
667 | return !!this.testResultValidator;
668 | }
669 |
670 | /**
671 | * Remove TestResultValidator adapter
672 | */
673 | removeTestResultValidator(): void {
674 | this.testResultValidator = undefined;
675 | }
676 |
677 | /**
678 | * Register git operation hook
679 | */
680 | onGitOperation(hook: (operation: string, data?: unknown) => void): void {
681 | this.gitOperationHook = hook;
682 | }
683 |
684 | /**
685 | * Register execute command hook
686 | */
687 | onExecute(hook: (command: string, context: WorkflowContext) => void): void {
688 | this.executeHook = hook;
689 | }
690 |
691 | /**
692 | * Execute a command (triggers execute hook)
693 | */
694 | executeCommand(command: string): void {
695 | if (this.executeHook) {
696 | this.executeHook(command, this.context);
697 | }
698 | }
699 | }
700 |
```
--------------------------------------------------------------------------------
/tests/unit/scripts/modules/task-manager/research.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { jest } from '@jest/globals';
2 |
3 | jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
4 | findProjectRoot: jest.fn(() => '/test/project/root'),
5 | log: jest.fn(),
6 | readJSON: jest.fn(),
7 | flattenTasksWithSubtasks: jest.fn(() => []),
8 | isEmpty: jest.fn(() => false)
9 | }));
10 |
11 | // Mock UI-affecting external libs to minimal no-op implementations
12 | jest.unstable_mockModule('chalk', () => ({
13 | default: {
14 | white: Object.assign(
15 | jest.fn((text) => text),
16 | {
17 | bold: jest.fn((text) => text)
18 | }
19 | ),
20 | cyan: Object.assign(
21 | jest.fn((text) => text),
22 | {
23 | bold: jest.fn((text) => text)
24 | }
25 | ),
26 | green: Object.assign(
27 | jest.fn((text) => text),
28 | {
29 | bold: jest.fn((text) => text)
30 | }
31 | ),
32 | yellow: jest.fn((text) => text),
33 | red: jest.fn((text) => text),
34 | gray: jest.fn((text) => text),
35 | blue: Object.assign(
36 | jest.fn((text) => text),
37 | {
38 | bold: jest.fn((text) => text)
39 | }
40 | ),
41 | bold: jest.fn((text) => text)
42 | }
43 | }));
44 |
45 | jest.unstable_mockModule('boxen', () => ({ default: (text) => text }));
46 |
47 | jest.unstable_mockModule('inquirer', () => ({
48 | default: { prompt: jest.fn() }
49 | }));
50 |
51 | jest.unstable_mockModule('cli-highlight', () => ({
52 | highlight: (code) => code
53 | }));
54 |
55 | jest.unstable_mockModule('cli-table3', () => ({
56 | default: jest.fn().mockImplementation(() => ({
57 | push: jest.fn(),
58 | toString: jest.fn(() => '')
59 | }))
60 | }));
61 |
62 | jest.unstable_mockModule(
63 | '../../../../../scripts/modules/utils/contextGatherer.js',
64 | () => ({
65 | ContextGatherer: jest.fn().mockImplementation(() => ({
66 | gather: jest.fn().mockResolvedValue({
67 | context: 'Gathered context',
68 | tokenBreakdown: { total: 500 }
69 | }),
70 | countTokens: jest.fn(() => 100)
71 | }))
72 | })
73 | );
74 |
75 | jest.unstable_mockModule(
76 | '../../../../../scripts/modules/utils/fuzzyTaskSearch.js',
77 | () => ({
78 | FuzzyTaskSearch: jest.fn().mockImplementation(() => ({
79 | findRelevantTasks: jest.fn(() => []),
80 | getTaskIds: jest.fn(() => [])
81 | }))
82 | })
83 | );
84 |
85 | jest.unstable_mockModule(
86 | '../../../../../scripts/modules/ai-services-unified.js',
87 | () => ({
88 | generateTextService: jest.fn().mockResolvedValue({
89 | mainResult:
90 | 'Test research result with ```javascript\nconsole.log("test");\n```',
91 | telemetryData: {}
92 | })
93 | })
94 | );
95 |
96 | jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
97 | displayAiUsageSummary: jest.fn(),
98 | startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })),
99 | stopLoadingIndicator: jest.fn()
100 | }));
101 |
102 | jest.unstable_mockModule(
103 | '../../../../../scripts/modules/prompt-manager.js',
104 | () => ({
105 | getPromptManager: jest.fn().mockReturnValue({
106 | loadPrompt: jest.fn().mockResolvedValue({
107 | systemPrompt: 'System prompt',
108 | userPrompt: 'User prompt'
109 | })
110 | })
111 | })
112 | );
113 |
114 | const { performResearch } = await import(
115 | '../../../../../scripts/modules/task-manager/research.js'
116 | );
117 |
118 | // Import mocked modules for testing
119 | const utils = await import('../../../../../scripts/modules/utils.js');
120 | const { ContextGatherer } = await import(
121 | '../../../../../scripts/modules/utils/contextGatherer.js'
122 | );
123 | const { FuzzyTaskSearch } = await import(
124 | '../../../../../scripts/modules/utils/fuzzyTaskSearch.js'
125 | );
126 | const { generateTextService } = await import(
127 | '../../../../../scripts/modules/ai-services-unified.js'
128 | );
129 |
130 | describe('performResearch project root validation', () => {
131 | it('throws error when project root cannot be determined', async () => {
132 | // Mock findProjectRoot to return null
133 | utils.findProjectRoot.mockReturnValueOnce(null);
134 |
135 | await expect(
136 | performResearch('Test query', {}, {}, 'json', false)
137 | ).rejects.toThrow('Could not determine project root directory');
138 | });
139 | });
140 |
141 | describe('performResearch tag-aware functionality', () => {
142 | let mockContextGatherer;
143 | let mockFuzzySearch;
144 | let mockReadJSON;
145 | let mockFlattenTasks;
146 |
147 | beforeEach(() => {
148 | // Reset all mocks
149 | jest.clearAllMocks();
150 |
151 | // Set up default mocks
152 | utils.findProjectRoot.mockReturnValue('/test/project/root');
153 | utils.readJSON.mockResolvedValue({
154 | tasks: [
155 | { id: 1, title: 'Task 1', description: 'Description 1' },
156 | { id: 2, title: 'Task 2', description: 'Description 2' }
157 | ]
158 | });
159 | utils.flattenTasksWithSubtasks.mockReturnValue([
160 | { id: 1, title: 'Task 1', description: 'Description 1' },
161 | { id: 2, title: 'Task 2', description: 'Description 2' }
162 | ]);
163 |
164 | // Set up ContextGatherer mock
165 | mockContextGatherer = {
166 | gather: jest.fn().mockResolvedValue({
167 | context: 'Gathered context',
168 | tokenBreakdown: { total: 500 }
169 | }),
170 | countTokens: jest.fn(() => 100)
171 | };
172 | ContextGatherer.mockImplementation(() => mockContextGatherer);
173 |
174 | // Set up FuzzyTaskSearch mock
175 | mockFuzzySearch = {
176 | findRelevantTasks: jest.fn(() => [
177 | { id: 1, title: 'Task 1', description: 'Description 1' }
178 | ]),
179 | getTaskIds: jest.fn(() => ['1'])
180 | };
181 | FuzzyTaskSearch.mockImplementation(() => mockFuzzySearch);
182 |
183 | // Store references for easier access
184 | mockReadJSON = utils.readJSON;
185 | mockFlattenTasks = utils.flattenTasksWithSubtasks;
186 | });
187 |
188 | describe('tag parameter passing to ContextGatherer', () => {
189 | it('passes tag parameter to ContextGatherer constructor', async () => {
190 | const testTag = 'feature-branch';
191 |
192 | await performResearch('Test query', { tag: testTag }, {}, 'json', false);
193 |
194 | expect(ContextGatherer).toHaveBeenCalledWith(
195 | '/test/project/root',
196 | testTag
197 | );
198 | });
199 |
200 | it('passes undefined tag when no tag is provided', async () => {
201 | await performResearch('Test query', {}, {}, 'json', false);
202 |
203 | expect(ContextGatherer).toHaveBeenCalledWith(
204 | '/test/project/root',
205 | undefined
206 | );
207 | });
208 |
209 | it('passes empty string tag when empty string is provided', async () => {
210 | await performResearch('Test query', { tag: '' }, {}, 'json', false);
211 |
212 | expect(ContextGatherer).toHaveBeenCalledWith('/test/project/root', '');
213 | });
214 |
215 | it('passes null tag when null is provided', async () => {
216 | await performResearch('Test query', { tag: null }, {}, 'json', false);
217 |
218 | expect(ContextGatherer).toHaveBeenCalledWith('/test/project/root', null);
219 | });
220 | });
221 |
222 | describe('tag-aware readJSON calls', () => {
223 | it('calls readJSON with correct tag parameter for task discovery', async () => {
224 | const testTag = 'development';
225 |
226 | await performResearch('Test query', { tag: testTag }, {}, 'json', false);
227 |
228 | expect(mockReadJSON).toHaveBeenCalledWith(
229 | expect.stringContaining('tasks.json'),
230 | '/test/project/root',
231 | testTag
232 | );
233 | });
234 |
235 | it('calls readJSON with undefined tag when no tag provided', async () => {
236 | await performResearch('Test query', {}, {}, 'json', false);
237 |
238 | expect(mockReadJSON).toHaveBeenCalledWith(
239 | expect.stringContaining('tasks.json'),
240 | '/test/project/root',
241 | undefined
242 | );
243 | });
244 |
245 | it('calls readJSON with provided projectRoot and tag', async () => {
246 | const customProjectRoot = '/custom/project/root';
247 | const testTag = 'production';
248 |
249 | await performResearch(
250 | 'Test query',
251 | {
252 | projectRoot: customProjectRoot,
253 | tag: testTag
254 | },
255 | {},
256 | 'json',
257 | false
258 | );
259 |
260 | expect(mockReadJSON).toHaveBeenCalledWith(
261 | expect.stringContaining('tasks.json'),
262 | customProjectRoot,
263 | testTag
264 | );
265 | });
266 | });
267 |
268 | describe('context gathering behavior for different tags', () => {
269 | it('calls contextGatherer.gather with correct parameters', async () => {
270 | const options = {
271 | taskIds: ['1', '2'],
272 | filePaths: ['src/file.js'],
273 | customContext: 'Custom context',
274 | includeProjectTree: true,
275 | tag: 'feature-branch'
276 | };
277 |
278 | await performResearch('Test query', options, {}, 'json', false);
279 |
280 | expect(mockContextGatherer.gather).toHaveBeenCalledWith({
281 | tasks: expect.arrayContaining(['1', '2']),
282 | files: ['src/file.js'],
283 | customContext: 'Custom context',
284 | includeProjectTree: true,
285 | format: 'research',
286 | includeTokenCounts: true
287 | });
288 | });
289 |
290 | it('handles empty task discovery gracefully when readJSON fails', async () => {
291 | mockReadJSON.mockRejectedValueOnce(new Error('File not found'));
292 |
293 | const result = await performResearch(
294 | 'Test query',
295 | { tag: 'test-tag' },
296 | {},
297 | 'json',
298 | false
299 | );
300 |
301 | // Should still succeed even if task discovery fails
302 | expect(result).toBeDefined();
303 | expect(mockContextGatherer.gather).toHaveBeenCalledWith({
304 | tasks: [],
305 | files: [],
306 | customContext: '',
307 | includeProjectTree: false,
308 | format: 'research',
309 | includeTokenCounts: true
310 | });
311 | });
312 |
313 | it('combines provided taskIds with auto-discovered tasks', async () => {
314 | const providedTaskIds = ['3', '4'];
315 | const autoDiscoveredIds = ['1', '2'];
316 |
317 | mockFuzzySearch.getTaskIds.mockReturnValue(autoDiscoveredIds);
318 |
319 | await performResearch(
320 | 'Test query',
321 | {
322 | taskIds: providedTaskIds,
323 | tag: 'feature-branch'
324 | },
325 | {},
326 | 'json',
327 | false
328 | );
329 |
330 | expect(mockContextGatherer.gather).toHaveBeenCalledWith({
331 | tasks: expect.arrayContaining([
332 | ...providedTaskIds,
333 | ...autoDiscoveredIds
334 | ]),
335 | files: [],
336 | customContext: '',
337 | includeProjectTree: false,
338 | format: 'research',
339 | includeTokenCounts: true
340 | });
341 | });
342 |
343 | it('removes duplicate tasks when auto-discovered tasks overlap with provided tasks', async () => {
344 | const providedTaskIds = ['1', '2'];
345 | const autoDiscoveredIds = ['2', '3']; // '2' is duplicate
346 |
347 | mockFuzzySearch.getTaskIds.mockReturnValue(autoDiscoveredIds);
348 |
349 | await performResearch(
350 | 'Test query',
351 | {
352 | taskIds: providedTaskIds,
353 | tag: 'feature-branch'
354 | },
355 | {},
356 | 'json',
357 | false
358 | );
359 |
360 | expect(mockContextGatherer.gather).toHaveBeenCalledWith({
361 | tasks: ['1', '2', '3'], // Should include '3' but not duplicate '2'
362 | files: [],
363 | customContext: '',
364 | includeProjectTree: false,
365 | format: 'research',
366 | includeTokenCounts: true
367 | });
368 | });
369 | });
370 |
371 | describe('tag-aware fuzzy search', () => {
372 | it('initializes FuzzyTaskSearch with flattened tasks from correct tag', async () => {
373 | const testTag = 'development';
374 | const mockFlattenedTasks = [
375 | { id: 1, title: 'Dev Task 1' },
376 | { id: 2, title: 'Dev Task 2' }
377 | ];
378 |
379 | mockFlattenTasks.mockReturnValue(mockFlattenedTasks);
380 |
381 | await performResearch('Test query', { tag: testTag }, {}, 'json', false);
382 |
383 | expect(mockFlattenTasks).toHaveBeenCalledWith(
384 | expect.arrayContaining([
385 | expect.objectContaining({ id: 1 }),
386 | expect.objectContaining({ id: 2 })
387 | ])
388 | );
389 | expect(FuzzyTaskSearch).toHaveBeenCalledWith(
390 | mockFlattenedTasks,
391 | 'research'
392 | );
393 | });
394 |
395 | it('calls fuzzy search with correct parameters', async () => {
396 | const testQuery = 'authentication implementation';
397 |
398 | await performResearch(
399 | testQuery,
400 | { tag: 'feature-branch' },
401 | {},
402 | 'json',
403 | false
404 | );
405 |
406 | expect(mockFuzzySearch.findRelevantTasks).toHaveBeenCalledWith(
407 | testQuery,
408 | {
409 | maxResults: 8,
410 | includeRecent: true,
411 | includeCategoryMatches: true
412 | }
413 | );
414 | });
415 |
416 | it('handles empty tasks data gracefully', async () => {
417 | mockReadJSON.mockResolvedValueOnce({ tasks: [] });
418 |
419 | await performResearch(
420 | 'Test query',
421 | { tag: 'empty-tag' },
422 | {},
423 | 'json',
424 | false
425 | );
426 |
427 | // Should not call FuzzyTaskSearch when no tasks exist
428 | expect(FuzzyTaskSearch).not.toHaveBeenCalled();
429 | expect(mockContextGatherer.gather).toHaveBeenCalledWith({
430 | tasks: [],
431 | files: [],
432 | customContext: '',
433 | includeProjectTree: false,
434 | format: 'research',
435 | includeTokenCounts: true
436 | });
437 | });
438 |
439 | it('handles null tasks data gracefully', async () => {
440 | mockReadJSON.mockResolvedValueOnce(null);
441 |
442 | await performResearch(
443 | 'Test query',
444 | { tag: 'null-tag' },
445 | {},
446 | 'json',
447 | false
448 | );
449 |
450 | // Should not call FuzzyTaskSearch when data is null
451 | expect(FuzzyTaskSearch).not.toHaveBeenCalled();
452 | });
453 | });
454 |
455 | describe('error handling for invalid tags', () => {
456 | it('continues execution when readJSON throws error for invalid tag', async () => {
457 | mockReadJSON.mockRejectedValueOnce(new Error('Tag not found'));
458 |
459 | const result = await performResearch(
460 | 'Test query',
461 | { tag: 'invalid-tag' },
462 | {},
463 | 'json',
464 | false
465 | );
466 |
467 | // Should still succeed and return a result
468 | expect(result).toBeDefined();
469 | expect(mockContextGatherer.gather).toHaveBeenCalled();
470 | });
471 |
472 | it('logs debug message when task discovery fails', async () => {
473 | const mockLog = {
474 | debug: jest.fn(),
475 | info: jest.fn(),
476 | warn: jest.fn(),
477 | error: jest.fn(),
478 | success: jest.fn()
479 | };
480 |
481 | mockReadJSON.mockRejectedValueOnce(new Error('File not found'));
482 |
483 | await performResearch(
484 | 'Test query',
485 | { tag: 'error-tag' },
486 | { mcpLog: mockLog },
487 | 'json',
488 | false
489 | );
490 |
491 | expect(mockLog.debug).toHaveBeenCalledWith(
492 | expect.stringContaining('Could not auto-discover tasks')
493 | );
494 | });
495 |
496 | it('handles ContextGatherer constructor errors gracefully', async () => {
497 | ContextGatherer.mockImplementationOnce(() => {
498 | throw new Error('Invalid tag provided');
499 | });
500 |
501 | await expect(
502 | performResearch('Test query', { tag: 'invalid-tag' }, {}, 'json', false)
503 | ).rejects.toThrow('Invalid tag provided');
504 | });
505 |
506 | it('handles ContextGatherer.gather errors gracefully', async () => {
507 | mockContextGatherer.gather.mockRejectedValueOnce(
508 | new Error('Gather failed')
509 | );
510 |
511 | await expect(
512 | performResearch(
513 | 'Test query',
514 | { tag: 'gather-error-tag' },
515 | {},
516 | 'json',
517 | false
518 | )
519 | ).rejects.toThrow('Gather failed');
520 | });
521 | });
522 |
523 | describe('MCP integration with tags', () => {
524 | it('uses MCP logger when mcpLog is provided in context', async () => {
525 | const mockMCPLog = {
526 | debug: jest.fn(),
527 | info: jest.fn(),
528 | warn: jest.fn(),
529 | error: jest.fn(),
530 | success: jest.fn()
531 | };
532 |
533 | mockReadJSON.mockRejectedValueOnce(new Error('Test error'));
534 |
535 | await performResearch(
536 | 'Test query',
537 | { tag: 'mcp-tag' },
538 | { mcpLog: mockMCPLog },
539 | 'json',
540 | false
541 | );
542 |
543 | expect(mockMCPLog.debug).toHaveBeenCalledWith(
544 | expect.stringContaining('Could not auto-discover tasks')
545 | );
546 | });
547 |
548 | it('passes session to generateTextService when provided', async () => {
549 | const mockSession = { userId: 'test-user', env: {} };
550 |
551 | await performResearch(
552 | 'Test query',
553 | { tag: 'session-tag' },
554 | { session: mockSession },
555 | 'json',
556 | false
557 | );
558 |
559 | expect(generateTextService).toHaveBeenCalledWith(
560 | expect.objectContaining({
561 | session: mockSession
562 | })
563 | );
564 | });
565 | });
566 |
567 | describe('output format handling with tags', () => {
568 | it('displays UI banner only in text format', async () => {
569 | const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
570 |
571 | await performResearch('Test query', { tag: 'ui-tag' }, {}, 'text', false);
572 |
573 | expect(consoleSpy).toHaveBeenCalledWith(
574 | expect.stringContaining('🔍 AI Research Query')
575 | );
576 |
577 | consoleSpy.mockRestore();
578 | });
579 |
580 | it('does not display UI banner in json format', async () => {
581 | const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
582 |
583 | await performResearch('Test query', { tag: 'ui-tag' }, {}, 'json', false);
584 |
585 | expect(consoleSpy).not.toHaveBeenCalledWith(
586 | expect.stringContaining('🔍 AI Research Query')
587 | );
588 |
589 | consoleSpy.mockRestore();
590 | });
591 | });
592 |
593 | describe('comprehensive tag integration test', () => {
594 | it('performs complete research flow with tag-aware functionality', async () => {
595 | const testOptions = {
596 | taskIds: ['1', '2'],
597 | filePaths: ['src/main.js'],
598 | customContext: 'Testing tag integration',
599 | includeProjectTree: true,
600 | detailLevel: 'high',
601 | tag: 'integration-test',
602 | projectRoot: '/custom/root'
603 | };
604 |
605 | const testContext = {
606 | session: { userId: 'test-user' },
607 | mcpLog: {
608 | debug: jest.fn(),
609 | info: jest.fn(),
610 | warn: jest.fn(),
611 | error: jest.fn(),
612 | success: jest.fn()
613 | },
614 | commandName: 'test-research',
615 | outputType: 'mcp'
616 | };
617 |
618 | // Mock successful task discovery
619 | mockFuzzySearch.getTaskIds.mockReturnValue(['3', '4']);
620 |
621 | const result = await performResearch(
622 | 'Integration test query',
623 | testOptions,
624 | testContext,
625 | 'json',
626 | false
627 | );
628 |
629 | // Verify ContextGatherer was initialized with correct tag
630 | expect(ContextGatherer).toHaveBeenCalledWith(
631 | '/custom/root',
632 | 'integration-test'
633 | );
634 |
635 | // Verify readJSON was called with correct parameters
636 | expect(mockReadJSON).toHaveBeenCalledWith(
637 | expect.stringContaining('tasks.json'),
638 | '/custom/root',
639 | 'integration-test'
640 | );
641 |
642 | // Verify context gathering was called with combined tasks
643 | expect(mockContextGatherer.gather).toHaveBeenCalledWith({
644 | tasks: ['1', '2', '3', '4'],
645 | files: ['src/main.js'],
646 | customContext: 'Testing tag integration',
647 | includeProjectTree: true,
648 | format: 'research',
649 | includeTokenCounts: true
650 | });
651 |
652 | // Verify AI service was called with session
653 | expect(generateTextService).toHaveBeenCalledWith(
654 | expect.objectContaining({
655 | session: testContext.session,
656 | role: 'research'
657 | })
658 | );
659 |
660 | expect(result).toBeDefined();
661 | });
662 | });
663 | });
664 |
```
--------------------------------------------------------------------------------
/tests/unit/profiles/mcp-config-validation.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { RULE_PROFILES } from '../../../src/constants/profiles.js';
2 | import { getRulesProfile } from '../../../src/utils/rule-transformer.js';
3 | import path from 'path';
4 |
5 | describe('MCP Configuration Validation', () => {
6 | describe('Profile MCP Configuration Properties', () => {
7 | const expectedMcpConfigurations = {
8 | amp: {
9 | shouldHaveMcp: true,
10 | expectedDir: '.vscode',
11 | expectedConfigName: 'settings.json',
12 | expectedPath: '.vscode/settings.json'
13 | },
14 | claude: {
15 | shouldHaveMcp: true,
16 | expectedDir: '.',
17 | expectedConfigName: '.mcp.json',
18 | expectedPath: '.mcp.json'
19 | },
20 | cline: {
21 | shouldHaveMcp: false,
22 | expectedDir: '.clinerules',
23 | expectedConfigName: null,
24 | expectedPath: null
25 | },
26 | codex: {
27 | shouldHaveMcp: false,
28 | expectedDir: '.',
29 | expectedConfigName: null,
30 | expectedPath: null
31 | },
32 | cursor: {
33 | shouldHaveMcp: true,
34 | expectedDir: '.cursor',
35 | expectedConfigName: 'mcp.json',
36 | expectedPath: '.cursor/mcp.json'
37 | },
38 | gemini: {
39 | shouldHaveMcp: true,
40 | expectedDir: '.gemini',
41 | expectedConfigName: 'settings.json',
42 | expectedPath: '.gemini/settings.json'
43 | },
44 | kiro: {
45 | shouldHaveMcp: true,
46 | expectedDir: '.kiro',
47 | expectedConfigName: 'settings/mcp.json',
48 | expectedPath: '.kiro/settings/mcp.json'
49 | },
50 | opencode: {
51 | shouldHaveMcp: true,
52 | expectedDir: '.',
53 | expectedConfigName: 'opencode.json',
54 | expectedPath: 'opencode.json'
55 | },
56 | roo: {
57 | shouldHaveMcp: true,
58 | expectedDir: '.roo',
59 | expectedConfigName: 'mcp.json',
60 | expectedPath: '.roo/mcp.json'
61 | },
62 | trae: {
63 | shouldHaveMcp: false,
64 | expectedDir: '.trae',
65 | expectedConfigName: null,
66 | expectedPath: null
67 | },
68 | vscode: {
69 | shouldHaveMcp: true,
70 | expectedDir: '.vscode',
71 | expectedConfigName: 'mcp.json',
72 | expectedPath: '.vscode/mcp.json'
73 | },
74 | windsurf: {
75 | shouldHaveMcp: true,
76 | expectedDir: '.windsurf',
77 | expectedConfigName: 'mcp.json',
78 | expectedPath: '.windsurf/mcp.json'
79 | },
80 | zed: {
81 | shouldHaveMcp: true,
82 | expectedDir: '.zed',
83 | expectedConfigName: 'settings.json',
84 | expectedPath: '.zed/settings.json'
85 | }
86 | };
87 |
88 | Object.entries(expectedMcpConfigurations).forEach(
89 | ([profileName, expected]) => {
90 | test(`should have correct MCP configuration for ${profileName} profile`, () => {
91 | const profile = getRulesProfile(profileName);
92 | expect(profile).toBeDefined();
93 | expect(profile.mcpConfig).toBe(expected.shouldHaveMcp);
94 | expect(profile.profileDir).toBe(expected.expectedDir);
95 | expect(profile.mcpConfigName).toBe(expected.expectedConfigName);
96 | expect(profile.mcpConfigPath).toBe(expected.expectedPath);
97 | });
98 | }
99 | );
100 | });
101 |
102 | describe('MCP Configuration Path Consistency', () => {
103 | test('should ensure all profiles have consistent mcpConfigPath construction', () => {
104 | RULE_PROFILES.forEach((profileName) => {
105 | const profile = getRulesProfile(profileName);
106 | if (profile.mcpConfig !== false) {
107 | // For root directory profiles, path.join('.', filename) normalizes to just 'filename'
108 | // except for Claude which uses '.mcp.json' explicitly
109 | let expectedPath;
110 | if (profile.profileDir === '.') {
111 | if (profileName === 'claude') {
112 | expectedPath = '.mcp.json'; // Claude explicitly uses '.mcp.json'
113 | } else {
114 | expectedPath = profile.mcpConfigName; // Other root profiles normalize to just the filename
115 | }
116 | } else {
117 | expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`;
118 | }
119 | expect(profile.mcpConfigPath).toBe(expectedPath);
120 | }
121 | });
122 | });
123 |
124 | test('should ensure no two profiles have the same MCP config path', () => {
125 | const mcpPaths = new Set();
126 | RULE_PROFILES.forEach((profileName) => {
127 | const profile = getRulesProfile(profileName);
128 | if (profile.mcpConfig !== false) {
129 | expect(mcpPaths.has(profile.mcpConfigPath)).toBe(false);
130 | mcpPaths.add(profile.mcpConfigPath);
131 | }
132 | });
133 | });
134 |
135 | test('should ensure all MCP-enabled profiles use proper directory structure', () => {
136 | const rootProfiles = ['opencode', 'claude', 'codex']; // Profiles that use root directory for config
137 | const nestedConfigProfiles = ['kiro']; // Profiles that use nested directories for config
138 |
139 | RULE_PROFILES.forEach((profileName) => {
140 | const profile = getRulesProfile(profileName);
141 | if (profile.mcpConfig !== false) {
142 | if (rootProfiles.includes(profileName)) {
143 | // Root profiles have different patterns
144 | if (profileName === 'claude') {
145 | expect(profile.mcpConfigPath).toBe('.mcp.json');
146 | } else {
147 | // Other root profiles normalize to just the filename (no ./ prefix)
148 | expect(profile.mcpConfigPath).toMatch(/^[\w_.]+$/);
149 | }
150 | } else if (nestedConfigProfiles.includes(profileName)) {
151 | // Profiles with nested config directories
152 | expect(profile.mcpConfigPath).toMatch(
153 | /^\.[\w-]+\/[\w-]+\/[\w_.]+$/
154 | );
155 | } else {
156 | // Other profiles should have config files in their specific directories
157 | expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/);
158 | }
159 | }
160 | });
161 | });
162 |
163 | test('should ensure all profiles have required MCP properties', () => {
164 | RULE_PROFILES.forEach((profileName) => {
165 | const profile = getRulesProfile(profileName);
166 | expect(profile).toHaveProperty('mcpConfig');
167 | expect(profile).toHaveProperty('profileDir');
168 | expect(profile).toHaveProperty('mcpConfigName');
169 | expect(profile).toHaveProperty('mcpConfigPath');
170 | });
171 | });
172 | });
173 |
174 | describe('MCP Configuration File Names', () => {
175 | test('should use standard mcp.json for MCP-enabled profiles', () => {
176 | const standardMcpProfiles = ['cursor', 'roo', 'vscode', 'windsurf'];
177 | standardMcpProfiles.forEach((profileName) => {
178 | const profile = getRulesProfile(profileName);
179 | expect(profile.mcpConfigName).toBe('mcp.json');
180 | });
181 | });
182 |
183 | test('should use custom settings.json for Gemini profile', () => {
184 | const profile = getRulesProfile('gemini');
185 | expect(profile.mcpConfigName).toBe('settings.json');
186 | });
187 |
188 | test('should have null config name for non-MCP profiles', () => {
189 | // Only codex, cline, and trae profiles should have null config names
190 | const nonMcpProfiles = ['codex', 'cline', 'trae'];
191 |
192 | for (const profileName of nonMcpProfiles) {
193 | const profile = getRulesProfile(profileName);
194 | expect(profile.mcpConfigName).toBe(null);
195 | }
196 | });
197 | });
198 |
199 | describe('Profile Directory Structure', () => {
200 | test('should ensure each profile has a unique directory', () => {
201 | const profileDirs = new Set();
202 | // Profiles that use root directory (can share the same directory)
203 | const rootProfiles = ['claude', 'codex', 'gemini', 'opencode'];
204 | // Profiles that intentionally share the same directory
205 | const sharedDirectoryProfiles = ['amp', 'vscode']; // Both use .vscode
206 |
207 | RULE_PROFILES.forEach((profileName) => {
208 | const profile = getRulesProfile(profileName);
209 |
210 | // Root profiles can share the root directory for rules
211 | if (rootProfiles.includes(profileName) && profile.rulesDir === '.') {
212 | expect(profile.rulesDir).toBe('.');
213 | }
214 |
215 | // Profile directories should be unique (except for root profiles and shared directory profiles)
216 | if (
217 | !rootProfiles.includes(profileName) &&
218 | !sharedDirectoryProfiles.includes(profileName)
219 | ) {
220 | if (profile.profileDir !== '.') {
221 | expect(profileDirs.has(profile.profileDir)).toBe(false);
222 | profileDirs.add(profile.profileDir);
223 | }
224 | } else if (sharedDirectoryProfiles.includes(profileName)) {
225 | // Shared directory profiles should use .vscode
226 | expect(profile.profileDir).toBe('.vscode');
227 | }
228 | });
229 | });
230 |
231 | test('should ensure profile directories follow expected naming convention', () => {
232 | // Profiles that use root directory for rules
233 | const rootRulesProfiles = ['claude', 'codex', 'gemini', 'opencode'];
234 |
235 | RULE_PROFILES.forEach((profileName) => {
236 | const profile = getRulesProfile(profileName);
237 |
238 | // Some profiles use root directory for rules
239 | if (
240 | rootRulesProfiles.includes(profileName) &&
241 | profile.rulesDir === '.'
242 | ) {
243 | expect(profile.rulesDir).toBe('.');
244 | }
245 |
246 | // Profile directories (not rules directories) should follow the .name pattern
247 | // unless they are root profiles with profileDir = '.'
248 | if (profile.profileDir !== '.') {
249 | expect(profile.profileDir).toMatch(/^\.[\w-]+$/);
250 | }
251 | });
252 | });
253 | });
254 |
255 | describe('MCP Configuration Creation Logic', () => {
256 | test('should indicate which profiles require MCP configuration creation', () => {
257 | // Get all profiles that have MCP configuration enabled
258 | const mcpEnabledProfiles = RULE_PROFILES.filter((profileName) => {
259 | const profile = getRulesProfile(profileName);
260 | return profile.mcpConfig !== false;
261 | });
262 |
263 | // Verify expected MCP-enabled profiles
264 | expect(mcpEnabledProfiles).toContain('amp');
265 | expect(mcpEnabledProfiles).toContain('claude');
266 | expect(mcpEnabledProfiles).toContain('cursor');
267 | expect(mcpEnabledProfiles).toContain('gemini');
268 | expect(mcpEnabledProfiles).toContain('opencode');
269 | expect(mcpEnabledProfiles).toContain('vscode');
270 | expect(mcpEnabledProfiles).toContain('windsurf');
271 | expect(mcpEnabledProfiles).toContain('zed');
272 | expect(mcpEnabledProfiles).toContain('roo');
273 | expect(mcpEnabledProfiles).not.toContain('cline');
274 | expect(mcpEnabledProfiles).not.toContain('codex');
275 | expect(mcpEnabledProfiles).not.toContain('trae');
276 | });
277 |
278 | test('should provide all necessary information for MCP config creation', () => {
279 | RULE_PROFILES.forEach((profileName) => {
280 | const profile = getRulesProfile(profileName);
281 | if (profile.mcpConfig !== false) {
282 | expect(profile.mcpConfigPath).toBeDefined();
283 | expect(typeof profile.mcpConfigPath).toBe('string');
284 | expect(profile.mcpConfigPath.length).toBeGreaterThan(0);
285 | }
286 | });
287 | });
288 | });
289 |
290 | describe('MCP Configuration Path Usage Verification', () => {
291 | test('should verify that rule transformer functions use mcpConfigPath correctly', () => {
292 | RULE_PROFILES.forEach((profileName) => {
293 | const profile = getRulesProfile(profileName);
294 | if (profile.mcpConfig !== false) {
295 | // Verify the path is properly formatted for path.join usage
296 | expect(profile.mcpConfigPath.startsWith('/')).toBe(false);
297 |
298 | // Root directory profiles have different patterns
299 | if (profile.profileDir === '.') {
300 | if (profileName === 'claude') {
301 | expect(profile.mcpConfigPath).toBe('.mcp.json');
302 | } else {
303 | // Other root profiles (opencode) normalize to just the filename
304 | expect(profile.mcpConfigPath).toBe(profile.mcpConfigName);
305 | }
306 | } else {
307 | // Non-root profiles should contain a directory separator
308 | expect(profile.mcpConfigPath).toContain('/');
309 | }
310 |
311 | // Verify it matches the expected pattern based on how path.join works
312 | let expectedPath;
313 | if (profile.profileDir === '.') {
314 | if (profileName === 'claude') {
315 | expectedPath = '.mcp.json'; // Claude explicitly uses '.mcp.json'
316 | } else {
317 | expectedPath = profile.mcpConfigName; // path.join('.', 'filename') normalizes to 'filename'
318 | }
319 | } else {
320 | expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`;
321 | }
322 | expect(profile.mcpConfigPath).toBe(expectedPath);
323 | }
324 | });
325 | });
326 |
327 | test('should verify that mcpConfigPath is properly constructed for path.join usage', () => {
328 | RULE_PROFILES.forEach((profileName) => {
329 | const profile = getRulesProfile(profileName);
330 | if (profile.mcpConfig !== false) {
331 | // Test that path.join works correctly with the mcpConfigPath
332 | const testProjectRoot = '/test/project';
333 | const fullPath = path.join(testProjectRoot, profile.mcpConfigPath);
334 |
335 | // Should result in a proper absolute path
336 | // Note: path.join normalizes paths, so './opencode.json' becomes 'opencode.json'
337 | const normalizedExpectedPath = path.join(
338 | testProjectRoot,
339 | profile.mcpConfigPath
340 | );
341 | expect(fullPath).toBe(normalizedExpectedPath);
342 | expect(fullPath).toContain(profile.mcpConfigName);
343 | }
344 | });
345 | });
346 | });
347 |
348 | describe('MCP Configuration Function Integration', () => {
349 | test('should verify that setupMCPConfiguration receives the correct mcpConfigPath parameter', () => {
350 | RULE_PROFILES.forEach((profileName) => {
351 | const profile = getRulesProfile(profileName);
352 | if (profile.mcpConfig !== false) {
353 | // Verify the path structure is correct for the new function signature
354 | if (profile.profileDir === '.') {
355 | // Root directory profiles have special handling
356 | if (profileName === 'claude') {
357 | expect(profile.mcpConfigPath).toBe('.mcp.json');
358 | } else {
359 | // Other root profiles normalize to just the filename
360 | expect(profile.mcpConfigPath).toBe(profile.mcpConfigName);
361 | }
362 | } else if (profileName === 'kiro') {
363 | // Kiro has a nested config structure
364 | const parts = profile.mcpConfigPath.split('/');
365 | expect(parts).toHaveLength(3); // Should be profileDir/settings/mcp.json
366 | expect(parts[0]).toBe(profile.profileDir);
367 | expect(parts[1]).toBe('settings');
368 | expect(parts[2]).toBe('mcp.json');
369 | } else {
370 | // Non-root profiles should have profileDir/configName structure
371 | const parts = profile.mcpConfigPath.split('/');
372 | expect(parts).toHaveLength(2); // Should be profileDir/configName
373 | expect(parts[0]).toBe(profile.profileDir);
374 | expect(parts[1]).toBe(profile.mcpConfigName);
375 | }
376 | }
377 | });
378 | });
379 | });
380 |
381 | describe('MCP configuration validation', () => {
382 | const mcpProfiles = [
383 | 'amp',
384 | 'claude',
385 | 'cursor',
386 | 'gemini',
387 | 'kiro',
388 | 'opencode',
389 | 'roo',
390 | 'windsurf',
391 | 'vscode',
392 | 'zed'
393 | ];
394 | const nonMcpProfiles = ['codex', 'cline', 'trae'];
395 | const profilesWithLifecycle = ['claude'];
396 | const profilesWithoutLifecycle = ['codex'];
397 |
398 | test.each(mcpProfiles)(
399 | 'should have valid MCP config for %s profile',
400 | (profileName) => {
401 | const profile = getRulesProfile(profileName);
402 | expect(profile).toBeDefined();
403 | expect(profile.mcpConfig).toBe(true);
404 | expect(profile.mcpConfigPath).toBeDefined();
405 | expect(typeof profile.mcpConfigPath).toBe('string');
406 | }
407 | );
408 |
409 | test.each(nonMcpProfiles)(
410 | 'should not require MCP config for %s profile',
411 | (profileName) => {
412 | const profile = getRulesProfile(profileName);
413 | expect(profile).toBeDefined();
414 | expect(profile.mcpConfig).toBe(false);
415 | }
416 | );
417 | });
418 |
419 | describe('Profile structure validation', () => {
420 | const allProfiles = [
421 | 'amp',
422 | 'claude',
423 | 'cline',
424 | 'codex',
425 | 'cursor',
426 | 'gemini',
427 | 'opencode',
428 | 'roo',
429 | 'trae',
430 | 'vscode',
431 | 'windsurf',
432 | 'zed'
433 | ];
434 | const profilesWithLifecycle = ['amp', 'claude'];
435 | const profilesWithPostConvertLifecycle = ['opencode'];
436 | const profilesWithoutLifecycle = ['codex'];
437 |
438 | test.each(allProfiles)(
439 | 'should have file mappings for %s profile',
440 | (profileName) => {
441 | const profile = getRulesProfile(profileName);
442 | expect(profile).toBeDefined();
443 | expect(profile.fileMap).toBeDefined();
444 | expect(typeof profile.fileMap).toBe('object');
445 | expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0);
446 | }
447 | );
448 |
449 | test.each(profilesWithLifecycle)(
450 | 'should have file mappings and lifecycle functions for %s profile',
451 | (profileName) => {
452 | const profile = getRulesProfile(profileName);
453 | expect(profile).toBeDefined();
454 | // Claude profile has both fileMap and lifecycle functions
455 | expect(profile.fileMap).toBeDefined();
456 | expect(typeof profile.fileMap).toBe('object');
457 | expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0);
458 | expect(typeof profile.onAddRulesProfile).toBe('function');
459 | expect(typeof profile.onRemoveRulesProfile).toBe('function');
460 | expect(typeof profile.onPostConvertRulesProfile).toBe('function');
461 | }
462 | );
463 |
464 | test.each(profilesWithPostConvertLifecycle)(
465 | 'should have file mappings and post-convert lifecycle functions for %s profile',
466 | (profileName) => {
467 | const profile = getRulesProfile(profileName);
468 | expect(profile).toBeDefined();
469 | // OpenCode profile has fileMap and post-convert lifecycle functions
470 | expect(profile.fileMap).toBeDefined();
471 | expect(typeof profile.fileMap).toBe('object');
472 | expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0);
473 | expect(profile.onAddRulesProfile).toBeUndefined(); // OpenCode doesn't have onAdd
474 | expect(typeof profile.onRemoveRulesProfile).toBe('function');
475 | expect(typeof profile.onPostConvertRulesProfile).toBe('function');
476 | }
477 | );
478 |
479 | test.each(profilesWithoutLifecycle)(
480 | 'should have file mappings without lifecycle functions for %s profile',
481 | (profileName) => {
482 | const profile = getRulesProfile(profileName);
483 | expect(profile).toBeDefined();
484 | // Codex profile has fileMap but no lifecycle functions (simplified)
485 | expect(profile.fileMap).toBeDefined();
486 | expect(typeof profile.fileMap).toBe('object');
487 | expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0);
488 | expect(profile.onAddRulesProfile).toBeUndefined();
489 | expect(profile.onRemoveRulesProfile).toBeUndefined();
490 | expect(profile.onPostConvertRulesProfile).toBeUndefined();
491 | }
492 | );
493 | });
494 | });
495 |
```
--------------------------------------------------------------------------------
/tests/unit/scripts/modules/task-manager/move-task-cross-tag.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { jest } from '@jest/globals';
2 |
3 | // --- Mocks ---
4 | jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
5 | readJSON: jest.fn(),
6 | writeJSON: jest.fn(),
7 | log: jest.fn(),
8 | setTasksForTag: jest.fn(),
9 | truncate: jest.fn((t) => t),
10 | isSilentMode: jest.fn(() => false),
11 | traverseDependencies: jest.fn((sourceTasks, allTasks, options = {}) => {
12 | // Mock realistic dependency behavior for testing
13 | const { direction = 'forward' } = options;
14 |
15 | if (direction === 'forward') {
16 | // For forward dependencies: return tasks that the source tasks depend on
17 | const result = [];
18 | sourceTasks.forEach((task) => {
19 | if (task.dependencies && Array.isArray(task.dependencies)) {
20 | result.push(...task.dependencies);
21 | }
22 | });
23 | return result;
24 | } else if (direction === 'reverse') {
25 | // For reverse dependencies: return tasks that depend on the source tasks
26 | const sourceIds = sourceTasks.map((t) => t.id);
27 | const normalizedSourceIds = sourceIds.map((id) => String(id));
28 | const result = [];
29 | allTasks.forEach((task) => {
30 | if (task.dependencies && Array.isArray(task.dependencies)) {
31 | const hasDependency = task.dependencies.some((depId) =>
32 | normalizedSourceIds.includes(String(depId))
33 | );
34 | if (hasDependency) {
35 | result.push(task.id);
36 | }
37 | }
38 | });
39 | return result;
40 | }
41 | return [];
42 | })
43 | }));
44 |
45 | jest.unstable_mockModule(
46 | '../../../../../scripts/modules/task-manager/generate-task-files.js',
47 | () => ({
48 | default: jest.fn().mockResolvedValue()
49 | })
50 | );
51 |
52 | jest.unstable_mockModule(
53 | '../../../../../scripts/modules/task-manager.js',
54 | () => ({
55 | isTaskDependentOn: jest.fn(() => false)
56 | })
57 | );
58 |
59 | jest.unstable_mockModule(
60 | '../../../../../scripts/modules/dependency-manager.js',
61 | () => ({
62 | validateCrossTagMove: jest.fn(),
63 | findCrossTagDependencies: jest.fn(),
64 | getDependentTaskIds: jest.fn(),
65 | validateSubtaskMove: jest.fn()
66 | })
67 | );
68 |
69 | const { readJSON, writeJSON, log } = await import(
70 | '../../../../../scripts/modules/utils.js'
71 | );
72 |
73 | const {
74 | validateCrossTagMove,
75 | findCrossTagDependencies,
76 | getDependentTaskIds,
77 | validateSubtaskMove
78 | } = await import('../../../../../scripts/modules/dependency-manager.js');
79 |
80 | const { moveTasksBetweenTags, getAllTasksWithTags } = await import(
81 | '../../../../../scripts/modules/task-manager/move-task.js'
82 | );
83 |
84 | describe('Cross-Tag Task Movement', () => {
85 | let mockRawData;
86 | let mockTasksPath;
87 | let mockContext;
88 |
89 | beforeEach(() => {
90 | jest.clearAllMocks();
91 |
92 | // Setup mock data
93 | mockRawData = {
94 | backlog: {
95 | tasks: [
96 | { id: 1, title: 'Task 1', dependencies: [2] },
97 | { id: 2, title: 'Task 2', dependencies: [] },
98 | { id: 3, title: 'Task 3', dependencies: [1] }
99 | ]
100 | },
101 | 'in-progress': {
102 | tasks: [{ id: 4, title: 'Task 4', dependencies: [] }]
103 | },
104 | done: {
105 | tasks: [{ id: 5, title: 'Task 5', dependencies: [4] }]
106 | }
107 | };
108 |
109 | mockTasksPath = '/test/path/tasks.json';
110 | mockContext = { projectRoot: '/test/project' };
111 |
112 | // Mock readJSON to return our test data
113 | readJSON.mockImplementation((path, projectRoot, tag) => {
114 | return { ...mockRawData[tag], tag, _rawTaggedData: mockRawData };
115 | });
116 |
117 | writeJSON.mockResolvedValue();
118 | log.mockImplementation(() => {});
119 | });
120 |
121 | afterEach(() => {
122 | jest.clearAllMocks();
123 | });
124 |
125 | describe('getAllTasksWithTags', () => {
126 | it('should return all tasks with tag information', () => {
127 | const allTasks = getAllTasksWithTags(mockRawData);
128 |
129 | expect(allTasks).toHaveLength(5);
130 | expect(allTasks.find((t) => t.id === 1).tag).toBe('backlog');
131 | expect(allTasks.find((t) => t.id === 4).tag).toBe('in-progress');
132 | expect(allTasks.find((t) => t.id === 5).tag).toBe('done');
133 | });
134 | });
135 |
136 | describe('validateCrossTagMove', () => {
137 | it('should allow move when no dependencies exist', () => {
138 | const task = { id: 2, dependencies: [] };
139 | const allTasks = getAllTasksWithTags(mockRawData);
140 |
141 | validateCrossTagMove.mockReturnValue({ canMove: true, conflicts: [] });
142 | const result = validateCrossTagMove(
143 | task,
144 | 'backlog',
145 | 'in-progress',
146 | allTasks
147 | );
148 |
149 | expect(result.canMove).toBe(true);
150 | expect(result.conflicts).toHaveLength(0);
151 | });
152 |
153 | it('should block move when cross-tag dependencies exist', () => {
154 | const task = { id: 1, dependencies: [2] };
155 | const allTasks = getAllTasksWithTags(mockRawData);
156 |
157 | validateCrossTagMove.mockReturnValue({
158 | canMove: false,
159 | conflicts: [{ taskId: 1, dependencyId: 2, dependencyTag: 'backlog' }]
160 | });
161 | const result = validateCrossTagMove(
162 | task,
163 | 'backlog',
164 | 'in-progress',
165 | allTasks
166 | );
167 |
168 | expect(result.canMove).toBe(false);
169 | expect(result.conflicts).toHaveLength(1);
170 | expect(result.conflicts[0].dependencyId).toBe(2);
171 | });
172 | });
173 |
174 | describe('findCrossTagDependencies', () => {
175 | it('should find cross-tag dependencies for multiple tasks', () => {
176 | const sourceTasks = [
177 | { id: 1, dependencies: [2] },
178 | { id: 3, dependencies: [1] }
179 | ];
180 | const allTasks = getAllTasksWithTags(mockRawData);
181 |
182 | findCrossTagDependencies.mockReturnValue([
183 | { taskId: 1, dependencyId: 2, dependencyTag: 'backlog' },
184 | { taskId: 3, dependencyId: 1, dependencyTag: 'backlog' }
185 | ]);
186 | const conflicts = findCrossTagDependencies(
187 | sourceTasks,
188 | 'backlog',
189 | 'in-progress',
190 | allTasks
191 | );
192 |
193 | expect(conflicts).toHaveLength(2);
194 | expect(
195 | conflicts.some((c) => c.taskId === 1 && c.dependencyId === 2)
196 | ).toBe(true);
197 | expect(
198 | conflicts.some((c) => c.taskId === 3 && c.dependencyId === 1)
199 | ).toBe(true);
200 | });
201 | });
202 |
203 | describe('getDependentTaskIds', () => {
204 | it('should return dependent task IDs', () => {
205 | const sourceTasks = [{ id: 1, dependencies: [2] }];
206 | const crossTagDependencies = [
207 | { taskId: 1, dependencyId: 2, dependencyTag: 'backlog' }
208 | ];
209 | const allTasks = getAllTasksWithTags(mockRawData);
210 |
211 | getDependentTaskIds.mockReturnValue([2]);
212 | const dependentTaskIds = getDependentTaskIds(
213 | sourceTasks,
214 | crossTagDependencies,
215 | allTasks
216 | );
217 |
218 | expect(dependentTaskIds).toContain(2);
219 | });
220 | });
221 |
222 | // New test: ensure with-dependencies only traverses tasks from the source tag
223 | it('should scope dependency traversal to source tag when using --with-dependencies', async () => {
224 | findCrossTagDependencies.mockReturnValue([]);
225 | validateSubtaskMove.mockImplementation(() => {});
226 |
227 | const result = await moveTasksBetweenTags(
228 | mockTasksPath,
229 | [1], // backlog:1 depends on backlog:2
230 | 'backlog',
231 | 'in-progress',
232 | { withDependencies: true },
233 | mockContext
234 | );
235 |
236 | // Write should include backlog:2 moved, and must NOT traverse or fetch dependencies from the target tag
237 | expect(writeJSON).toHaveBeenCalledWith(
238 | mockTasksPath,
239 | expect.objectContaining({
240 | 'in-progress': expect.objectContaining({
241 | tasks: expect.arrayContaining([
242 | expect.objectContaining({ id: 1 }),
243 | expect.objectContaining({ id: 2 }) // the backlog:2 now moved
244 | // ensure existing in-progress:2 remains (by id) but we don't double-add or fetch deps from it
245 | ])
246 | })
247 | }),
248 | mockContext.projectRoot,
249 | null
250 | );
251 | });
252 |
253 | describe('moveTasksBetweenTags', () => {
254 | it('should move tasks without dependencies successfully', async () => {
255 | // Mock the dependency functions to return no conflicts
256 | findCrossTagDependencies.mockReturnValue([]);
257 | validateSubtaskMove.mockImplementation(() => {});
258 |
259 | const result = await moveTasksBetweenTags(
260 | mockTasksPath,
261 | [2],
262 | 'backlog',
263 | 'in-progress',
264 | {},
265 | mockContext
266 | );
267 |
268 | expect(result.message).toContain('Successfully moved 1 tasks');
269 | expect(writeJSON).toHaveBeenCalledWith(
270 | mockTasksPath,
271 | expect.any(Object),
272 | mockContext.projectRoot,
273 | null
274 | );
275 | });
276 |
277 | it('should throw error for cross-tag dependencies by default', async () => {
278 | const mockDependency = {
279 | taskId: 1,
280 | dependencyId: 2,
281 | dependencyTag: 'backlog'
282 | };
283 | findCrossTagDependencies.mockReturnValue([mockDependency]);
284 | validateSubtaskMove.mockImplementation(() => {});
285 |
286 | await expect(
287 | moveTasksBetweenTags(
288 | mockTasksPath,
289 | [1],
290 | 'backlog',
291 | 'in-progress',
292 | {},
293 | mockContext
294 | )
295 | ).rejects.toThrow(
296 | 'Cannot move tasks: 1 cross-tag dependency conflicts found'
297 | );
298 |
299 | expect(writeJSON).not.toHaveBeenCalled();
300 | });
301 |
302 | it('should move with dependencies when --with-dependencies is used', async () => {
303 | const mockDependency = {
304 | taskId: 1,
305 | dependencyId: 2,
306 | dependencyTag: 'backlog'
307 | };
308 | findCrossTagDependencies.mockReturnValue([mockDependency]);
309 | getDependentTaskIds.mockReturnValue([2]);
310 | validateSubtaskMove.mockImplementation(() => {});
311 |
312 | const result = await moveTasksBetweenTags(
313 | mockTasksPath,
314 | [1],
315 | 'backlog',
316 | 'in-progress',
317 | { withDependencies: true },
318 | mockContext
319 | );
320 |
321 | expect(result.message).toContain('Successfully moved 2 tasks');
322 | expect(writeJSON).toHaveBeenCalledWith(
323 | mockTasksPath,
324 | expect.objectContaining({
325 | backlog: expect.objectContaining({
326 | tasks: expect.arrayContaining([
327 | expect.objectContaining({
328 | id: 3,
329 | title: 'Task 3',
330 | dependencies: [1]
331 | })
332 | ])
333 | }),
334 | 'in-progress': expect.objectContaining({
335 | tasks: expect.arrayContaining([
336 | expect.objectContaining({
337 | id: 4,
338 | title: 'Task 4',
339 | dependencies: []
340 | }),
341 | expect.objectContaining({
342 | id: 1,
343 | title: 'Task 1',
344 | dependencies: [2],
345 | metadata: expect.objectContaining({
346 | moveHistory: expect.arrayContaining([
347 | expect.objectContaining({
348 | fromTag: 'backlog',
349 | toTag: 'in-progress',
350 | timestamp: expect.any(String)
351 | })
352 | ])
353 | })
354 | }),
355 | expect.objectContaining({
356 | id: 2,
357 | title: 'Task 2',
358 | dependencies: [],
359 | metadata: expect.objectContaining({
360 | moveHistory: expect.arrayContaining([
361 | expect.objectContaining({
362 | fromTag: 'backlog',
363 | toTag: 'in-progress',
364 | timestamp: expect.any(String)
365 | })
366 | ])
367 | })
368 | })
369 | ])
370 | }),
371 | done: expect.objectContaining({
372 | tasks: expect.arrayContaining([
373 | expect.objectContaining({
374 | id: 5,
375 | title: 'Task 5',
376 | dependencies: [4]
377 | })
378 | ])
379 | })
380 | }),
381 | mockContext.projectRoot,
382 | null
383 | );
384 | });
385 |
386 | it('should break dependencies when --ignore-dependencies is used', async () => {
387 | const mockDependency = {
388 | taskId: 1,
389 | dependencyId: 2,
390 | dependencyTag: 'backlog'
391 | };
392 | findCrossTagDependencies.mockReturnValue([mockDependency]);
393 | validateSubtaskMove.mockImplementation(() => {});
394 |
395 | const result = await moveTasksBetweenTags(
396 | mockTasksPath,
397 | [2],
398 | 'backlog',
399 | 'in-progress',
400 | { ignoreDependencies: true },
401 | mockContext
402 | );
403 |
404 | expect(result.message).toContain('Successfully moved 1 tasks');
405 | expect(writeJSON).toHaveBeenCalledWith(
406 | mockTasksPath,
407 | expect.objectContaining({
408 | backlog: expect.objectContaining({
409 | tasks: expect.arrayContaining([
410 | expect.objectContaining({
411 | id: 1,
412 | title: 'Task 1',
413 | dependencies: [2] // Dependencies not actually removed in current implementation
414 | }),
415 | expect.objectContaining({
416 | id: 3,
417 | title: 'Task 3',
418 | dependencies: [1]
419 | })
420 | ])
421 | }),
422 | 'in-progress': expect.objectContaining({
423 | tasks: expect.arrayContaining([
424 | expect.objectContaining({
425 | id: 4,
426 | title: 'Task 4',
427 | dependencies: []
428 | }),
429 | expect.objectContaining({
430 | id: 2,
431 | title: 'Task 2',
432 | dependencies: [],
433 | metadata: expect.objectContaining({
434 | moveHistory: expect.arrayContaining([
435 | expect.objectContaining({
436 | fromTag: 'backlog',
437 | toTag: 'in-progress',
438 | timestamp: expect.any(String)
439 | })
440 | ])
441 | })
442 | })
443 | ])
444 | }),
445 | done: expect.objectContaining({
446 | tasks: expect.arrayContaining([
447 | expect.objectContaining({
448 | id: 5,
449 | title: 'Task 5',
450 | dependencies: [4]
451 | })
452 | ])
453 | })
454 | }),
455 | mockContext.projectRoot,
456 | null
457 | );
458 | });
459 |
460 | it('should create target tag if it does not exist', async () => {
461 | findCrossTagDependencies.mockReturnValue([]);
462 | validateSubtaskMove.mockImplementation(() => {});
463 |
464 | const result = await moveTasksBetweenTags(
465 | mockTasksPath,
466 | [2],
467 | 'backlog',
468 | 'new-tag',
469 | {},
470 | mockContext
471 | );
472 |
473 | expect(result.message).toContain('Successfully moved 1 tasks');
474 | expect(result.message).toContain('new-tag');
475 | expect(writeJSON).toHaveBeenCalledWith(
476 | mockTasksPath,
477 | expect.objectContaining({
478 | backlog: expect.objectContaining({
479 | tasks: expect.arrayContaining([
480 | expect.objectContaining({
481 | id: 1,
482 | title: 'Task 1',
483 | dependencies: [2]
484 | }),
485 | expect.objectContaining({
486 | id: 3,
487 | title: 'Task 3',
488 | dependencies: [1]
489 | })
490 | ])
491 | }),
492 | 'new-tag': expect.objectContaining({
493 | tasks: expect.arrayContaining([
494 | expect.objectContaining({
495 | id: 2,
496 | title: 'Task 2',
497 | dependencies: [],
498 | metadata: expect.objectContaining({
499 | moveHistory: expect.arrayContaining([
500 | expect.objectContaining({
501 | fromTag: 'backlog',
502 | toTag: 'new-tag',
503 | timestamp: expect.any(String)
504 | })
505 | ])
506 | })
507 | })
508 | ])
509 | }),
510 | 'in-progress': expect.objectContaining({
511 | tasks: expect.arrayContaining([
512 | expect.objectContaining({
513 | id: 4,
514 | title: 'Task 4',
515 | dependencies: []
516 | })
517 | ])
518 | }),
519 | done: expect.objectContaining({
520 | tasks: expect.arrayContaining([
521 | expect.objectContaining({
522 | id: 5,
523 | title: 'Task 5',
524 | dependencies: [4]
525 | })
526 | ])
527 | })
528 | }),
529 | mockContext.projectRoot,
530 | null
531 | );
532 | });
533 |
534 | it('should throw error for subtask movement', async () => {
535 | const subtaskError = 'Cannot move subtask 1.2 directly between tags';
536 | validateSubtaskMove.mockImplementation(() => {
537 | throw new Error(subtaskError);
538 | });
539 |
540 | await expect(
541 | moveTasksBetweenTags(
542 | mockTasksPath,
543 | ['1.2'],
544 | 'backlog',
545 | 'in-progress',
546 | {},
547 | mockContext
548 | )
549 | ).rejects.toThrow(subtaskError);
550 |
551 | expect(writeJSON).not.toHaveBeenCalled();
552 | });
553 |
554 | it('should throw error for invalid task IDs', async () => {
555 | findCrossTagDependencies.mockReturnValue([]);
556 | validateSubtaskMove.mockImplementation(() => {});
557 |
558 | await expect(
559 | moveTasksBetweenTags(
560 | mockTasksPath,
561 | [999], // Non-existent task
562 | 'backlog',
563 | 'in-progress',
564 | {},
565 | mockContext
566 | )
567 | ).rejects.toThrow('Task 999 not found in source tag "backlog"');
568 |
569 | expect(writeJSON).not.toHaveBeenCalled();
570 | });
571 |
572 | it('should throw error for invalid source tag', async () => {
573 | findCrossTagDependencies.mockReturnValue([]);
574 | validateSubtaskMove.mockImplementation(() => {});
575 |
576 | await expect(
577 | moveTasksBetweenTags(
578 | mockTasksPath,
579 | [1],
580 | 'non-existent-tag',
581 | 'in-progress',
582 | {},
583 | mockContext
584 | )
585 | ).rejects.toThrow('Source tag "non-existent-tag" not found or invalid');
586 |
587 | expect(writeJSON).not.toHaveBeenCalled();
588 | });
589 |
590 | it('should handle string dependencies correctly during cross-tag move', async () => {
591 | // Setup mock data with string dependencies
592 | mockRawData = {
593 | backlog: {
594 | tasks: [
595 | { id: 1, title: 'Task 1', dependencies: ['2'] }, // String dependency
596 | { id: 2, title: 'Task 2', dependencies: [] },
597 | { id: 3, title: 'Task 3', dependencies: ['1'] } // String dependency
598 | ]
599 | },
600 | 'in-progress': {
601 | tasks: [{ id: 4, title: 'Task 4', dependencies: [] }]
602 | }
603 | };
604 |
605 | // Mock readJSON to return our test data
606 | readJSON.mockImplementation((path, projectRoot, tag) => {
607 | return { ...mockRawData[tag], tag, _rawTaggedData: mockRawData };
608 | });
609 |
610 | findCrossTagDependencies.mockReturnValue([]);
611 | validateSubtaskMove.mockImplementation(() => {});
612 |
613 | const result = await moveTasksBetweenTags(
614 | mockTasksPath,
615 | ['1'], // String task ID
616 | 'backlog',
617 | 'in-progress',
618 | {},
619 | mockContext
620 | );
621 |
622 | expect(result.message).toContain('Successfully moved 1 tasks');
623 | expect(writeJSON).toHaveBeenCalledWith(
624 | mockTasksPath,
625 | expect.objectContaining({
626 | backlog: expect.objectContaining({
627 | tasks: expect.arrayContaining([
628 | expect.objectContaining({
629 | id: 2,
630 | title: 'Task 2',
631 | dependencies: []
632 | }),
633 | expect.objectContaining({
634 | id: 3,
635 | title: 'Task 3',
636 | dependencies: ['1'] // Should remain as string
637 | })
638 | ])
639 | }),
640 | 'in-progress': expect.objectContaining({
641 | tasks: expect.arrayContaining([
642 | expect.objectContaining({
643 | id: 1,
644 | title: 'Task 1',
645 | dependencies: ['2'], // Should remain as string
646 | metadata: expect.objectContaining({
647 | moveHistory: expect.arrayContaining([
648 | expect.objectContaining({
649 | fromTag: 'backlog',
650 | toTag: 'in-progress',
651 | timestamp: expect.any(String)
652 | })
653 | ])
654 | })
655 | })
656 | ])
657 | })
658 | }),
659 | mockContext.projectRoot,
660 | null
661 | );
662 | });
663 | });
664 | });
665 |
```
--------------------------------------------------------------------------------
/tests/integration/move-task-simple.integration.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { jest } from '@jest/globals';
2 | import path from 'path';
3 | import mockFs from 'mock-fs';
4 | import fs from 'fs';
5 | import { fileURLToPath } from 'url';
6 |
7 | // Import the actual move task functionality
8 | import moveTask, {
9 | moveTasksBetweenTags
10 | } from '../../scripts/modules/task-manager/move-task.js';
11 | import { readJSON, writeJSON } from '../../scripts/modules/utils.js';
12 |
13 | // Mock console to avoid conflicts with mock-fs
14 | const originalConsole = { ...console };
15 | beforeAll(() => {
16 | global.console = {
17 | ...console,
18 | log: jest.fn(),
19 | error: jest.fn(),
20 | warn: jest.fn(),
21 | info: jest.fn()
22 | };
23 | });
24 |
25 | afterAll(() => {
26 | global.console = originalConsole;
27 | });
28 |
29 | // Get __dirname equivalent for ES modules
30 | const __filename = fileURLToPath(import.meta.url);
31 | const __dirname = path.dirname(__filename);
32 |
33 | describe('Cross-Tag Task Movement Simple Integration Tests', () => {
34 | const testDataDir = path.join(__dirname, 'fixtures');
35 | const testTasksPath = path.join(testDataDir, 'tasks.json');
36 |
37 | // Test data structure with proper tagged format
38 | const testData = {
39 | backlog: {
40 | tasks: [
41 | { id: 1, title: 'Task 1', dependencies: [], status: 'pending' },
42 | { id: 2, title: 'Task 2', dependencies: [], status: 'pending' }
43 | ]
44 | },
45 | 'in-progress': {
46 | tasks: [
47 | { id: 3, title: 'Task 3', dependencies: [], status: 'in-progress' }
48 | ]
49 | }
50 | };
51 |
52 | beforeEach(() => {
53 | // Set up mock file system with test data
54 | mockFs({
55 | [testDataDir]: {
56 | 'tasks.json': JSON.stringify(testData, null, 2)
57 | }
58 | });
59 | });
60 |
61 | afterEach(() => {
62 | // Clean up mock file system
63 | mockFs.restore();
64 | });
65 |
66 | describe('Real Module Integration Tests', () => {
67 | it('should move task within same tag using actual moveTask function', async () => {
68 | // Test moving Task 1 from position 1 to position 5 within backlog tag
69 | const result = await moveTask(
70 | testTasksPath,
71 | '1',
72 | '5',
73 | false, // Don't generate files for this test
74 | { tag: 'backlog' }
75 | );
76 |
77 | // Verify the move operation was successful
78 | expect(result).toBeDefined();
79 | expect(result.message).toContain('Moved task 1 to new ID 5');
80 |
81 | // Read the updated data to verify the move actually happened
82 | const updatedData = readJSON(testTasksPath, null, 'backlog');
83 | const rawData = updatedData._rawTaggedData || updatedData;
84 | const backlogTasks = rawData.backlog.tasks;
85 |
86 | // Verify Task 1 is no longer at position 1
87 | const taskAtPosition1 = backlogTasks.find((t) => t.id === 1);
88 | expect(taskAtPosition1).toBeUndefined();
89 |
90 | // Verify Task 1 is now at position 5
91 | const taskAtPosition5 = backlogTasks.find((t) => t.id === 5);
92 | expect(taskAtPosition5).toBeDefined();
93 | expect(taskAtPosition5.title).toBe('Task 1');
94 | expect(taskAtPosition5.status).toBe('pending');
95 | });
96 |
97 | it('should move tasks between tags using moveTasksBetweenTags function', async () => {
98 | // Test moving Task 1 from backlog to in-progress tag
99 | const result = await moveTasksBetweenTags(
100 | testTasksPath,
101 | ['1'], // Task IDs to move (as strings)
102 | 'backlog', // Source tag
103 | 'in-progress', // Target tag
104 | { withDependencies: false, ignoreDependencies: false },
105 | { projectRoot: testDataDir }
106 | );
107 |
108 | // Verify the cross-tag move operation was successful
109 | expect(result).toBeDefined();
110 | expect(result.message).toContain(
111 | 'Successfully moved 1 tasks from "backlog" to "in-progress"'
112 | );
113 | expect(result.movedTasks).toHaveLength(1);
114 | expect(result.movedTasks[0].id).toBe('1');
115 | expect(result.movedTasks[0].fromTag).toBe('backlog');
116 | expect(result.movedTasks[0].toTag).toBe('in-progress');
117 |
118 | // Read the updated data to verify the move actually happened
119 | const updatedData = readJSON(testTasksPath, null, 'backlog');
120 | // readJSON returns resolved data, so we need to access the raw tagged data
121 | const rawData = updatedData._rawTaggedData || updatedData;
122 | const backlogTasks = rawData.backlog?.tasks || [];
123 | const inProgressTasks = rawData['in-progress']?.tasks || [];
124 |
125 | // Verify Task 1 is no longer in backlog
126 | const taskInBacklog = backlogTasks.find((t) => t.id === 1);
127 | expect(taskInBacklog).toBeUndefined();
128 |
129 | // Verify Task 1 is now in in-progress
130 | const taskInProgress = inProgressTasks.find((t) => t.id === 1);
131 | expect(taskInProgress).toBeDefined();
132 | expect(taskInProgress.title).toBe('Task 1');
133 | expect(taskInProgress.status).toBe('pending');
134 | });
135 |
136 | it('should handle subtask movement restrictions', async () => {
137 | // Create data with subtasks
138 | const dataWithSubtasks = {
139 | backlog: {
140 | tasks: [
141 | {
142 | id: 1,
143 | title: 'Task 1',
144 | dependencies: [],
145 | status: 'pending',
146 | subtasks: [
147 | { id: '1.1', title: 'Subtask 1.1', status: 'pending' },
148 | { id: '1.2', title: 'Subtask 1.2', status: 'pending' }
149 | ]
150 | }
151 | ]
152 | },
153 | 'in-progress': {
154 | tasks: [
155 | { id: 2, title: 'Task 2', dependencies: [], status: 'in-progress' }
156 | ]
157 | }
158 | };
159 |
160 | // Write subtask data to mock file system
161 | mockFs({
162 | [testDataDir]: {
163 | 'tasks.json': JSON.stringify(dataWithSubtasks, null, 2)
164 | }
165 | });
166 |
167 | // Try to move a subtask directly - this should actually work (converts subtask to task)
168 | const result = await moveTask(
169 | testTasksPath,
170 | '1.1', // Subtask ID
171 | '5', // New task ID
172 | false,
173 | { tag: 'backlog' }
174 | );
175 |
176 | // Verify the subtask was converted to a task
177 | expect(result).toBeDefined();
178 | expect(result.message).toContain('Converted subtask 1.1 to task 5');
179 |
180 | // Verify the subtask was removed from the parent and converted to a standalone task
181 | const updatedData = readJSON(testTasksPath, null, 'backlog');
182 | const rawData = updatedData._rawTaggedData || updatedData;
183 | const task1 = rawData.backlog?.tasks?.find((t) => t.id === 1);
184 | const newTask5 = rawData.backlog?.tasks?.find((t) => t.id === 5);
185 |
186 | expect(task1).toBeDefined();
187 | expect(task1.subtasks).toHaveLength(1); // Only 1.2 remains
188 | expect(task1.subtasks[0].id).toBe(2);
189 |
190 | expect(newTask5).toBeDefined();
191 | expect(newTask5.title).toBe('Subtask 1.1');
192 | expect(newTask5.status).toBe('pending');
193 | });
194 |
195 | it('should handle missing source tag errors', async () => {
196 | // Try to move from a non-existent tag
197 | await expect(
198 | moveTasksBetweenTags(
199 | testTasksPath,
200 | ['1'],
201 | 'non-existent-tag', // Source tag doesn't exist
202 | 'in-progress',
203 | { withDependencies: false, ignoreDependencies: false },
204 | { projectRoot: testDataDir }
205 | )
206 | ).rejects.toThrow();
207 | });
208 |
209 | it('should handle missing task ID errors', async () => {
210 | // Try to move a non-existent task
211 | await expect(
212 | moveTask(
213 | testTasksPath,
214 | '999', // Non-existent task ID
215 | '5',
216 | false,
217 | { tag: 'backlog' }
218 | )
219 | ).rejects.toThrow();
220 | });
221 |
222 | it('should handle ignoreDependencies option correctly', async () => {
223 | // Create data with dependencies
224 | const dataWithDependencies = {
225 | backlog: {
226 | tasks: [
227 | { id: 1, title: 'Task 1', dependencies: [2], status: 'pending' },
228 | { id: 2, title: 'Task 2', dependencies: [], status: 'pending' }
229 | ]
230 | },
231 | 'in-progress': {
232 | tasks: [
233 | { id: 3, title: 'Task 3', dependencies: [], status: 'in-progress' }
234 | ]
235 | }
236 | };
237 |
238 | // Write dependency data to mock file system
239 | mockFs({
240 | [testDataDir]: {
241 | 'tasks.json': JSON.stringify(dataWithDependencies, null, 2)
242 | }
243 | });
244 |
245 | // Move Task 1 while ignoring its dependencies
246 | const result = await moveTasksBetweenTags(
247 | testTasksPath,
248 | ['1'], // Only Task 1
249 | 'backlog',
250 | 'in-progress',
251 | { withDependencies: false, ignoreDependencies: true },
252 | { projectRoot: testDataDir }
253 | );
254 |
255 | expect(result).toBeDefined();
256 | expect(result.movedTasks).toHaveLength(1);
257 |
258 | // Verify Task 1 moved but Task 2 stayed
259 | const updatedData = readJSON(testTasksPath, null, 'backlog');
260 | const rawData = updatedData._rawTaggedData || updatedData;
261 | expect(rawData.backlog.tasks).toHaveLength(1); // Task 2 remains
262 | expect(rawData['in-progress'].tasks).toHaveLength(2); // Task 3 + Task 1
263 |
264 | // Verify Task 1 has no dependencies (they were ignored)
265 | const movedTask = rawData['in-progress'].tasks.find((t) => t.id === 1);
266 | expect(movedTask.dependencies).toEqual([]);
267 | });
268 | });
269 |
270 | describe('Complex Dependency Scenarios', () => {
271 | beforeAll(() => {
272 | // Document the mock-fs limitation for complex dependency scenarios
273 | console.warn(
274 | '⚠️ Complex dependency tests are skipped due to mock-fs limitations. ' +
275 | 'These tests require real filesystem operations for proper dependency resolution. ' +
276 | 'Consider using real temporary filesystem setup for these scenarios.'
277 | );
278 | });
279 |
280 | it.skip('should handle dependency conflicts during cross-tag moves', async () => {
281 | // For now, skip this test as the mock setup is not working correctly
282 | // TODO: Fix mock-fs setup for complex dependency scenarios
283 | });
284 |
285 | it.skip('should handle withDependencies option correctly', async () => {
286 | // For now, skip this test as the mock setup is not working correctly
287 | // TODO: Fix mock-fs setup for complex dependency scenarios
288 | });
289 | });
290 |
291 | describe('Complex Dependency Integration Tests with Mock-fs', () => {
292 | const complexTestData = {
293 | backlog: {
294 | tasks: [
295 | { id: 1, title: 'Task 1', dependencies: [2, 3], status: 'pending' },
296 | { id: 2, title: 'Task 2', dependencies: [4], status: 'pending' },
297 | { id: 3, title: 'Task 3', dependencies: [], status: 'pending' },
298 | { id: 4, title: 'Task 4', dependencies: [], status: 'pending' }
299 | ]
300 | },
301 | 'in-progress': {
302 | tasks: [
303 | { id: 5, title: 'Task 5', dependencies: [], status: 'in-progress' }
304 | ]
305 | }
306 | };
307 |
308 | beforeEach(() => {
309 | // Set up mock file system with complex dependency data
310 | mockFs({
311 | [testDataDir]: {
312 | 'tasks.json': JSON.stringify(complexTestData, null, 2)
313 | }
314 | });
315 | });
316 |
317 | afterEach(() => {
318 | // Clean up mock file system
319 | mockFs.restore();
320 | });
321 |
322 | it('should handle dependency conflicts during cross-tag moves using actual move functions', async () => {
323 | // Test moving Task 1 which has dependencies on Tasks 2 and 3
324 | // This should fail because Task 1 depends on Tasks 2 and 3 which are in the same tag
325 | await expect(
326 | moveTasksBetweenTags(
327 | testTasksPath,
328 | ['1'], // Task 1 with dependencies
329 | 'backlog',
330 | 'in-progress',
331 | { withDependencies: false, ignoreDependencies: false },
332 | { projectRoot: testDataDir }
333 | )
334 | ).rejects.toThrow(
335 | 'Cannot move tasks: 2 cross-tag dependency conflicts found'
336 | );
337 | });
338 |
339 | it('should handle withDependencies option correctly using actual move functions', async () => {
340 | // Test moving Task 1 with its dependencies (Tasks 2 and 3)
341 | // Task 2 also depends on Task 4, so all 4 tasks should move
342 | const result = await moveTasksBetweenTags(
343 | testTasksPath,
344 | ['1'], // Task 1
345 | 'backlog',
346 | 'in-progress',
347 | { withDependencies: true, ignoreDependencies: false },
348 | { projectRoot: testDataDir }
349 | );
350 |
351 | // Verify the move operation was successful
352 | expect(result).toBeDefined();
353 | expect(result.message).toContain(
354 | 'Successfully moved 4 tasks from "backlog" to "in-progress"'
355 | );
356 | expect(result.movedTasks).toHaveLength(4); // Task 1 + Tasks 2, 3, 4
357 |
358 | // Read the updated data to verify all dependent tasks moved
359 | const updatedData = readJSON(testTasksPath, null, 'backlog');
360 | const rawData = updatedData._rawTaggedData || updatedData;
361 |
362 | // Verify all tasks moved from backlog
363 | expect(rawData.backlog?.tasks || []).toHaveLength(0); // All tasks moved
364 |
365 | // Verify all tasks are now in in-progress
366 | expect(rawData['in-progress']?.tasks || []).toHaveLength(5); // Task 5 + Tasks 1, 2, 3, 4
367 |
368 | // Verify dependency relationships are preserved
369 | const task1 = rawData['in-progress']?.tasks?.find((t) => t.id === 1);
370 | const task2 = rawData['in-progress']?.tasks?.find((t) => t.id === 2);
371 | const task3 = rawData['in-progress']?.tasks?.find((t) => t.id === 3);
372 | const task4 = rawData['in-progress']?.tasks?.find((t) => t.id === 4);
373 |
374 | expect(task1?.dependencies).toEqual([2, 3]);
375 | expect(task2?.dependencies).toEqual([4]);
376 | expect(task3?.dependencies).toEqual([]);
377 | expect(task4?.dependencies).toEqual([]);
378 | });
379 |
380 | it('should handle circular dependency detection using actual move functions', async () => {
381 | // Create data with circular dependencies
382 | const circularData = {
383 | backlog: {
384 | tasks: [
385 | { id: 1, title: 'Task 1', dependencies: [2], status: 'pending' },
386 | { id: 2, title: 'Task 2', dependencies: [3], status: 'pending' },
387 | { id: 3, title: 'Task 3', dependencies: [1], status: 'pending' } // Circular dependency
388 | ]
389 | },
390 | 'in-progress': {
391 | tasks: [
392 | { id: 4, title: 'Task 4', dependencies: [], status: 'in-progress' }
393 | ]
394 | }
395 | };
396 |
397 | // Set up mock file system with circular dependency data
398 | mockFs({
399 | [testDataDir]: {
400 | 'tasks.json': JSON.stringify(circularData, null, 2)
401 | }
402 | });
403 |
404 | // Attempt to move Task 1 with dependencies should fail due to circular dependency
405 | await expect(
406 | moveTasksBetweenTags(
407 | testTasksPath,
408 | ['1'],
409 | 'backlog',
410 | 'in-progress',
411 | { withDependencies: true, ignoreDependencies: false },
412 | { projectRoot: testDataDir }
413 | )
414 | ).rejects.toThrow();
415 | });
416 |
417 | it('should handle nested dependency chains using actual move functions', async () => {
418 | // Create data with nested dependency chains
419 | const nestedData = {
420 | backlog: {
421 | tasks: [
422 | { id: 1, title: 'Task 1', dependencies: [2], status: 'pending' },
423 | { id: 2, title: 'Task 2', dependencies: [3], status: 'pending' },
424 | { id: 3, title: 'Task 3', dependencies: [4], status: 'pending' },
425 | { id: 4, title: 'Task 4', dependencies: [], status: 'pending' }
426 | ]
427 | },
428 | 'in-progress': {
429 | tasks: [
430 | { id: 5, title: 'Task 5', dependencies: [], status: 'in-progress' }
431 | ]
432 | }
433 | };
434 |
435 | // Set up mock file system with nested dependency data
436 | mockFs({
437 | [testDataDir]: {
438 | 'tasks.json': JSON.stringify(nestedData, null, 2)
439 | }
440 | });
441 |
442 | // Test moving Task 1 with all its nested dependencies
443 | const result = await moveTasksBetweenTags(
444 | testTasksPath,
445 | ['1'], // Task 1
446 | 'backlog',
447 | 'in-progress',
448 | { withDependencies: true, ignoreDependencies: false },
449 | { projectRoot: testDataDir }
450 | );
451 |
452 | // Verify the move operation was successful
453 | expect(result).toBeDefined();
454 | expect(result.message).toContain(
455 | 'Successfully moved 4 tasks from "backlog" to "in-progress"'
456 | );
457 | expect(result.movedTasks).toHaveLength(4); // Tasks 1, 2, 3, 4
458 |
459 | // Read the updated data to verify all tasks moved
460 | const updatedData = readJSON(testTasksPath, null, 'backlog');
461 | const rawData = updatedData._rawTaggedData || updatedData;
462 |
463 | // Verify all tasks moved from backlog
464 | expect(rawData.backlog?.tasks || []).toHaveLength(0); // All tasks moved
465 |
466 | // Verify all tasks are now in in-progress
467 | expect(rawData['in-progress']?.tasks || []).toHaveLength(5); // Task 5 + Tasks 1, 2, 3, 4
468 |
469 | // Verify dependency relationships are preserved
470 | const task1 = rawData['in-progress']?.tasks?.find((t) => t.id === 1);
471 | const task2 = rawData['in-progress']?.tasks?.find((t) => t.id === 2);
472 | const task3 = rawData['in-progress']?.tasks?.find((t) => t.id === 3);
473 | const task4 = rawData['in-progress']?.tasks?.find((t) => t.id === 4);
474 |
475 | expect(task1?.dependencies).toEqual([2]);
476 | expect(task2?.dependencies).toEqual([3]);
477 | expect(task3?.dependencies).toEqual([4]);
478 | expect(task4?.dependencies).toEqual([]);
479 | });
480 |
481 | it('should handle cross-tag dependency resolution using actual move functions', async () => {
482 | // Create data with cross-tag dependencies
483 | const crossTagData = {
484 | backlog: {
485 | tasks: [
486 | { id: 1, title: 'Task 1', dependencies: [5], status: 'pending' }, // Depends on task in in-progress
487 | { id: 2, title: 'Task 2', dependencies: [], status: 'pending' }
488 | ]
489 | },
490 | 'in-progress': {
491 | tasks: [
492 | { id: 5, title: 'Task 5', dependencies: [], status: 'in-progress' }
493 | ]
494 | }
495 | };
496 |
497 | // Set up mock file system with cross-tag dependency data
498 | mockFs({
499 | [testDataDir]: {
500 | 'tasks.json': JSON.stringify(crossTagData, null, 2)
501 | }
502 | });
503 |
504 | // Test moving Task 1 which depends on Task 5 in another tag
505 | const result = await moveTasksBetweenTags(
506 | testTasksPath,
507 | ['1'], // Task 1
508 | 'backlog',
509 | 'in-progress',
510 | { withDependencies: false, ignoreDependencies: false },
511 | { projectRoot: testDataDir }
512 | );
513 |
514 | // Verify the move operation was successful
515 | expect(result).toBeDefined();
516 | expect(result.message).toContain(
517 | 'Successfully moved 1 tasks from "backlog" to "in-progress"'
518 | );
519 |
520 | // Read the updated data to verify the move actually happened
521 | const updatedData = readJSON(testTasksPath, null, 'backlog');
522 | const rawData = updatedData._rawTaggedData || updatedData;
523 |
524 | // Verify Task 1 is no longer in backlog
525 | const taskInBacklog = rawData.backlog?.tasks?.find((t) => t.id === 1);
526 | expect(taskInBacklog).toBeUndefined();
527 |
528 | // Verify Task 1 is now in in-progress with its dependency preserved
529 | const taskInProgress = rawData['in-progress']?.tasks?.find(
530 | (t) => t.id === 1
531 | );
532 | expect(taskInProgress).toBeDefined();
533 | expect(taskInProgress.title).toBe('Task 1');
534 | expect(taskInProgress.dependencies).toEqual([5]); // Cross-tag dependency preserved
535 | });
536 | });
537 | });
538 |
```
--------------------------------------------------------------------------------
/scripts/modules/task-manager/update-task-by-id.js:
--------------------------------------------------------------------------------
```javascript
1 | import fs from 'fs';
2 | import chalk from 'chalk';
3 | import boxen from 'boxen';
4 | import Table from 'cli-table3';
5 |
6 | import {
7 | readJSON,
8 | writeJSON,
9 | truncate,
10 | flattenTasksWithSubtasks,
11 | findProjectRoot
12 | } from '../utils.js';
13 |
14 | import {
15 | getStatusWithColor,
16 | startLoadingIndicator,
17 | stopLoadingIndicator,
18 | displayAiUsageSummary
19 | } from '../ui.js';
20 |
21 | import {
22 | generateTextService,
23 | generateObjectService
24 | } from '../ai-services-unified.js';
25 | import { COMMAND_SCHEMAS } from '../../../src/schemas/registry.js';
26 | import {
27 | isApiKeySet,
28 | hasCodebaseAnalysis,
29 | getDebugFlag
30 | } from '../config-manager.js';
31 | import { getPromptManager } from '../prompt-manager.js';
32 | import { ContextGatherer } from '../utils/contextGatherer.js';
33 | import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
34 | import { tryUpdateViaRemote } from '@tm/bridge';
35 | import { createBridgeLogger } from '../bridge-utils.js';
36 |
37 | /**
38 | * Update a task by ID with new information using the unified AI service.
39 | * @param {string} tasksPath - Path to the tasks.json file
40 | * @param {string|number} taskId - ID of the task to update (supports numeric, alphanumeric like HAM-123, and subtask IDs like 1.2)
41 | * @param {string} prompt - Prompt for generating updated task information
42 | * @param {boolean} [useResearch=false] - Whether to use the research AI role.
43 | * @param {Object} context - Context object containing session and mcpLog.
44 | * @param {Object} [context.session] - Session object from MCP server.
45 | * @param {Object} [context.mcpLog] - MCP logger object.
46 | * @param {string} [context.projectRoot] - Project root path.
47 | * @param {string} [context.tag] - Tag for the task
48 | * @param {string} [outputFormat='text'] - Output format ('text' or 'json').
49 | * @param {boolean} [appendMode=false] - If true, append to details instead of full update.
50 | * @returns {Promise<Object|null>} - The updated task or null if update failed.
51 | */
52 | async function updateTaskById(
53 | tasksPath,
54 | taskId,
55 | prompt,
56 | useResearch = false,
57 | context = {},
58 | outputFormat = 'text',
59 | appendMode = false
60 | ) {
61 | const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context;
62 | const { report, isMCP } = createBridgeLogger(mcpLog, session);
63 |
64 | try {
65 | report('info', `Updating single task ${taskId} with prompt: "${prompt}"`);
66 |
67 | // --- Input Validations ---
68 | // Note: taskId can be a number (1), string with dot (1.2), or display ID (HAM-123)
69 | // So we don't validate it as strictly anymore
70 | if (taskId === null || taskId === undefined || String(taskId).trim() === '')
71 | throw new Error('Task ID cannot be empty.');
72 |
73 | if (!prompt || typeof prompt !== 'string' || prompt.trim() === '')
74 | throw new Error('Prompt cannot be empty.');
75 |
76 | // Determine project root first (needed for API key checks)
77 | const projectRoot = providedProjectRoot || findProjectRoot();
78 | if (!projectRoot) {
79 | throw new Error('Could not determine project root directory');
80 | }
81 |
82 | if (useResearch && !isApiKeySet('perplexity', session)) {
83 | report(
84 | 'warn',
85 | 'Perplexity research requested but API key not set. Falling back.'
86 | );
87 | if (outputFormat === 'text')
88 | console.log(
89 | chalk.yellow('Perplexity AI not available. Falling back to main AI.')
90 | );
91 | useResearch = false;
92 | }
93 |
94 | // --- BRIDGE: Try remote update first (API storage) ---
95 | const remoteResult = await tryUpdateViaRemote({
96 | taskId,
97 | prompt,
98 | projectRoot,
99 | tag,
100 | appendMode,
101 | useResearch,
102 | isMCP,
103 | outputFormat,
104 | report
105 | });
106 |
107 | // If remote handled it, return the result
108 | if (remoteResult) {
109 | return remoteResult;
110 | }
111 | // Otherwise fall through to file-based logic below
112 | // --- End BRIDGE ---
113 |
114 | // For file storage, ensure the tasks file exists
115 | if (!fs.existsSync(tasksPath))
116 | throw new Error(`Tasks file not found: ${tasksPath}`);
117 | // --- End Input Validations ---
118 |
119 | // --- Task Loading and Status Check (Keep existing) ---
120 | const data = readJSON(tasksPath, projectRoot, tag);
121 | if (!data || !data.tasks)
122 | throw new Error(`No valid tasks found in ${tasksPath}.`);
123 | // File storage requires a strict numeric task ID
124 | const idStr = String(taskId).trim();
125 | if (!/^\d+$/.test(idStr)) {
126 | throw new Error(
127 | 'For file storage, taskId must be a positive integer. ' +
128 | 'Use update-subtask-by-id for IDs like "1.2", or run in API storage for display IDs (e.g., "HAM-123").'
129 | );
130 | }
131 | const numericTaskId = Number(idStr);
132 | const taskIndex = data.tasks.findIndex((task) => task.id === numericTaskId);
133 | if (taskIndex === -1) {
134 | report('error', `Task with ID ${numericTaskId} not found`);
135 | throw new Error(`Task with ID ${numericTaskId} not found.`);
136 | }
137 | const taskToUpdate = data.tasks[taskIndex];
138 | if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') {
139 | report(
140 | 'warn',
141 | `Task ${taskId} is already marked as done and cannot be updated`
142 | );
143 |
144 | // Only show warning box for text output (CLI)
145 | if (outputFormat === 'text') {
146 | console.log(
147 | boxen(
148 | chalk.yellow(
149 | `Task ${taskId} is already marked as ${taskToUpdate.status} and cannot be updated.`
150 | ) +
151 | '\n\n' +
152 | chalk.white(
153 | 'Completed tasks are locked to maintain consistency. To modify a completed task, you must first:'
154 | ) +
155 | '\n' +
156 | chalk.white(
157 | '1. Change its status to "pending" or "in-progress"'
158 | ) +
159 | '\n' +
160 | chalk.white('2. Then run the update-task command'),
161 | { padding: 1, borderColor: 'yellow', borderStyle: 'round' }
162 | )
163 | );
164 | }
165 | return null;
166 | }
167 | // --- End Task Loading ---
168 |
169 | // --- Context Gathering ---
170 | let gatheredContext = '';
171 | try {
172 | const contextGatherer = new ContextGatherer(projectRoot, tag);
173 | const allTasksFlat = flattenTasksWithSubtasks(data.tasks);
174 | const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-task');
175 | const searchQuery = `${taskToUpdate.title} ${taskToUpdate.description} ${prompt}`;
176 | const searchResults = fuzzySearch.findRelevantTasks(searchQuery, {
177 | maxResults: 5,
178 | includeSelf: true
179 | });
180 | const relevantTaskIds = fuzzySearch.getTaskIds(searchResults);
181 |
182 | const finalTaskIds = [
183 | ...new Set([taskId.toString(), ...relevantTaskIds])
184 | ];
185 |
186 | if (finalTaskIds.length > 0) {
187 | const contextResult = await contextGatherer.gather({
188 | tasks: finalTaskIds,
189 | format: 'research'
190 | });
191 | gatheredContext = contextResult.context || '';
192 | }
193 | } catch (contextError) {
194 | report('warn', `Could not gather context: ${contextError.message}`);
195 | }
196 | // --- End Context Gathering ---
197 |
198 | // --- Display Task Info (CLI Only - Keep existing) ---
199 | if (outputFormat === 'text') {
200 | // Show the task that will be updated
201 | const table = new Table({
202 | head: [
203 | chalk.cyan.bold('ID'),
204 | chalk.cyan.bold('Title'),
205 | chalk.cyan.bold('Status')
206 | ],
207 | colWidths: [5, 60, 10]
208 | });
209 |
210 | table.push([
211 | taskToUpdate.id,
212 | truncate(taskToUpdate.title, 57),
213 | getStatusWithColor(taskToUpdate.status)
214 | ]);
215 |
216 | console.log(
217 | boxen(chalk.white.bold(`Updating Task #${taskId}`), {
218 | padding: 1,
219 | borderColor: 'blue',
220 | borderStyle: 'round',
221 | margin: { top: 1, bottom: 0 }
222 | })
223 | );
224 |
225 | console.log(table.toString());
226 |
227 | // Display a message about how completed subtasks are handled
228 | console.log(
229 | boxen(
230 | chalk.cyan.bold('How Completed Subtasks Are Handled:') +
231 | '\n\n' +
232 | chalk.white(
233 | '• Subtasks marked as "done" or "completed" will be preserved\n'
234 | ) +
235 | chalk.white(
236 | '• New subtasks will build upon what has already been completed\n'
237 | ) +
238 | chalk.white(
239 | '• If completed work needs revision, a new subtask will be created instead of modifying done items\n'
240 | ) +
241 | chalk.white(
242 | '• This approach maintains a clear record of completed work and new requirements'
243 | ),
244 | {
245 | padding: 1,
246 | borderColor: 'blue',
247 | borderStyle: 'round',
248 | margin: { top: 1, bottom: 1 }
249 | }
250 | )
251 | );
252 | }
253 |
254 | // --- Build Prompts using PromptManager ---
255 | const promptManager = getPromptManager();
256 |
257 | const promptParams = {
258 | task: taskToUpdate,
259 | taskJson: JSON.stringify(taskToUpdate, null, 2),
260 | updatePrompt: prompt,
261 | appendMode: appendMode,
262 | useResearch: useResearch,
263 | currentDetails: taskToUpdate.details || '(No existing details)',
264 | gatheredContext: gatheredContext || '',
265 | hasCodebaseAnalysis: hasCodebaseAnalysis(
266 | useResearch,
267 | projectRoot,
268 | session
269 | ),
270 | projectRoot: projectRoot
271 | };
272 |
273 | const variantKey = appendMode
274 | ? 'append'
275 | : useResearch
276 | ? 'research'
277 | : 'default';
278 |
279 | report(
280 | 'info',
281 | `Loading prompt template with variant: ${variantKey}, appendMode: ${appendMode}, useResearch: ${useResearch}`
282 | );
283 |
284 | let systemPrompt;
285 | let userPrompt;
286 | try {
287 | const promptResult = promptManager.loadPrompt(
288 | 'update-task',
289 | promptParams,
290 | variantKey
291 | );
292 | report(
293 | 'info',
294 | `Prompt result type: ${typeof promptResult}, keys: ${promptResult ? Object.keys(promptResult).join(', ') : 'null'}`
295 | );
296 |
297 | // Extract prompts - loadPrompt returns { systemPrompt, userPrompt, metadata }
298 | systemPrompt = promptResult.systemPrompt;
299 | userPrompt = promptResult.userPrompt;
300 |
301 | report(
302 | 'info',
303 | `Loaded prompts - systemPrompt length: ${systemPrompt?.length}, userPrompt length: ${userPrompt?.length}`
304 | );
305 | } catch (error) {
306 | report('error', `Failed to load prompt template: ${error.message}`);
307 | throw new Error(`Failed to load prompt template: ${error.message}`);
308 | }
309 |
310 | // If prompts are still not set, throw an error
311 | if (!systemPrompt || !userPrompt) {
312 | throw new Error(
313 | `Failed to load prompts: systemPrompt=${!!systemPrompt}, userPrompt=${!!userPrompt}`
314 | );
315 | }
316 | // --- End Build Prompts ---
317 |
318 | let loadingIndicator = null;
319 | let aiServiceResponse = null;
320 |
321 | if (!isMCP && outputFormat === 'text') {
322 | loadingIndicator = startLoadingIndicator(
323 | useResearch ? 'Updating task with research...\n' : 'Updating task...\n'
324 | );
325 | }
326 |
327 | try {
328 | const serviceRole = useResearch ? 'research' : 'main';
329 |
330 | if (appendMode) {
331 | // Append mode still uses generateTextService since it returns plain text
332 | aiServiceResponse = await generateTextService({
333 | role: serviceRole,
334 | session: session,
335 | projectRoot: projectRoot,
336 | systemPrompt: systemPrompt,
337 | prompt: userPrompt,
338 | commandName: 'update-task',
339 | outputType: isMCP ? 'mcp' : 'cli'
340 | });
341 | } else {
342 | // Full update mode uses generateObjectService for structured output
343 | aiServiceResponse = await generateObjectService({
344 | role: serviceRole,
345 | session: session,
346 | projectRoot: projectRoot,
347 | systemPrompt: systemPrompt,
348 | prompt: userPrompt,
349 | schema: COMMAND_SCHEMAS['update-task-by-id'],
350 | objectName: 'task',
351 | commandName: 'update-task',
352 | outputType: isMCP ? 'mcp' : 'cli'
353 | });
354 | }
355 |
356 | if (loadingIndicator)
357 | stopLoadingIndicator(loadingIndicator, 'AI update complete.');
358 |
359 | if (appendMode) {
360 | // Append mode: handle as plain text
361 | const generatedContentString = aiServiceResponse.mainResult;
362 | let newlyAddedSnippet = '';
363 |
364 | if (generatedContentString && generatedContentString.trim()) {
365 | const timestamp = new Date().toISOString();
366 | const formattedBlock = `<info added on ${timestamp}>\n${generatedContentString.trim()}\n</info added on ${timestamp}>`;
367 | newlyAddedSnippet = formattedBlock;
368 |
369 | // Append to task details
370 | taskToUpdate.details =
371 | (taskToUpdate.details ? taskToUpdate.details + '\n' : '') +
372 | formattedBlock;
373 | } else {
374 | report(
375 | 'warn',
376 | 'AI response was empty or whitespace after trimming. Original details remain unchanged.'
377 | );
378 | newlyAddedSnippet = 'No new details were added by the AI.';
379 | }
380 |
381 | // Update description with timestamp if prompt is short
382 | if (prompt.length < 100) {
383 | if (taskToUpdate.description) {
384 | taskToUpdate.description += ` [Updated: ${new Date().toLocaleDateString()}]`;
385 | }
386 | }
387 |
388 | // Write the updated task back to file
389 | data.tasks[taskIndex] = taskToUpdate;
390 | writeJSON(tasksPath, data, projectRoot, tag);
391 | report('success', `Successfully appended to task ${taskId}`);
392 |
393 | // Display success message for CLI
394 | if (outputFormat === 'text') {
395 | console.log(
396 | boxen(
397 | chalk.green(`Successfully appended to task #${taskId}`) +
398 | '\n\n' +
399 | chalk.white.bold('Title:') +
400 | ' ' +
401 | taskToUpdate.title +
402 | '\n\n' +
403 | chalk.white.bold('Newly Added Content:') +
404 | '\n' +
405 | chalk.white(newlyAddedSnippet),
406 | { padding: 1, borderColor: 'green', borderStyle: 'round' }
407 | )
408 | );
409 | }
410 |
411 | // Display AI usage telemetry for CLI users
412 | if (outputFormat === 'text' && aiServiceResponse.telemetryData) {
413 | displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli');
414 | }
415 |
416 | // Return the updated task
417 | return {
418 | updatedTask: taskToUpdate,
419 | telemetryData: aiServiceResponse.telemetryData,
420 | tagInfo: aiServiceResponse.tagInfo
421 | };
422 | }
423 |
424 | // Full update mode: Use structured data directly
425 | const updatedTask = aiServiceResponse.mainResult.task;
426 |
427 | // --- Task Validation/Correction (Keep existing logic) ---
428 | if (!updatedTask || typeof updatedTask !== 'object')
429 | throw new Error('Received invalid task object from AI.');
430 | if (!updatedTask.title || !updatedTask.description)
431 | throw new Error('Updated task missing required fields.');
432 | // Preserve ID if AI changed it
433 | if (updatedTask.id !== taskId) {
434 | report('warn', `AI changed task ID. Restoring original ID ${taskId}.`);
435 | updatedTask.id = taskId;
436 | }
437 | // Preserve status if AI changed it
438 | if (
439 | updatedTask.status !== taskToUpdate.status &&
440 | !prompt.toLowerCase().includes('status')
441 | ) {
442 | report(
443 | 'warn',
444 | `AI changed task status. Restoring original status '${taskToUpdate.status}'.`
445 | );
446 | updatedTask.status = taskToUpdate.status;
447 | }
448 | // Fix subtask IDs if they exist (ensure they are numeric and sequential)
449 | if (updatedTask.subtasks && Array.isArray(updatedTask.subtasks)) {
450 | let currentSubtaskId = 1;
451 | updatedTask.subtasks = updatedTask.subtasks.map((subtask) => {
452 | // Fix AI-generated subtask IDs that might be strings or use parent ID as prefix
453 | const correctedSubtask = {
454 | ...subtask,
455 | id: currentSubtaskId, // Override AI-generated ID with correct sequential ID
456 | dependencies: Array.isArray(subtask.dependencies)
457 | ? subtask.dependencies
458 | .map((dep) =>
459 | typeof dep === 'string' ? parseInt(dep, 10) : dep
460 | )
461 | .filter(
462 | (depId) =>
463 | !Number.isNaN(depId) &&
464 | depId >= 1 &&
465 | depId < currentSubtaskId
466 | )
467 | : [],
468 | status: subtask.status || 'pending'
469 | };
470 | currentSubtaskId++;
471 | return correctedSubtask;
472 | });
473 | report(
474 | 'info',
475 | `Fixed ${updatedTask.subtasks.length} subtask IDs to be sequential numeric IDs.`
476 | );
477 | }
478 |
479 | // Preserve completed subtasks (Keep existing logic)
480 | if (taskToUpdate.subtasks?.length > 0) {
481 | if (!updatedTask.subtasks) {
482 | report(
483 | 'warn',
484 | 'Subtasks removed by AI. Restoring original subtasks.'
485 | );
486 | updatedTask.subtasks = taskToUpdate.subtasks;
487 | } else {
488 | const completedOriginal = taskToUpdate.subtasks.filter(
489 | (st) => st.status === 'done' || st.status === 'completed'
490 | );
491 | completedOriginal.forEach((compSub) => {
492 | const updatedSub = updatedTask.subtasks.find(
493 | (st) => st.id === compSub.id
494 | );
495 | if (
496 | !updatedSub ||
497 | JSON.stringify(updatedSub) !== JSON.stringify(compSub)
498 | ) {
499 | report(
500 | 'warn',
501 | `Completed subtask ${compSub.id} was modified or removed. Restoring.`
502 | );
503 | // Remove potentially modified version
504 | updatedTask.subtasks = updatedTask.subtasks.filter(
505 | (st) => st.id !== compSub.id
506 | );
507 | // Add back original
508 | updatedTask.subtasks.push(compSub);
509 | }
510 | });
511 | // Deduplicate just in case
512 | const subtaskIds = new Set();
513 | updatedTask.subtasks = updatedTask.subtasks.filter((st) => {
514 | if (!subtaskIds.has(st.id)) {
515 | subtaskIds.add(st.id);
516 | return true;
517 | }
518 | report('warn', `Duplicate subtask ID ${st.id} removed.`);
519 | return false;
520 | });
521 | }
522 | }
523 | // --- End Task Validation/Correction ---
524 |
525 | // --- Update Task Data (Keep existing) ---
526 | data.tasks[taskIndex] = updatedTask;
527 | // --- End Update Task Data ---
528 |
529 | // --- Write File and Generate (Unchanged) ---
530 | writeJSON(tasksPath, data, projectRoot, tag);
531 | report('success', `Successfully updated task ${taskId}`);
532 | // await generateTaskFiles(tasksPath, path.dirname(tasksPath));
533 | // --- End Write File ---
534 |
535 | // --- Display CLI Telemetry ---
536 | if (outputFormat === 'text' && aiServiceResponse.telemetryData) {
537 | displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli'); // <<< ADD display
538 | }
539 |
540 | // --- Return Success with Telemetry ---
541 | return {
542 | updatedTask: updatedTask, // Return the updated task object
543 | telemetryData: aiServiceResponse.telemetryData, // <<< ADD telemetryData
544 | tagInfo: aiServiceResponse.tagInfo
545 | };
546 | } catch (error) {
547 | // Catch errors from generateTextService
548 | if (loadingIndicator) stopLoadingIndicator(loadingIndicator);
549 | report('error', `Error during AI service call: ${error.message}`);
550 | if (error.message.includes('API key')) {
551 | report('error', 'Please ensure API keys are configured correctly.');
552 | }
553 | throw error; // Re-throw error
554 | }
555 | } catch (error) {
556 | // General error catch
557 | // --- General Error Handling (Keep existing) ---
558 | report('error', `Error updating task: ${error.message}`);
559 | if (outputFormat === 'text') {
560 | console.error(chalk.red(`Error: ${error.message}`));
561 | // ... helpful hints ...
562 | if (getDebugFlag(session)) console.error(error);
563 | process.exit(1);
564 | }
565 | throw error; // Re-throw for MCP
566 | // --- End General Error Handling ---
567 | }
568 | }
569 |
570 | export default updateTaskById;
571 |
```