This is page 59 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
--------------------------------------------------------------------------------
/tests/unit/scripts/modules/task-manager/parse-prd.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * Tests for the parse-prd.js module
3 | */
4 | import { jest } from '@jest/globals';
5 |
6 | // Mock the dependencies before importing the module under test
7 | jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
8 | readJSON: jest.fn(),
9 | writeJSON: jest.fn(),
10 | log: jest.fn(),
11 | CONFIG: {
12 | model: 'mock-claude-model',
13 | maxTokens: 4000,
14 | temperature: 0.7,
15 | debug: false
16 | },
17 | sanitizePrompt: jest.fn((prompt) => prompt),
18 | truncate: jest.fn((text) => text),
19 | isSilentMode: jest.fn(() => false),
20 | enableSilentMode: jest.fn(),
21 | disableSilentMode: jest.fn(),
22 | findTaskById: jest.fn(),
23 | ensureTagMetadata: jest.fn((tagObj) => tagObj),
24 | getCurrentTag: jest.fn(() => 'master'),
25 | promptYesNo: jest.fn()
26 | }));
27 |
28 | jest.unstable_mockModule(
29 | '../../../../../scripts/modules/ai-services-unified.js',
30 | () => ({
31 | generateObjectService: jest.fn().mockResolvedValue({
32 | tasks: [
33 | {
34 | id: 1,
35 | title: 'Test Task 1',
36 | priority: 'high',
37 | description: 'Test description 1',
38 | status: 'pending',
39 | dependencies: []
40 | },
41 | {
42 | id: 2,
43 | title: 'Test Task 2',
44 | priority: 'medium',
45 | description: 'Test description 2',
46 | status: 'pending',
47 | dependencies: []
48 | },
49 | {
50 | id: 3,
51 | title: 'Test Task 3',
52 | priority: 'low',
53 | description: 'Test description 3',
54 | status: 'pending',
55 | dependencies: []
56 | }
57 | ]
58 | }),
59 | streamObjectService: jest.fn().mockImplementation(async () => {
60 | // Return an object with partialObjectStream as a getter that returns the async generator
61 | return {
62 | mainResult: {
63 | get partialObjectStream() {
64 | return (async function* () {
65 | yield { tasks: [] };
66 | yield {
67 | tasks: [
68 | {
69 | id: 1,
70 | title: 'Test Task 1',
71 | priority: 'high',
72 | description: 'Test description 1',
73 | status: 'pending',
74 | dependencies: []
75 | }
76 | ]
77 | };
78 | yield {
79 | tasks: [
80 | {
81 | id: 1,
82 | title: 'Test Task 1',
83 | priority: 'high',
84 | description: 'Test description 1',
85 | status: 'pending',
86 | dependencies: []
87 | },
88 | {
89 | id: 2,
90 | title: 'Test Task 2',
91 | priority: 'medium',
92 | description: 'Test description 2',
93 | status: 'pending',
94 | dependencies: []
95 | }
96 | ]
97 | };
98 | yield {
99 | tasks: [
100 | {
101 | id: 1,
102 | title: 'Test Task 1',
103 | priority: 'high',
104 | description: 'Test description 1',
105 | status: 'pending',
106 | dependencies: []
107 | },
108 | {
109 | id: 2,
110 | title: 'Test Task 2',
111 | priority: 'medium',
112 | description: 'Test description 2',
113 | status: 'pending',
114 | dependencies: []
115 | },
116 | {
117 | id: 3,
118 | title: 'Test Task 3',
119 | priority: 'low',
120 | description: 'Test description 3',
121 | status: 'pending',
122 | dependencies: []
123 | }
124 | ]
125 | };
126 | })();
127 | },
128 | usage: Promise.resolve({
129 | promptTokens: 100,
130 | completionTokens: 200,
131 | totalTokens: 300
132 | }),
133 | object: Promise.resolve({
134 | tasks: [
135 | {
136 | id: 1,
137 | title: 'Test Task 1',
138 | priority: 'high',
139 | description: 'Test description 1',
140 | status: 'pending',
141 | dependencies: []
142 | },
143 | {
144 | id: 2,
145 | title: 'Test Task 2',
146 | priority: 'medium',
147 | description: 'Test description 2',
148 | status: 'pending',
149 | dependencies: []
150 | },
151 | {
152 | id: 3,
153 | title: 'Test Task 3',
154 | priority: 'low',
155 | description: 'Test description 3',
156 | status: 'pending',
157 | dependencies: []
158 | }
159 | ]
160 | })
161 | },
162 | providerName: 'anthropic',
163 | modelId: 'claude-3-5-sonnet-20241022',
164 | telemetryData: {}
165 | };
166 | })
167 | })
168 | );
169 |
170 | jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
171 | getStatusWithColor: jest.fn((status) => status),
172 | startLoadingIndicator: jest.fn(),
173 | stopLoadingIndicator: jest.fn(),
174 | displayAiUsageSummary: jest.fn()
175 | }));
176 |
177 | jest.unstable_mockModule(
178 | '../../../../../scripts/modules/config-manager.js',
179 | () => ({
180 | getDebugFlag: jest.fn(() => false),
181 | getMainModelId: jest.fn(() => 'claude-3-5-sonnet'),
182 | getResearchModelId: jest.fn(() => 'claude-3-5-sonnet'),
183 | getParametersForRole: jest.fn(() => ({
184 | provider: 'anthropic',
185 | modelId: 'claude-3-5-sonnet'
186 | })),
187 | getDefaultNumTasks: jest.fn(() => 10),
188 | getDefaultPriority: jest.fn(() => 'medium'),
189 | getMainProvider: jest.fn(() => 'openai'),
190 | getResearchProvider: jest.fn(() => 'perplexity'),
191 | hasCodebaseAnalysis: jest.fn(() => false)
192 | })
193 | );
194 |
195 | jest.unstable_mockModule(
196 | '../../../../../scripts/modules/task-manager/generate-task-files.js',
197 | () => ({
198 | default: jest.fn().mockResolvedValue()
199 | })
200 | );
201 |
202 | jest.unstable_mockModule(
203 | '../../../../../scripts/modules/task-manager/models.js',
204 | () => ({
205 | getModelConfiguration: jest.fn(() => ({
206 | model: 'mock-model',
207 | maxTokens: 4000,
208 | temperature: 0.7
209 | }))
210 | })
211 | );
212 |
213 | jest.unstable_mockModule(
214 | '../../../../../scripts/modules/prompt-manager.js',
215 | () => ({
216 | getPromptManager: jest.fn().mockReturnValue({
217 | loadPrompt: jest.fn().mockImplementation((templateName, params) => {
218 | // Create dynamic mock prompts based on the parameters
219 | const { numTasks } = params || {};
220 | let numTasksText = '';
221 |
222 | if (numTasks > 0) {
223 | numTasksText = `approximately ${numTasks}`;
224 | } else {
225 | numTasksText = 'an appropriate number of';
226 | }
227 |
228 | return Promise.resolve({
229 | systemPrompt: 'Mocked system prompt for parse-prd',
230 | userPrompt: `Generate ${numTasksText} top-level development tasks from the PRD content.`
231 | });
232 | })
233 | })
234 | })
235 | );
236 |
237 | // Mock fs module
238 | jest.unstable_mockModule('fs', () => ({
239 | default: {
240 | readFileSync: jest.fn(),
241 | existsSync: jest.fn(),
242 | mkdirSync: jest.fn(),
243 | writeFileSync: jest.fn(),
244 | promises: {
245 | readFile: jest.fn()
246 | }
247 | },
248 | readFileSync: jest.fn(),
249 | existsSync: jest.fn(),
250 | mkdirSync: jest.fn(),
251 | writeFileSync: jest.fn()
252 | }));
253 |
254 | // Mock path module
255 | jest.unstable_mockModule('path', () => ({
256 | default: {
257 | dirname: jest.fn(),
258 | join: jest.fn((dir, file) => `${dir}/${file}`)
259 | },
260 | dirname: jest.fn(),
261 | join: jest.fn((dir, file) => `${dir}/${file}`)
262 | }));
263 |
264 | // Mock JSONParser for streaming tests
265 | jest.unstable_mockModule('@streamparser/json', () => ({
266 | JSONParser: jest.fn().mockImplementation(() => ({
267 | onValue: jest.fn(),
268 | onError: jest.fn(),
269 | write: jest.fn(),
270 | end: jest.fn()
271 | }))
272 | }));
273 |
274 | // Mock stream-parser functions
275 | jest.unstable_mockModule('../../../../../src/utils/stream-parser.js', () => {
276 | // Define mock StreamingError class
277 | class StreamingError extends Error {
278 | constructor(message, code) {
279 | super(message);
280 | this.name = 'StreamingError';
281 | this.code = code;
282 | }
283 | }
284 |
285 | // Define mock error codes
286 | const STREAMING_ERROR_CODES = {
287 | NOT_ASYNC_ITERABLE: 'STREAMING_NOT_SUPPORTED',
288 | STREAM_PROCESSING_FAILED: 'STREAM_PROCESSING_FAILED',
289 | STREAM_NOT_ITERABLE: 'STREAM_NOT_ITERABLE'
290 | };
291 |
292 | return {
293 | parseStream: jest.fn().mockResolvedValue({
294 | items: [{ id: 1, title: 'Test Task', priority: 'high' }],
295 | accumulatedText:
296 | '{"tasks":[{"id":1,"title":"Test Task","priority":"high"}]}',
297 | estimatedTokens: 50,
298 | usedFallback: false
299 | }),
300 | createTaskProgressCallback: jest.fn().mockReturnValue(jest.fn()),
301 | createConsoleProgressCallback: jest.fn().mockReturnValue(jest.fn()),
302 | StreamingError,
303 | STREAMING_ERROR_CODES
304 | };
305 | });
306 |
307 | // Mock progress tracker to prevent intervals
308 | jest.unstable_mockModule(
309 | '../../../../../src/progress/parse-prd-tracker.js',
310 | () => ({
311 | createParsePrdTracker: jest.fn().mockReturnValue({
312 | start: jest.fn(),
313 | stop: jest.fn(),
314 | cleanup: jest.fn(),
315 | updateTokens: jest.fn(),
316 | addTaskLine: jest.fn(),
317 | trackTaskPriority: jest.fn(),
318 | getSummary: jest.fn().mockReturnValue({
319 | taskPriorities: { high: 0, medium: 0, low: 0 },
320 | elapsedTime: 0,
321 | actionVerb: 'generated'
322 | })
323 | })
324 | })
325 | );
326 |
327 | // Mock UI functions to prevent any display delays
328 | jest.unstable_mockModule('../../../../../src/ui/parse-prd.js', () => ({
329 | displayParsePrdStart: jest.fn(),
330 | displayParsePrdSummary: jest.fn()
331 | }));
332 |
333 | // Import the mocked modules
334 | const { readJSON, promptYesNo } = await import(
335 | '../../../../../scripts/modules/utils.js'
336 | );
337 |
338 | const { generateObjectService, streamObjectService } = await import(
339 | '../../../../../scripts/modules/ai-services-unified.js'
340 | );
341 |
342 | const { JSONParser } = await import('@streamparser/json');
343 |
344 | const { parseStream, StreamingError, STREAMING_ERROR_CODES } = await import(
345 | '../../../../../src/utils/stream-parser.js'
346 | );
347 |
348 | const { createParsePrdTracker } = await import(
349 | '../../../../../src/progress/parse-prd-tracker.js'
350 | );
351 |
352 | const { displayParsePrdStart, displayParsePrdSummary } = await import(
353 | '../../../../../src/ui/parse-prd.js'
354 | );
355 |
356 | // Note: getDefaultNumTasks validation happens at CLI/MCP level, not in the main parse-prd module
357 | const generateTaskFiles = (
358 | await import(
359 | '../../../../../scripts/modules/task-manager/generate-task-files.js'
360 | )
361 | ).default;
362 |
363 | const fs = await import('fs');
364 | const path = await import('path');
365 |
366 | // Import the module under test
367 | const { default: parsePRD } = await import(
368 | '../../../../../scripts/modules/task-manager/parse-prd/parse-prd.js'
369 | );
370 |
371 | // Sample data for tests (from main test file)
372 | const sampleClaudeResponse = {
373 | tasks: [
374 | {
375 | id: 1,
376 | title: 'Setup Project Structure',
377 | description: 'Initialize the project with necessary files and folders',
378 | status: 'pending',
379 | dependencies: [],
380 | priority: 'high'
381 | },
382 | {
383 | id: 2,
384 | title: 'Implement Core Features',
385 | description: 'Build the main functionality',
386 | status: 'pending',
387 | dependencies: [1],
388 | priority: 'high'
389 | }
390 | ],
391 | metadata: {
392 | projectName: 'Test Project',
393 | totalTasks: 2,
394 | sourceFile: 'path/to/prd.txt',
395 | generatedAt: expect.any(String)
396 | }
397 | };
398 |
399 | describe('parsePRD', () => {
400 | // Mock the sample PRD content
401 | const samplePRDContent = '# Sample PRD for Testing';
402 |
403 | // Mock existing tasks for append test - TAGGED FORMAT
404 | const existingTasksData = {
405 | master: {
406 | tasks: [
407 | { id: 1, title: 'Existing Task 1', status: 'done' },
408 | { id: 2, title: 'Existing Task 2', status: 'pending' }
409 | ]
410 | }
411 | };
412 |
413 | // Mock new tasks with continuing IDs for append test
414 | const newTasksClaudeResponse = {
415 | tasks: [
416 | { id: 3, title: 'New Task 3' },
417 | { id: 4, title: 'New Task 4' }
418 | ],
419 | metadata: {
420 | projectName: 'Test Project',
421 | totalTasks: 2,
422 | sourceFile: 'path/to/prd.txt',
423 | generatedAt: expect.any(String)
424 | }
425 | };
426 |
427 | beforeEach(() => {
428 | // Reset all mocks
429 | jest.clearAllMocks();
430 |
431 | // Set up mocks for fs, path and other modules
432 | fs.default.readFileSync.mockReturnValue(samplePRDContent);
433 | fs.default.promises.readFile.mockResolvedValue(samplePRDContent);
434 | fs.default.existsSync.mockReturnValue(true);
435 | path.default.dirname.mockReturnValue('tasks');
436 | generateObjectService.mockResolvedValue({
437 | mainResult: sampleClaudeResponse,
438 | telemetryData: {}
439 | });
440 | // Reset streamObjectService mock to working implementation
441 | streamObjectService.mockImplementation(async () => {
442 | return {
443 | mainResult: {
444 | get partialObjectStream() {
445 | return (async function* () {
446 | yield { tasks: [] };
447 | yield { tasks: [sampleClaudeResponse.tasks[0]] };
448 | yield {
449 | tasks: [
450 | sampleClaudeResponse.tasks[0],
451 | sampleClaudeResponse.tasks[1]
452 | ]
453 | };
454 | yield sampleClaudeResponse;
455 | })();
456 | },
457 | usage: Promise.resolve({
458 | promptTokens: 100,
459 | completionTokens: 200,
460 | totalTokens: 300
461 | }),
462 | object: Promise.resolve(sampleClaudeResponse)
463 | },
464 | providerName: 'anthropic',
465 | modelId: 'claude-3-5-sonnet-20241022',
466 | telemetryData: {}
467 | };
468 | });
469 | // generateTaskFiles.mockResolvedValue(undefined);
470 | promptYesNo.mockResolvedValue(true); // Default to "yes" for confirmation
471 |
472 | // Mock process.exit to prevent actual exit and throw error instead for CLI tests
473 | jest.spyOn(process, 'exit').mockImplementation((code) => {
474 | throw new Error(`process.exit was called with code ${code}`);
475 | });
476 |
477 | // Mock console.error to prevent output
478 | jest.spyOn(console, 'error').mockImplementation(() => {});
479 | jest.spyOn(console, 'log').mockImplementation(() => {});
480 | });
481 |
482 | afterEach(() => {
483 | // Restore all mocks after each test
484 | jest.restoreAllMocks();
485 | });
486 |
487 | test('should parse a PRD file and generate tasks', async () => {
488 | // Setup mocks to simulate normal conditions (no existing output file)
489 | fs.default.existsSync.mockImplementation((p) => {
490 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
491 | if (p === 'tasks') return true; // Directory exists
492 | return false;
493 | });
494 |
495 | // Also mock the other fs methods that might be called
496 | fs.default.readFileSync.mockReturnValue(samplePRDContent);
497 | fs.default.promises.readFile.mockResolvedValue(samplePRDContent);
498 |
499 | // Call the function with mcpLog to force non-streaming mode
500 | const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
501 | tag: 'master',
502 | mcpLog: {
503 | info: jest.fn(),
504 | warn: jest.fn(),
505 | error: jest.fn(),
506 | debug: jest.fn(),
507 | success: jest.fn()
508 | }
509 | });
510 |
511 | // Verify fs.readFileSync was called with the correct arguments
512 | expect(fs.default.readFileSync).toHaveBeenCalledWith(
513 | 'path/to/prd.txt',
514 | 'utf8'
515 | );
516 |
517 | // Verify generateObjectService was called
518 | expect(generateObjectService).toHaveBeenCalled();
519 |
520 | // Verify directory check
521 | expect(fs.default.existsSync).toHaveBeenCalledWith('tasks');
522 |
523 | // Verify fs.writeFileSync was called with the correct arguments in tagged format
524 | expect(fs.default.writeFileSync).toHaveBeenCalledWith(
525 | 'tasks/tasks.json',
526 | expect.stringContaining('"master"')
527 | );
528 |
529 | // Verify result
530 | expect(result).toEqual({
531 | success: true,
532 | tasksPath: 'tasks/tasks.json',
533 | telemetryData: {}
534 | });
535 |
536 | // Verify that the written data contains 2 tasks from sampleClaudeResponse in the correct tag
537 | const writtenDataString = fs.default.writeFileSync.mock.calls[0][1];
538 | const writtenData = JSON.parse(writtenDataString);
539 | expect(writtenData.master.tasks.length).toBe(2);
540 | });
541 |
542 | test('should create the tasks directory if it does not exist', async () => {
543 | // Mock existsSync to return false specifically for the directory check
544 | // but true for the output file check (so we don't trigger confirmation path)
545 | fs.default.existsSync.mockImplementation((p) => {
546 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
547 | if (p === 'tasks') return false; // Directory doesn't exist
548 | return true; // Default for other paths
549 | });
550 |
551 | // Call the function with mcpLog to force non-streaming mode
552 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
553 | tag: 'master',
554 | mcpLog: {
555 | info: jest.fn(),
556 | warn: jest.fn(),
557 | error: jest.fn(),
558 | debug: jest.fn(),
559 | success: jest.fn()
560 | }
561 | });
562 |
563 | // Verify mkdir was called
564 | expect(fs.default.mkdirSync).toHaveBeenCalledWith('tasks', {
565 | recursive: true
566 | });
567 | });
568 |
569 | test('should handle errors in the PRD parsing process', async () => {
570 | // Mock an error in generateObjectService
571 | const testError = new Error('Test error in AI API call');
572 | generateObjectService.mockRejectedValueOnce(testError);
573 |
574 | // Setup mocks to simulate normal file conditions (no existing file)
575 | fs.default.existsSync.mockImplementation((p) => {
576 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
577 | if (p === 'tasks') return true; // Directory exists
578 | return false;
579 | });
580 |
581 | // Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit)
582 | await expect(
583 | parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
584 | tag: 'master',
585 | mcpLog: {
586 | info: jest.fn(),
587 | warn: jest.fn(),
588 | error: jest.fn(),
589 | debug: jest.fn(),
590 | success: jest.fn()
591 | }
592 | })
593 | ).rejects.toThrow('Test error in AI API call');
594 | });
595 |
596 | test('should generate individual task files after creating tasks.json', async () => {
597 | // Setup mocks to simulate normal conditions (no existing output file)
598 | fs.default.existsSync.mockImplementation((p) => {
599 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
600 | if (p === 'tasks') return true; // Directory exists
601 | return false;
602 | });
603 |
604 | // Call the function with mcpLog to force non-streaming mode
605 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
606 | tag: 'master',
607 | mcpLog: {
608 | info: jest.fn(),
609 | warn: jest.fn(),
610 | error: jest.fn(),
611 | debug: jest.fn(),
612 | success: jest.fn()
613 | }
614 | });
615 |
616 | // generateTaskFiles is currently commented out in parse-prd.js
617 | });
618 |
619 | test('should overwrite tasks.json when force flag is true', async () => {
620 | // Setup mocks to simulate tasks.json already exists
621 | fs.default.existsSync.mockImplementation((p) => {
622 | if (p === 'tasks/tasks.json') return true; // Output file exists
623 | if (p === 'tasks') return true; // Directory exists
624 | return false;
625 | });
626 |
627 | // Call the function with force=true to allow overwrite and mcpLog to force non-streaming mode
628 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
629 | force: true,
630 | tag: 'master',
631 | mcpLog: {
632 | info: jest.fn(),
633 | warn: jest.fn(),
634 | error: jest.fn(),
635 | debug: jest.fn(),
636 | success: jest.fn()
637 | }
638 | });
639 |
640 | // Verify prompt was NOT called (confirmation happens at CLI level, not in core function)
641 | expect(promptYesNo).not.toHaveBeenCalled();
642 |
643 | // Verify the file was written after force overwrite
644 | expect(fs.default.writeFileSync).toHaveBeenCalledWith(
645 | 'tasks/tasks.json',
646 | expect.stringContaining('"master"')
647 | );
648 | });
649 |
650 | test('should throw error when tasks in tag exist without force flag in MCP mode', async () => {
651 | // Setup mocks to simulate tasks.json already exists with tasks in the target tag
652 | fs.default.existsSync.mockReturnValue(true);
653 | // Mock readFileSync to return data with tasks in the 'master' tag
654 | fs.default.readFileSync.mockReturnValueOnce(
655 | JSON.stringify(existingTasksData)
656 | );
657 |
658 | // Call the function with mcpLog to make it think it's in MCP mode (which throws instead of process.exit)
659 | await expect(
660 | parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
661 | tag: 'master',
662 | mcpLog: {
663 | info: jest.fn(),
664 | warn: jest.fn(),
665 | error: jest.fn(),
666 | debug: jest.fn(),
667 | success: jest.fn()
668 | }
669 | })
670 | ).rejects.toThrow('already contains');
671 |
672 | // Verify prompt was NOT called
673 | expect(promptYesNo).not.toHaveBeenCalled();
674 |
675 | // Verify the file was NOT written
676 | expect(fs.default.writeFileSync).not.toHaveBeenCalled();
677 | });
678 |
679 | test('should throw error when tasks in tag exist without force flag in CLI mode', async () => {
680 | // Setup mocks to simulate tasks.json already exists with tasks in the target tag
681 | fs.default.existsSync.mockReturnValue(true);
682 | fs.default.readFileSync.mockReturnValueOnce(
683 | JSON.stringify(existingTasksData)
684 | );
685 |
686 | // Call the function without mcpLog (CLI mode) and expect it to throw an error
687 | // In test environment, process.exit is prevented and error is thrown instead
688 | await expect(
689 | parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, { tag: 'master' })
690 | ).rejects.toThrow('process.exit was called with code 1');
691 |
692 | // Verify the file was NOT written
693 | expect(fs.default.writeFileSync).not.toHaveBeenCalled();
694 | });
695 |
696 | test('should append new tasks when append option is true', async () => {
697 | // Setup mocks to simulate tasks.json already exists
698 | fs.default.existsSync.mockReturnValue(true);
699 |
700 | // Mock for reading existing tasks in tagged format
701 | readJSON.mockReturnValue(existingTasksData);
702 | // Mock readFileSync to return the raw content for the initial check
703 | fs.default.readFileSync.mockReturnValueOnce(
704 | JSON.stringify(existingTasksData)
705 | );
706 |
707 | // Mock generateObjectService to return new tasks with continuing IDs
708 | generateObjectService.mockResolvedValueOnce({
709 | mainResult: { object: newTasksClaudeResponse },
710 | telemetryData: {}
711 | });
712 |
713 | // Call the function with append option and mcpLog to force non-streaming mode
714 | const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 2, {
715 | tag: 'master',
716 | append: true,
717 | mcpLog: {
718 | info: jest.fn(),
719 | warn: jest.fn(),
720 | error: jest.fn(),
721 | debug: jest.fn(),
722 | success: jest.fn()
723 | }
724 | });
725 |
726 | // Verify prompt was NOT called (no confirmation needed for append)
727 | expect(promptYesNo).not.toHaveBeenCalled();
728 |
729 | // Verify the file was written with merged tasks in the correct tag
730 | expect(fs.default.writeFileSync).toHaveBeenCalledWith(
731 | 'tasks/tasks.json',
732 | expect.stringContaining('"master"')
733 | );
734 |
735 | // Verify the result contains merged tasks
736 | expect(result).toEqual({
737 | success: true,
738 | tasksPath: 'tasks/tasks.json',
739 | telemetryData: {}
740 | });
741 |
742 | // Verify that the written data contains 4 tasks (2 existing + 2 new)
743 | const writtenDataString = fs.default.writeFileSync.mock.calls[0][1];
744 | const writtenData = JSON.parse(writtenDataString);
745 | expect(writtenData.master.tasks.length).toBe(4);
746 | });
747 |
748 | test('should skip prompt and not overwrite when append is true', async () => {
749 | // Setup mocks to simulate tasks.json already exists
750 | fs.default.existsSync.mockReturnValue(true);
751 | fs.default.readFileSync.mockReturnValueOnce(
752 | JSON.stringify(existingTasksData)
753 | );
754 |
755 | // Ensure generateObjectService returns proper tasks
756 | generateObjectService.mockResolvedValue({
757 | mainResult: {
758 | tasks: [
759 | {
760 | id: 1,
761 | title: 'Test Task 1',
762 | priority: 'high',
763 | description: 'Test description 1',
764 | status: 'pending',
765 | dependencies: []
766 | },
767 | {
768 | id: 2,
769 | title: 'Test Task 2',
770 | priority: 'medium',
771 | description: 'Test description 2',
772 | status: 'pending',
773 | dependencies: []
774 | },
775 | {
776 | id: 3,
777 | title: 'Test Task 3',
778 | priority: 'low',
779 | description: 'Test description 3',
780 | status: 'pending',
781 | dependencies: []
782 | }
783 | ]
784 | },
785 | telemetryData: {}
786 | });
787 |
788 | // Call the function with append option
789 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
790 | tag: 'master',
791 | append: true
792 | });
793 |
794 | // Verify prompt was NOT called with append flag
795 | expect(promptYesNo).not.toHaveBeenCalled();
796 | });
797 |
798 | describe('Streaming vs Non-Streaming Modes', () => {
799 | test('should use non-streaming when reportProgress function is provided (streaming disabled)', async () => {
800 | // Setup mocks to simulate normal conditions (no existing output file)
801 | fs.default.existsSync.mockImplementation((path) => {
802 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
803 | if (path === 'tasks') return true; // Directory exists
804 | return false;
805 | });
806 |
807 | // Mock progress reporting function
808 | const mockReportProgress = jest.fn(() => Promise.resolve());
809 |
810 | // Mock JSONParser instance
811 | const mockParser = {
812 | onValue: jest.fn(),
813 | onError: jest.fn(),
814 | write: jest.fn(),
815 | end: jest.fn()
816 | };
817 | JSONParser.mockReturnValue(mockParser);
818 |
819 | // Call the function with reportProgress - with streaming disabled, should use non-streaming
820 | const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
821 | reportProgress: mockReportProgress
822 | });
823 |
824 | // With streaming disabled, should use generateObjectService instead
825 | expect(generateObjectService).toHaveBeenCalled();
826 |
827 | // Verify streamObjectService was NOT called (streaming is disabled)
828 | expect(streamObjectService).not.toHaveBeenCalled();
829 |
830 | // Verify progress reporting was still called
831 | expect(mockReportProgress).toHaveBeenCalled();
832 |
833 | // Verify result structure
834 | expect(result).toEqual({
835 | success: true,
836 | tasksPath: 'tasks/tasks.json',
837 | telemetryData: {}
838 | });
839 | });
840 |
841 | test.skip('should fallback to non-streaming when streaming fails with specific errors (streaming disabled)', async () => {
842 | // Setup mocks to simulate normal conditions (no existing output file)
843 | fs.default.existsSync.mockImplementation((path) => {
844 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
845 | if (path === 'tasks') return true; // Directory exists
846 | return false;
847 | });
848 |
849 | // Mock progress reporting function
850 | const mockReportProgress = jest.fn(() => Promise.resolve());
851 |
852 | // Mock streamObjectService to return a stream that fails during processing
853 | streamObjectService.mockImplementationOnce(async () => {
854 | return {
855 | mainResult: {
856 | get partialObjectStream() {
857 | return (async function* () {
858 | throw new Error('Stream processing failed');
859 | })();
860 | },
861 | usage: Promise.resolve(null),
862 | object: Promise.resolve(null)
863 | },
864 | providerName: 'anthropic',
865 | modelId: 'claude-3-5-sonnet-20241022',
866 | telemetryData: {}
867 | };
868 | });
869 |
870 | // Ensure generateObjectService returns tasks for fallback
871 | generateObjectService.mockResolvedValue({
872 | mainResult: {
873 | tasks: [
874 | {
875 | id: 1,
876 | title: 'Test Task 1',
877 | priority: 'high',
878 | description: 'Test description 1',
879 | status: 'pending',
880 | dependencies: []
881 | },
882 | {
883 | id: 2,
884 | title: 'Test Task 2',
885 | priority: 'medium',
886 | description: 'Test description 2',
887 | status: 'pending',
888 | dependencies: []
889 | },
890 | {
891 | id: 3,
892 | title: 'Test Task 3',
893 | priority: 'low',
894 | description: 'Test description 3',
895 | status: 'pending',
896 | dependencies: []
897 | }
898 | ]
899 | },
900 | telemetryData: {}
901 | });
902 |
903 | // Call the function with reportProgress to trigger streaming path
904 | const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
905 | reportProgress: mockReportProgress
906 | });
907 |
908 | // Verify streamObjectService was called first (streaming attempt)
909 | expect(streamObjectService).toHaveBeenCalled();
910 |
911 | // Verify generateObjectService was called as fallback
912 | expect(generateObjectService).toHaveBeenCalled();
913 |
914 | // Verify result structure (should succeed via fallback)
915 | expect(result).toEqual({
916 | success: true,
917 | tasksPath: 'tasks/tasks.json',
918 | telemetryData: {}
919 | });
920 | });
921 |
922 | test('should use non-streaming when reportProgress is not provided', async () => {
923 | // Setup mocks to simulate normal conditions (no existing output file)
924 | fs.default.existsSync.mockImplementation((path) => {
925 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
926 | if (path === 'tasks') return true; // Directory exists
927 | return false;
928 | });
929 |
930 | // Call the function without reportProgress but with mcpLog to force non-streaming path
931 | const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
932 | mcpLog: {
933 | info: jest.fn(),
934 | warn: jest.fn(),
935 | error: jest.fn(),
936 | debug: jest.fn(),
937 | success: jest.fn()
938 | }
939 | });
940 |
941 | // Verify generateObjectService was called (non-streaming path)
942 | expect(generateObjectService).toHaveBeenCalled();
943 |
944 | // Verify streamObjectService was NOT called (streaming path)
945 | expect(streamObjectService).not.toHaveBeenCalled();
946 |
947 | // Verify result structure
948 | expect(result).toEqual({
949 | success: true,
950 | tasksPath: 'tasks/tasks.json',
951 | telemetryData: {}
952 | });
953 | });
954 |
955 | test('should handle research flag with non-streaming (streaming disabled)', async () => {
956 | // Setup mocks to simulate normal conditions
957 | fs.default.existsSync.mockImplementation((path) => {
958 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
959 | if (path === 'tasks') return true; // Directory exists
960 | return false;
961 | });
962 |
963 | // Mock progress reporting function
964 | const mockReportProgress = jest.fn(() => Promise.resolve());
965 |
966 | // Call with reportProgress + research - with streaming disabled, should use non-streaming
967 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
968 | reportProgress: mockReportProgress,
969 | research: true
970 | });
971 |
972 | // With streaming disabled, should use generateObjectService with research role
973 | expect(generateObjectService).toHaveBeenCalledWith(
974 | expect.objectContaining({
975 | role: 'research'
976 | })
977 | );
978 | expect(streamObjectService).not.toHaveBeenCalled();
979 | });
980 |
981 | test('should handle research flag with non-streaming', async () => {
982 | // Setup mocks to simulate normal conditions
983 | fs.default.existsSync.mockImplementation((path) => {
984 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
985 | if (path === 'tasks') return true; // Directory exists
986 | return false;
987 | });
988 |
989 | // Call without reportProgress but with mcpLog (non-streaming) + research
990 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
991 | research: true,
992 | mcpLog: {
993 | info: jest.fn(),
994 | warn: jest.fn(),
995 | error: jest.fn(),
996 | debug: jest.fn(),
997 | success: jest.fn()
998 | }
999 | });
1000 |
1001 | // Verify non-streaming path was used with research role
1002 | expect(generateObjectService).toHaveBeenCalledWith(
1003 | expect.objectContaining({
1004 | role: 'research'
1005 | })
1006 | );
1007 | expect(streamObjectService).not.toHaveBeenCalled();
1008 | });
1009 |
1010 | test('should use non-streaming for CLI text mode (streaming disabled)', async () => {
1011 | // Setup mocks to simulate normal conditions
1012 | fs.default.existsSync.mockImplementation((path) => {
1013 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
1014 | if (path === 'tasks') return true; // Directory exists
1015 | return false;
1016 | });
1017 |
1018 | // Call without mcpLog and without reportProgress (CLI text mode)
1019 | const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
1020 |
1021 | // With streaming disabled, should use generateObjectService even in CLI text mode
1022 | expect(generateObjectService).toHaveBeenCalled();
1023 | expect(streamObjectService).not.toHaveBeenCalled();
1024 |
1025 | // Progress tracker components may still be called for CLI mode display
1026 | // but the actual parsing uses non-streaming
1027 |
1028 | expect(result).toEqual({
1029 | success: true,
1030 | tasksPath: 'tasks/tasks.json',
1031 | telemetryData: {}
1032 | });
1033 | });
1034 |
1035 | test.skip('should handle parseStream with usedFallback flag - needs rewrite for streamObject', async () => {
1036 | // Setup mocks to simulate normal conditions
1037 | fs.default.existsSync.mockImplementation((path) => {
1038 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
1039 | if (path === 'tasks') return true; // Directory exists
1040 | return false;
1041 | });
1042 |
1043 | // Mock progress reporting function
1044 | const mockReportProgress = jest.fn(() => Promise.resolve());
1045 |
1046 | // Mock parseStream to return usedFallback: true
1047 | parseStream.mockResolvedValueOnce({
1048 | items: [{ id: 1, title: 'Test Task', priority: 'high' }],
1049 | accumulatedText:
1050 | '{"tasks":[{"id":1,"title":"Test Task","priority":"high"}]}',
1051 | estimatedTokens: 50,
1052 | usedFallback: true // This triggers fallback reporting
1053 | });
1054 |
1055 | // Call with streaming
1056 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
1057 | reportProgress: mockReportProgress
1058 | });
1059 |
1060 | // Verify that usedFallback scenario was handled
1061 | expect(parseStream).toHaveBeenCalledWith(
1062 | expect.anything(),
1063 | expect.objectContaining({
1064 | jsonPaths: ['$.tasks.*'],
1065 | onProgress: expect.any(Function),
1066 | onError: expect.any(Function),
1067 | estimateTokens: expect.any(Function),
1068 | expectedTotal: 3,
1069 | fallbackItemExtractor: expect.any(Function)
1070 | })
1071 | );
1072 | });
1073 |
1074 | test.skip('should handle StreamingError types for fallback - needs rewrite for streamObject', async () => {
1075 | // Setup mocks to simulate normal conditions
1076 | fs.default.existsSync.mockImplementation((path) => {
1077 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
1078 | if (path === 'tasks') return true; // Directory exists
1079 | return false;
1080 | });
1081 |
1082 | // Test different StreamingError types that should trigger fallback
1083 | const streamingErrors = [
1084 | {
1085 | message: 'Stream object is not iterable',
1086 | code: STREAMING_ERROR_CODES.STREAM_NOT_ITERABLE
1087 | },
1088 | {
1089 | message: 'Failed to process AI text stream',
1090 | code: STREAMING_ERROR_CODES.STREAM_PROCESSING_FAILED
1091 | },
1092 | {
1093 | message: 'textStream is not async iterable',
1094 | code: STREAMING_ERROR_CODES.NOT_ASYNC_ITERABLE
1095 | }
1096 | ];
1097 |
1098 | for (const errorConfig of streamingErrors) {
1099 | // Clear mocks for each iteration
1100 | jest.clearAllMocks();
1101 |
1102 | // Setup mocks again
1103 | fs.default.existsSync.mockImplementation((path) => {
1104 | if (path === 'tasks/tasks.json') return false;
1105 | if (path === 'tasks') return true;
1106 | return false;
1107 | });
1108 | fs.default.readFileSync.mockReturnValue(samplePRDContent);
1109 | generateObjectService.mockResolvedValue({
1110 | mainResult: { object: sampleClaudeResponse },
1111 | telemetryData: {}
1112 | });
1113 |
1114 | // Mock streamTextService to fail with StreamingError
1115 | const error = new StreamingError(errorConfig.message, errorConfig.code);
1116 | streamTextService.mockRejectedValueOnce(error);
1117 |
1118 | // Mock progress reporting function
1119 | const mockReportProgress = jest.fn(() => Promise.resolve());
1120 |
1121 | // Call with streaming (should fallback to non-streaming)
1122 | const result = await parsePRD(
1123 | 'path/to/prd.txt',
1124 | 'tasks/tasks.json',
1125 | 3,
1126 | {
1127 | reportProgress: mockReportProgress
1128 | }
1129 | );
1130 |
1131 | // Verify streaming was attempted first
1132 | expect(streamTextService).toHaveBeenCalled();
1133 |
1134 | // Verify fallback to non-streaming occurred
1135 | expect(generateObjectService).toHaveBeenCalled();
1136 |
1137 | // Verify successful result despite streaming failure
1138 | expect(result).toEqual({
1139 | success: true,
1140 | tasksPath: 'tasks/tasks.json',
1141 | telemetryData: {}
1142 | });
1143 | }
1144 | });
1145 |
1146 | test.skip('should handle progress tracker integration in CLI streaming mode - needs rewrite for streamObject', async () => {
1147 | // Setup mocks to simulate normal conditions
1148 | fs.default.existsSync.mockImplementation((path) => {
1149 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
1150 | if (path === 'tasks') return true; // Directory exists
1151 | return false;
1152 | });
1153 |
1154 | // Mock progress tracker methods
1155 | const mockProgressTracker = {
1156 | start: jest.fn(),
1157 | stop: jest.fn(),
1158 | cleanup: jest.fn(),
1159 | addTaskLine: jest.fn(),
1160 | updateTokens: jest.fn(),
1161 | getSummary: jest.fn().mockReturnValue({
1162 | taskPriorities: { high: 1, medium: 0, low: 0 },
1163 | elapsedTime: 1000,
1164 | actionVerb: 'generated'
1165 | })
1166 | };
1167 | createParsePrdTracker.mockReturnValue(mockProgressTracker);
1168 |
1169 | // Call in CLI text mode (no mcpLog, no reportProgress)
1170 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3);
1171 |
1172 | // Verify progress tracker was created and used
1173 | expect(createParsePrdTracker).toHaveBeenCalledWith({
1174 | numUnits: 3,
1175 | unitName: 'task',
1176 | append: false
1177 | });
1178 | expect(mockProgressTracker.start).toHaveBeenCalled();
1179 | expect(mockProgressTracker.cleanup).toHaveBeenCalled();
1180 |
1181 | // Verify UI display functions were called
1182 | expect(displayParsePrdStart).toHaveBeenCalled();
1183 | expect(displayParsePrdSummary).toHaveBeenCalled();
1184 | });
1185 |
1186 | test.skip('should handle onProgress callback during streaming - needs rewrite for streamObject', async () => {
1187 | // Setup mocks to simulate normal conditions
1188 | fs.default.existsSync.mockImplementation((path) => {
1189 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
1190 | if (path === 'tasks') return true; // Directory exists
1191 | return false;
1192 | });
1193 |
1194 | // Mock progress reporting function
1195 | const mockReportProgress = jest.fn(() => Promise.resolve());
1196 |
1197 | // Mock parseStream to call onProgress
1198 | parseStream.mockImplementation(async (stream, options) => {
1199 | // Simulate calling onProgress during parsing
1200 | if (options.onProgress) {
1201 | await options.onProgress(
1202 | { title: 'Test Task', priority: 'high' },
1203 | { currentCount: 1, estimatedTokens: 50 }
1204 | );
1205 | }
1206 | return {
1207 | items: [{ id: 1, title: 'Test Task', priority: 'high' }],
1208 | accumulatedText:
1209 | '{"tasks":[{"id":1,"title":"Test Task","priority":"high"}]}',
1210 | estimatedTokens: 50,
1211 | usedFallback: false
1212 | };
1213 | });
1214 |
1215 | // Call with streaming
1216 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
1217 | reportProgress: mockReportProgress
1218 | });
1219 |
1220 | // Verify parseStream was called with correct onProgress callback
1221 | expect(parseStream).toHaveBeenCalledWith(
1222 | expect.anything(),
1223 | expect.objectContaining({
1224 | onProgress: expect.any(Function)
1225 | })
1226 | );
1227 |
1228 | // Verify progress was reported during streaming
1229 | expect(mockReportProgress).toHaveBeenCalled();
1230 | });
1231 |
1232 | test.skip('should not re-throw non-streaming errors during fallback - needs rewrite for streamObject', async () => {
1233 | // Setup mocks to simulate normal conditions
1234 | fs.default.existsSync.mockImplementation((path) => {
1235 | if (path === 'tasks/tasks.json') return false; // Output file doesn't exist
1236 | if (path === 'tasks') return true; // Directory exists
1237 | return false;
1238 | });
1239 |
1240 | // Mock progress reporting function
1241 | const mockReportProgress = jest.fn(() => Promise.resolve());
1242 |
1243 | // Mock streamTextService to fail with NON-streaming error
1244 | streamTextService.mockRejectedValueOnce(
1245 | new Error('AI API rate limit exceeded')
1246 | );
1247 |
1248 | // Call with streaming - should re-throw non-streaming errors
1249 | await expect(
1250 | parsePRD('path/to/prd.txt', 'tasks/tasks.json', 3, {
1251 | reportProgress: mockReportProgress
1252 | })
1253 | ).rejects.toThrow('AI API rate limit exceeded');
1254 |
1255 | // Verify streaming was attempted
1256 | expect(streamTextService).toHaveBeenCalled();
1257 |
1258 | // Verify fallback was NOT attempted (error was re-thrown)
1259 | expect(generateObjectService).not.toHaveBeenCalled();
1260 | });
1261 | });
1262 |
1263 | describe('Dynamic Task Generation', () => {
1264 | test('should use dynamic prompting when numTasks is 0', async () => {
1265 | // Setup mocks to simulate normal conditions (no existing output file)
1266 | fs.default.existsSync.mockImplementation((p) => {
1267 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
1268 | if (p === 'tasks') return true; // Directory exists
1269 | return false;
1270 | });
1271 |
1272 | // Call the function with numTasks=0 for dynamic generation and mcpLog to force non-streaming mode
1273 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, {
1274 | tag: 'master',
1275 | mcpLog: {
1276 | info: jest.fn(),
1277 | warn: jest.fn(),
1278 | error: jest.fn(),
1279 | debug: jest.fn(),
1280 | success: jest.fn()
1281 | }
1282 | });
1283 |
1284 | // Verify generateObjectService was called
1285 | expect(generateObjectService).toHaveBeenCalled();
1286 |
1287 | // Get the call arguments to verify the prompt
1288 | const callArgs = generateObjectService.mock.calls[0][0];
1289 | expect(callArgs.prompt).toContain('an appropriate number of');
1290 | expect(callArgs.prompt).not.toContain('approximately 0');
1291 | });
1292 |
1293 | test('should use specific count prompting when numTasks is positive', async () => {
1294 | // Setup mocks to simulate normal conditions (no existing output file)
1295 | fs.default.existsSync.mockImplementation((p) => {
1296 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
1297 | if (p === 'tasks') return true; // Directory exists
1298 | return false;
1299 | });
1300 |
1301 | // Call the function with specific numTasks and mcpLog to force non-streaming mode
1302 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 5, {
1303 | tag: 'master',
1304 | mcpLog: {
1305 | info: jest.fn(),
1306 | warn: jest.fn(),
1307 | error: jest.fn(),
1308 | debug: jest.fn(),
1309 | success: jest.fn()
1310 | }
1311 | });
1312 |
1313 | // Verify generateObjectService was called
1314 | expect(generateObjectService).toHaveBeenCalled();
1315 |
1316 | // Get the call arguments to verify the prompt
1317 | const callArgs = generateObjectService.mock.calls[0][0];
1318 | expect(callArgs.prompt).toContain('approximately 5');
1319 | expect(callArgs.prompt).not.toContain('an appropriate number of');
1320 | });
1321 |
1322 | test('should accept 0 as valid numTasks value', async () => {
1323 | // Setup mocks to simulate normal conditions (no existing output file)
1324 | fs.default.existsSync.mockImplementation((p) => {
1325 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
1326 | if (p === 'tasks') return true; // Directory exists
1327 | return false;
1328 | });
1329 |
1330 | // Call the function with numTasks=0 and mcpLog to force non-streaming mode - should not throw error
1331 | const result = await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 0, {
1332 | tag: 'master',
1333 | mcpLog: {
1334 | info: jest.fn(),
1335 | warn: jest.fn(),
1336 | error: jest.fn(),
1337 | debug: jest.fn(),
1338 | success: jest.fn()
1339 | }
1340 | });
1341 |
1342 | // Verify it completed successfully
1343 | expect(result).toEqual({
1344 | success: true,
1345 | tasksPath: 'tasks/tasks.json',
1346 | telemetryData: {}
1347 | });
1348 | });
1349 |
1350 | test('should use dynamic prompting when numTasks is negative (no validation in main module)', async () => {
1351 | // Setup mocks to simulate normal conditions (no existing output file)
1352 | fs.default.existsSync.mockImplementation((p) => {
1353 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
1354 | if (p === 'tasks') return true; // Directory exists
1355 | return false;
1356 | });
1357 |
1358 | // Call the function with negative numTasks and mcpLog to force non-streaming mode
1359 | // Note: The main parse-prd.js module doesn't validate numTasks - validation happens at CLI/MCP level
1360 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', -5, {
1361 | tag: 'master',
1362 | mcpLog: {
1363 | info: jest.fn(),
1364 | warn: jest.fn(),
1365 | error: jest.fn(),
1366 | debug: jest.fn(),
1367 | success: jest.fn()
1368 | }
1369 | });
1370 |
1371 | // Verify generateObjectService was called
1372 | expect(generateObjectService).toHaveBeenCalled();
1373 | const callArgs = generateObjectService.mock.calls[0][0];
1374 | // Negative values are treated as <= 0, so should use dynamic prompting
1375 | expect(callArgs.prompt).toContain('an appropriate number of');
1376 | expect(callArgs.prompt).not.toContain('approximately -5');
1377 | });
1378 | });
1379 |
1380 | describe('Configuration Integration', () => {
1381 | test('should use dynamic prompting when numTasks is null', async () => {
1382 | // Setup mocks to simulate normal conditions (no existing output file)
1383 | fs.default.existsSync.mockImplementation((p) => {
1384 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
1385 | if (p === 'tasks') return true; // Directory exists
1386 | return false;
1387 | });
1388 |
1389 | // Call the function with null numTasks and mcpLog to force non-streaming mode
1390 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', null, {
1391 | tag: 'master',
1392 | mcpLog: {
1393 | info: jest.fn(),
1394 | warn: jest.fn(),
1395 | error: jest.fn(),
1396 | debug: jest.fn(),
1397 | success: jest.fn()
1398 | }
1399 | });
1400 |
1401 | // Verify generateObjectService was called with dynamic prompting
1402 | expect(generateObjectService).toHaveBeenCalled();
1403 | const callArgs = generateObjectService.mock.calls[0][0];
1404 | expect(callArgs.prompt).toContain('an appropriate number of');
1405 | });
1406 |
1407 | test('should use dynamic prompting when numTasks is invalid string', async () => {
1408 | // Setup mocks to simulate normal conditions (no existing output file)
1409 | fs.default.existsSync.mockImplementation((p) => {
1410 | if (p === 'tasks/tasks.json') return false; // Output file doesn't exist
1411 | if (p === 'tasks') return true; // Directory exists
1412 | return false;
1413 | });
1414 |
1415 | // Call the function with invalid numTasks (string that's not a number) and mcpLog to force non-streaming mode
1416 | await parsePRD('path/to/prd.txt', 'tasks/tasks.json', 'invalid', {
1417 | tag: 'master',
1418 | mcpLog: {
1419 | info: jest.fn(),
1420 | warn: jest.fn(),
1421 | error: jest.fn(),
1422 | debug: jest.fn(),
1423 | success: jest.fn()
1424 | }
1425 | });
1426 |
1427 | // Verify generateObjectService was called with dynamic prompting
1428 | // Note: The main module doesn't validate - it just uses the value as-is
1429 | // Since 'invalid' > 0 is false, it uses dynamic prompting
1430 | expect(generateObjectService).toHaveBeenCalled();
1431 | const callArgs = generateObjectService.mock.calls[0][0];
1432 | expect(callArgs.prompt).toContain('an appropriate number of');
1433 | });
1434 | });
1435 | });
1436 |
```
--------------------------------------------------------------------------------
/scripts/modules/task-manager/tag-management.js:
--------------------------------------------------------------------------------
```javascript
1 | import path from 'path';
2 | import fs from 'fs';
3 | import inquirer from 'inquirer';
4 | import chalk from 'chalk';
5 | import boxen from 'boxen';
6 | import Table from 'cli-table3';
7 |
8 | import {
9 | log,
10 | readJSON,
11 | writeJSON,
12 | getCurrentTag,
13 | resolveTag,
14 | getTasksForTag,
15 | setTasksForTag,
16 | findProjectRoot,
17 | truncate
18 | } from '../utils.js';
19 | import { displayBanner, getStatusWithColor } from '../ui.js';
20 | import findNextTask from './find-next-task.js';
21 | import {
22 | tryListTagsViaRemote,
23 | tryUseTagViaRemote,
24 | tryAddTagViaRemote
25 | } from '@tm/bridge';
26 |
27 | /**
28 | * Create a new tag context
29 | * @param {string} tasksPath - Path to the tasks.json file
30 | * @param {string} tagName - Name of the new tag to create
31 | * @param {Object} options - Options object
32 | * @param {boolean} [options.copyFromCurrent=false] - Whether to copy tasks from current tag
33 | * @param {string} [options.copyFromTag] - Specific tag to copy tasks from
34 | * @param {string} [options.description] - Optional description for the tag
35 | * @param {Object} context - Context object containing session and projectRoot
36 | * @param {string} [context.projectRoot] - Project root path
37 | * @param {Object} [context.mcpLog] - MCP logger object (optional)
38 | * @param {string} outputFormat - Output format (text or json)
39 | * @returns {Promise<Object>} Result object with tag creation details
40 | */
41 | async function createTag(
42 | tasksPath,
43 | tagName,
44 | options = {},
45 | context = {},
46 | outputFormat = 'text'
47 | ) {
48 | const { mcpLog, projectRoot } = context;
49 | const { copyFromCurrent = false, copyFromTag, description } = options;
50 |
51 | // Create a consistent logFn object regardless of context
52 | const logFn = mcpLog || {
53 | info: (...args) => log('info', ...args),
54 | warn: (...args) => log('warn', ...args),
55 | error: (...args) => log('error', ...args),
56 | debug: (...args) => log('debug', ...args),
57 | success: (...args) => log('success', ...args)
58 | };
59 |
60 | // Check if API storage should handle this via remote
61 | const remoteResult = await tryAddTagViaRemote({
62 | tagName,
63 | projectRoot: projectRoot || findProjectRoot(),
64 | isMCP: !!mcpLog,
65 | outputFormat,
66 | report: (level, ...args) => logFn[level](...args)
67 | });
68 |
69 | // If remote handled it, return the result
70 | if (remoteResult) {
71 | if (!remoteResult.success) {
72 | throw new Error(remoteResult.message || 'Remote tag creation failed');
73 | }
74 | if (outputFormat === 'json') {
75 | return remoteResult;
76 | }
77 | // For text output, the bridge already displayed the message
78 | return remoteResult;
79 | }
80 |
81 | // Otherwise, continue with file-based logic below
82 | try {
83 | // Validate tag name
84 | if (!tagName || typeof tagName !== 'string') {
85 | throw new Error('Tag name is required and must be a string');
86 | }
87 |
88 | // Validate tag name format (alphanumeric, hyphens, underscores only)
89 | if (!/^[a-zA-Z0-9_-]+$/.test(tagName)) {
90 | throw new Error(
91 | 'Tag name can only contain letters, numbers, hyphens, and underscores'
92 | );
93 | }
94 |
95 | // Reserved tag names
96 | const reservedNames = ['master', 'main', 'default'];
97 | if (reservedNames.includes(tagName.toLowerCase())) {
98 | throw new Error(`"${tagName}" is a reserved tag name`);
99 | }
100 |
101 | logFn.info(`Creating new tag: ${tagName}`);
102 |
103 | // Read current tasks data
104 | const data = readJSON(tasksPath, projectRoot);
105 | if (!data) {
106 | throw new Error(`Could not read tasks file at ${tasksPath}`);
107 | }
108 |
109 | // Use raw tagged data for tag operations - ensure we get the actual tagged structure
110 | let rawData;
111 | if (data._rawTaggedData) {
112 | // If we have _rawTaggedData, use it (this is the clean tagged structure)
113 | rawData = data._rawTaggedData;
114 | } else if (data.tasks && !data.master) {
115 | // This is legacy format - create a master tag structure
116 | rawData = {
117 | master: {
118 | tasks: data.tasks,
119 | metadata: data.metadata || {
120 | created: new Date().toISOString(),
121 | updated: new Date().toISOString(),
122 | description: 'Tasks live here by default'
123 | }
124 | }
125 | };
126 | } else {
127 | // This is already in tagged format, use it directly but exclude internal fields
128 | rawData = {};
129 | for (const [key, value] of Object.entries(data)) {
130 | if (key !== '_rawTaggedData' && key !== 'tag') {
131 | rawData[key] = value;
132 | }
133 | }
134 | }
135 |
136 | // Check if tag already exists
137 | if (rawData[tagName]) {
138 | throw new Error(`Tag "${tagName}" already exists`);
139 | }
140 |
141 | // Determine source for copying tasks (only if explicitly requested)
142 | let sourceTasks = [];
143 | if (copyFromCurrent || copyFromTag) {
144 | const sourceTag = copyFromTag || getCurrentTag(projectRoot);
145 | sourceTasks = getTasksForTag(rawData, sourceTag);
146 |
147 | if (copyFromTag && sourceTasks.length === 0) {
148 | logFn.warn(`Source tag "${copyFromTag}" not found or has no tasks`);
149 | }
150 |
151 | logFn.info(`Copying ${sourceTasks.length} tasks from tag "${sourceTag}"`);
152 | } else {
153 | logFn.info('Creating empty tag (no tasks copied)');
154 | }
155 |
156 | // Create the new tag structure in raw data
157 | rawData[tagName] = {
158 | tasks: [...sourceTasks], // Create a copy of the tasks array
159 | metadata: {
160 | created: new Date().toISOString(),
161 | updated: new Date().toISOString(),
162 | description:
163 | description || `Tag created on ${new Date().toLocaleDateString()}`
164 | }
165 | };
166 |
167 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption)
168 | const cleanData = {};
169 | for (const [key, value] of Object.entries(rawData)) {
170 | if (key !== '_rawTaggedData') {
171 | cleanData[key] = value;
172 | }
173 | }
174 |
175 | // Write the clean data back to file with proper context to avoid tag corruption
176 | writeJSON(tasksPath, cleanData, projectRoot);
177 |
178 | logFn.success(`Successfully created tag "${tagName}"`);
179 |
180 | // For JSON output, return structured data
181 | if (outputFormat === 'json') {
182 | return {
183 | tagName,
184 | created: true,
185 | tasksCopied: sourceTasks.length,
186 | sourceTag:
187 | copyFromCurrent || copyFromTag
188 | ? copyFromTag || getCurrentTag(projectRoot)
189 | : null,
190 | description:
191 | description || `Tag created on ${new Date().toLocaleDateString()}`
192 | };
193 | }
194 |
195 | // For text output, display success message
196 | if (outputFormat === 'text') {
197 | console.log(
198 | boxen(
199 | chalk.green.bold('✓ Tag Created Successfully') +
200 | `\n\nTag Name: ${chalk.cyan(tagName)}` +
201 | `\nTasks Copied: ${chalk.yellow(sourceTasks.length)}` +
202 | (copyFromCurrent || copyFromTag
203 | ? `\nSource Tag: ${chalk.cyan(copyFromTag || getCurrentTag(projectRoot))}`
204 | : '') +
205 | (description ? `\nDescription: ${chalk.gray(description)}` : ''),
206 | {
207 | padding: 1,
208 | borderColor: 'green',
209 | borderStyle: 'round',
210 | margin: { top: 1, bottom: 1 }
211 | }
212 | )
213 | );
214 | }
215 |
216 | return {
217 | tagName,
218 | created: true,
219 | tasksCopied: sourceTasks.length,
220 | sourceTag:
221 | copyFromCurrent || copyFromTag
222 | ? copyFromTag || getCurrentTag(projectRoot)
223 | : null,
224 | description:
225 | description || `Tag created on ${new Date().toLocaleDateString()}`
226 | };
227 | } catch (error) {
228 | logFn.error(`Error creating tag: ${error.message}`);
229 | throw error;
230 | }
231 | }
232 |
233 | /**
234 | * Delete an existing tag
235 | * @param {string} tasksPath - Path to the tasks.json file
236 | * @param {string} tagName - Name of the tag to delete
237 | * @param {Object} options - Options object
238 | * @param {boolean} [options.yes=false] - Skip confirmation prompts
239 | * @param {Object} context - Context object containing session and projectRoot
240 | * @param {string} [context.projectRoot] - Project root path
241 | * @param {Object} [context.mcpLog] - MCP logger object (optional)
242 | * @param {string} outputFormat - Output format (text or json)
243 | * @returns {Promise<Object>} Result object with deletion details
244 | */
245 | async function deleteTag(
246 | tasksPath,
247 | tagName,
248 | options = {},
249 | context = {},
250 | outputFormat = 'text'
251 | ) {
252 | const { mcpLog, projectRoot } = context;
253 | const { yes = false } = options;
254 |
255 | // Create a consistent logFn object regardless of context
256 | const logFn = mcpLog || {
257 | info: (...args) => log('info', ...args),
258 | warn: (...args) => log('warn', ...args),
259 | error: (...args) => log('error', ...args),
260 | debug: (...args) => log('debug', ...args),
261 | success: (...args) => log('success', ...args)
262 | };
263 |
264 | try {
265 | // Validate tag name
266 | if (!tagName || typeof tagName !== 'string') {
267 | throw new Error('Tag name is required and must be a string');
268 | }
269 |
270 | // Prevent deletion of master tag
271 | if (tagName === 'master') {
272 | throw new Error('Cannot delete the "master" tag');
273 | }
274 |
275 | logFn.info(`Deleting tag: ${tagName}`);
276 |
277 | // Read current tasks data
278 | const data = readJSON(tasksPath, projectRoot);
279 | if (!data) {
280 | throw new Error(`Could not read tasks file at ${tasksPath}`);
281 | }
282 |
283 | // Use raw tagged data for tag operations - ensure we get the actual tagged structure
284 | let rawData;
285 | if (data._rawTaggedData) {
286 | // If we have _rawTaggedData, use it (this is the clean tagged structure)
287 | rawData = data._rawTaggedData;
288 | } else if (data.tasks && !data.master) {
289 | // This is legacy format - create a master tag structure
290 | rawData = {
291 | master: {
292 | tasks: data.tasks,
293 | metadata: data.metadata || {
294 | created: new Date().toISOString(),
295 | updated: new Date().toISOString(),
296 | description: 'Tasks live here by default'
297 | }
298 | }
299 | };
300 | } else {
301 | // This is already in tagged format, use it directly but exclude internal fields
302 | rawData = {};
303 | for (const [key, value] of Object.entries(data)) {
304 | if (key !== '_rawTaggedData' && key !== 'tag') {
305 | rawData[key] = value;
306 | }
307 | }
308 | }
309 |
310 | // Check if tag exists
311 | if (!rawData[tagName]) {
312 | throw new Error(`Tag "${tagName}" does not exist`);
313 | }
314 |
315 | // Get current tag to check if we're deleting the active tag
316 | const currentTag = getCurrentTag(projectRoot);
317 | const isCurrentTag = currentTag === tagName;
318 |
319 | // Get task count for confirmation
320 | const tasks = getTasksForTag(rawData, tagName);
321 | const taskCount = tasks.length;
322 |
323 | // If not forced and has tasks, require confirmation (for CLI)
324 | if (!yes && taskCount > 0 && outputFormat === 'text') {
325 | console.log(
326 | boxen(
327 | chalk.yellow.bold('⚠ WARNING: Tag Deletion') +
328 | `\n\nYou are about to delete tag "${chalk.cyan(tagName)}"` +
329 | `\nThis will permanently delete ${chalk.red.bold(taskCount)} tasks` +
330 | '\n\nThis action cannot be undone!',
331 | {
332 | padding: 1,
333 | borderColor: 'yellow',
334 | borderStyle: 'round',
335 | margin: { top: 1, bottom: 1 }
336 | }
337 | )
338 | );
339 |
340 | // First confirmation
341 | const firstConfirm = await inquirer.prompt([
342 | {
343 | type: 'confirm',
344 | name: 'proceed',
345 | message: `Are you sure you want to delete tag "${tagName}" and its ${taskCount} tasks?`,
346 | default: false
347 | }
348 | ]);
349 |
350 | if (!firstConfirm.proceed) {
351 | logFn.info('Tag deletion cancelled by user');
352 | throw new Error('Tag deletion cancelled');
353 | }
354 |
355 | // Second confirmation (double-check)
356 | const secondConfirm = await inquirer.prompt([
357 | {
358 | type: 'input',
359 | name: 'tagNameConfirm',
360 | message: `To confirm deletion, please type the tag name "${tagName}":`,
361 | validate: (input) => {
362 | if (input === tagName) {
363 | return true;
364 | }
365 | return `Please type exactly "${tagName}" to confirm deletion`;
366 | }
367 | }
368 | ]);
369 |
370 | if (secondConfirm.tagNameConfirm !== tagName) {
371 | logFn.info('Tag deletion cancelled - incorrect tag name confirmation');
372 | throw new Error('Tag deletion cancelled');
373 | }
374 |
375 | logFn.info('Double confirmation received, proceeding with deletion...');
376 | }
377 |
378 | // Delete the tag
379 | delete rawData[tagName];
380 |
381 | // If we're deleting the current tag, switch to master
382 | if (isCurrentTag) {
383 | await switchCurrentTag(projectRoot, 'master');
384 | logFn.info('Switched current tag to "master"');
385 | }
386 |
387 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption)
388 | const cleanData = {};
389 | for (const [key, value] of Object.entries(rawData)) {
390 | if (key !== '_rawTaggedData') {
391 | cleanData[key] = value;
392 | }
393 | }
394 |
395 | // Write the clean data back to file with proper context to avoid tag corruption
396 | writeJSON(tasksPath, cleanData, projectRoot);
397 |
398 | logFn.success(`Successfully deleted tag "${tagName}"`);
399 |
400 | // For JSON output, return structured data
401 | if (outputFormat === 'json') {
402 | return {
403 | tagName,
404 | deleted: true,
405 | tasksDeleted: taskCount,
406 | wasCurrentTag: isCurrentTag,
407 | switchedToMaster: isCurrentTag
408 | };
409 | }
410 |
411 | // For text output, display success message
412 | if (outputFormat === 'text') {
413 | console.log(
414 | boxen(
415 | chalk.red.bold('✓ Tag Deleted Successfully') +
416 | `\n\nTag Name: ${chalk.cyan(tagName)}` +
417 | `\nTasks Deleted: ${chalk.yellow(taskCount)}` +
418 | (isCurrentTag
419 | ? `\n${chalk.yellow('⚠ Switched current tag to "master"')}`
420 | : ''),
421 | {
422 | padding: 1,
423 | borderColor: 'red',
424 | borderStyle: 'round',
425 | margin: { top: 1, bottom: 1 }
426 | }
427 | )
428 | );
429 | }
430 |
431 | return {
432 | tagName,
433 | deleted: true,
434 | tasksDeleted: taskCount,
435 | wasCurrentTag: isCurrentTag,
436 | switchedToMaster: isCurrentTag
437 | };
438 | } catch (error) {
439 | logFn.error(`Error deleting tag: ${error.message}`);
440 | throw error;
441 | }
442 | }
443 |
444 | /**
445 | * Enhance existing tags with metadata if they don't have it
446 | * @param {string} tasksPath - Path to the tasks.json file
447 | * @param {Object} rawData - The raw tagged data
448 | * @param {Object} context - Context object
449 | * @returns {Promise<boolean>} True if any tags were enhanced
450 | */
451 | async function enhanceTagsWithMetadata(tasksPath, rawData, context = {}) {
452 | let enhanced = false;
453 |
454 | try {
455 | // Get file stats for creation date fallback
456 | let fileCreatedDate;
457 | try {
458 | const stats = fs.statSync(tasksPath);
459 | fileCreatedDate =
460 | stats.birthtime < stats.mtime ? stats.birthtime : stats.mtime;
461 | } catch (error) {
462 | fileCreatedDate = new Date();
463 | }
464 |
465 | for (const [tagName, tagData] of Object.entries(rawData)) {
466 | // Skip non-tag properties
467 | if (
468 | tagName === 'tasks' ||
469 | tagName === 'tag' ||
470 | tagName === '_rawTaggedData' ||
471 | !tagData ||
472 | typeof tagData !== 'object' ||
473 | !Array.isArray(tagData.tasks)
474 | ) {
475 | continue;
476 | }
477 |
478 | // Check if tag needs metadata enhancement
479 | if (!tagData.metadata) {
480 | tagData.metadata = {};
481 | enhanced = true;
482 | }
483 |
484 | // Add missing metadata fields
485 | if (!tagData.metadata.created) {
486 | tagData.metadata.created = fileCreatedDate.toISOString();
487 | enhanced = true;
488 | }
489 |
490 | if (!tagData.metadata.description) {
491 | if (tagName === 'master') {
492 | tagData.metadata.description = 'Tasks live here by default';
493 | } else {
494 | tagData.metadata.description = `Tag created on ${new Date(tagData.metadata.created).toLocaleDateString()}`;
495 | }
496 | enhanced = true;
497 | }
498 |
499 | // Add updated field if missing (set to created date initially)
500 | if (!tagData.metadata.updated) {
501 | tagData.metadata.updated = tagData.metadata.created;
502 | enhanced = true;
503 | }
504 | }
505 |
506 | // If we enhanced any tags, write the data back
507 | if (enhanced) {
508 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption)
509 | const cleanData = {};
510 | for (const [key, value] of Object.entries(rawData)) {
511 | if (key !== '_rawTaggedData') {
512 | cleanData[key] = value;
513 | }
514 | }
515 | writeJSON(tasksPath, cleanData, context.projectRoot);
516 | }
517 | } catch (error) {
518 | // Don't throw - just log and continue
519 | const logFn = context.mcpLog || {
520 | warn: (...args) => log('warn', ...args)
521 | };
522 | logFn.warn(`Could not enhance tag metadata: ${error.message}`);
523 | }
524 |
525 | return enhanced;
526 | }
527 |
528 | /**
529 | * List all available tags with metadata
530 | * @param {string} tasksPath - Path to the tasks.json file
531 | * @param {Object} options - Options object
532 | * @param {boolean} [options.showTaskCounts=true] - Whether to show task counts
533 | * @param {boolean} [options.showMetadata=false] - Whether to show metadata
534 | * @param {Object} context - Context object containing session and projectRoot
535 | * @param {string} [context.projectRoot] - Project root path
536 | * @param {Object} [context.mcpLog] - MCP logger object (optional)
537 | * @param {string} outputFormat - Output format (text or json)
538 | * @returns {Promise<Object>} Result object with tags list
539 | */
540 | async function tags(
541 | tasksPath,
542 | options = {},
543 | context = {},
544 | outputFormat = 'text'
545 | ) {
546 | const { mcpLog, projectRoot } = context;
547 | const { showTaskCounts = true, showMetadata = false } = options;
548 |
549 | // Create a consistent logFn object regardless of context
550 | const logFn = mcpLog || {
551 | info: (...args) => log('info', ...args),
552 | warn: (...args) => log('warn', ...args),
553 | error: (...args) => log('error', ...args),
554 | debug: (...args) => log('debug', ...args),
555 | success: (...args) => log('success', ...args)
556 | };
557 |
558 | try {
559 | logFn.info('Listing available tags');
560 |
561 | // Try API storage first via bridge
562 | const bridgeResult = await tryListTagsViaRemote({
563 | projectRoot,
564 | showMetadata,
565 | isMCP: !!mcpLog,
566 | outputFormat,
567 | report: (level, ...args) => {
568 | if (logFn[level]) {
569 | logFn[level](...args);
570 | } else {
571 | logFn.info(...args);
572 | }
573 | }
574 | });
575 |
576 | // If bridge handled it (API storage), return the result
577 | if (bridgeResult) {
578 | logFn.success(`Found ${bridgeResult.totalTags} tags via API storage`);
579 | return {
580 | tags: bridgeResult.tags,
581 | currentTag: bridgeResult.currentTag,
582 | totalTags: bridgeResult.totalTags
583 | };
584 | }
585 |
586 | // Fall through to file storage logic
587 | logFn.info('Using file storage for tags');
588 |
589 | // Read current tasks data
590 | const data = readJSON(tasksPath, projectRoot);
591 | if (!data) {
592 | throw new Error(`Could not read tasks file at ${tasksPath}`);
593 | }
594 |
595 | // Get current tag
596 | const currentTag = getCurrentTag(projectRoot);
597 |
598 | // Use raw tagged data if available, otherwise use the data directly
599 | const rawData = data._rawTaggedData || data;
600 |
601 | // Enhance existing tags with metadata if they don't have it
602 | await enhanceTagsWithMetadata(tasksPath, rawData, context);
603 |
604 | // Extract all tags
605 | const tagList = [];
606 | for (const [tagName, tagData] of Object.entries(rawData)) {
607 | // Skip non-tag properties (like legacy 'tasks' array, 'tag', '_rawTaggedData')
608 | if (
609 | tagName === 'tasks' ||
610 | tagName === 'tag' ||
611 | tagName === '_rawTaggedData' ||
612 | !tagData ||
613 | typeof tagData !== 'object' ||
614 | !Array.isArray(tagData.tasks)
615 | ) {
616 | continue;
617 | }
618 |
619 | const tasks = tagData.tasks || [];
620 | const metadata = tagData.metadata || {};
621 |
622 | tagList.push({
623 | name: tagName,
624 | isCurrent: tagName === currentTag,
625 | completedTasks: tasks.filter(
626 | (t) => t.status === 'done' || t.status === 'completed'
627 | ).length,
628 | tasks: tasks || [],
629 | created: metadata.created || 'Unknown',
630 | description: metadata.description || 'No description'
631 | });
632 | }
633 |
634 | // Sort tags: current tag first, then alphabetically
635 | tagList.sort((a, b) => {
636 | if (a.isCurrent) return -1;
637 | if (b.isCurrent) return 1;
638 | return a.name.localeCompare(b.name);
639 | });
640 |
641 | logFn.success(`Found ${tagList.length} tags`);
642 |
643 | // For JSON output, return structured data
644 | if (outputFormat === 'json') {
645 | return {
646 | tags: tagList,
647 | currentTag,
648 | totalTags: tagList.length
649 | };
650 | }
651 |
652 | // For text output, display formatted table
653 | if (outputFormat === 'text') {
654 | if (tagList.length === 0) {
655 | console.log(
656 | boxen(chalk.yellow('No tags found'), {
657 | padding: 1,
658 | borderColor: 'yellow',
659 | borderStyle: 'round',
660 | margin: { top: 1, bottom: 1 }
661 | })
662 | );
663 | return { tags: [], currentTag, totalTags: 0 };
664 | }
665 |
666 | // Create table headers based on options
667 | const headers = [chalk.cyan.bold('Tag Name')];
668 | if (showTaskCounts) {
669 | headers.push(chalk.cyan.bold('Tasks'));
670 | headers.push(chalk.cyan.bold('Completed'));
671 | }
672 | if (showMetadata) {
673 | headers.push(chalk.cyan.bold('Created'));
674 | headers.push(chalk.cyan.bold('Description'));
675 | }
676 |
677 | // Calculate dynamic column widths based on terminal width
678 | const terminalWidth = Math.max(process.stdout.columns || 120, 80);
679 | const usableWidth = Math.floor(terminalWidth * 0.95);
680 |
681 | let colWidths;
682 | if (showMetadata) {
683 | // With metadata: Tag Name, Tasks, Completed, Created, Description
684 | const widths = [0.25, 0.1, 0.12, 0.15, 0.38];
685 | colWidths = widths.map((w, i) =>
686 | Math.max(Math.floor(usableWidth * w), i === 0 ? 15 : 8)
687 | );
688 | } else {
689 | // Without metadata: Tag Name, Tasks, Completed
690 | const widths = [0.7, 0.15, 0.15];
691 | colWidths = widths.map((w, i) =>
692 | Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 10)
693 | );
694 | }
695 |
696 | const table = new Table({
697 | head: headers,
698 | colWidths: colWidths,
699 | wordWrap: true
700 | });
701 |
702 | // Add rows
703 | tagList.forEach((tag) => {
704 | const row = [];
705 |
706 | // Tag name with current indicator
707 | const tagDisplay = tag.isCurrent
708 | ? `${chalk.green('●')} ${chalk.green.bold(tag.name)} ${chalk.gray('(current)')}`
709 | : ` ${tag.name}`;
710 | row.push(tagDisplay);
711 |
712 | if (showTaskCounts) {
713 | row.push(chalk.white(tag.tasks.length.toString()));
714 | row.push(chalk.green(tag.completedTasks.toString()));
715 | }
716 |
717 | if (showMetadata) {
718 | const createdDate =
719 | tag.created !== 'Unknown'
720 | ? new Date(tag.created).toLocaleDateString()
721 | : 'Unknown';
722 | row.push(chalk.gray(createdDate));
723 | row.push(chalk.gray(truncate(tag.description, 50)));
724 | }
725 |
726 | table.push(row);
727 | });
728 |
729 | // console.log(
730 | // boxen(
731 | // chalk.white.bold('Available Tags') +
732 | // `\n\nCurrent Tag: ${chalk.green.bold(currentTag)}`,
733 | // {
734 | // padding: { top: 0, bottom: 1, left: 1, right: 1 },
735 | // borderColor: 'blue',
736 | // borderStyle: 'round',
737 | // margin: { top: 1, bottom: 0 }
738 | // }
739 | // )
740 | // );
741 |
742 | console.log(table.toString());
743 | }
744 |
745 | return {
746 | tags: tagList,
747 | currentTag,
748 | totalTags: tagList.length
749 | };
750 | } catch (error) {
751 | logFn.error(`Error listing tags: ${error.message}`);
752 | throw error;
753 | }
754 | }
755 |
756 | /**
757 | * Switch to a different tag context
758 | * @param {string} tasksPath - Path to the tasks.json file
759 | * @param {string} tagName - Name of the tag to switch to
760 | * @param {Object} options - Options object
761 | * @param {Object} context - Context object containing session and projectRoot
762 | * @param {string} [context.projectRoot] - Project root path
763 | * @param {Object} [context.mcpLog] - MCP logger object (optional)
764 | * @param {string} outputFormat - Output format (text or json)
765 | * @returns {Promise<Object>} Result object with switch details
766 | */
767 | async function useTag(
768 | tasksPath,
769 | tagName,
770 | options = {},
771 | context = {},
772 | outputFormat = 'text'
773 | ) {
774 | const { mcpLog, projectRoot } = context;
775 |
776 | // Create a consistent logFn object regardless of context
777 | const logFn = mcpLog || {
778 | info: (...args) => log('info', ...args),
779 | warn: (...args) => log('warn', ...args),
780 | error: (...args) => log('error', ...args),
781 | debug: (...args) => log('debug', ...args),
782 | success: (...args) => log('success', ...args)
783 | };
784 |
785 | try {
786 | // Validate tag name
787 | if (!tagName || typeof tagName !== 'string') {
788 | throw new Error('Tag name is required and must be a string');
789 | }
790 |
791 | logFn.info(`Switching to tag: ${tagName}`);
792 |
793 | // Try API storage first via bridge
794 | const bridgeResult = await tryUseTagViaRemote({
795 | tagName,
796 | projectRoot,
797 | isMCP: !!mcpLog,
798 | outputFormat,
799 | report: (level, ...args) => {
800 | if (logFn[level]) {
801 | logFn[level](...args);
802 | } else {
803 | logFn.info(...args);
804 | }
805 | }
806 | });
807 |
808 | // If bridge handled it (API storage), return the result
809 | if (bridgeResult) {
810 | logFn.success(
811 | `Successfully switched to tag "${tagName}" via API storage`
812 | );
813 | return bridgeResult;
814 | }
815 |
816 | // Fall through to file storage logic
817 | logFn.info('Using file storage for tag switch');
818 |
819 | // Read current tasks data to verify tag exists
820 | const data = readJSON(tasksPath, projectRoot);
821 | if (!data) {
822 | throw new Error(`Could not read tasks file at ${tasksPath}`);
823 | }
824 |
825 | // Use raw tagged data to check if tag exists
826 | const rawData = data._rawTaggedData || data;
827 |
828 | // Check if tag exists
829 | if (!rawData[tagName]) {
830 | throw new Error(`Tag "${tagName}" does not exist`);
831 | }
832 |
833 | // Get current tag
834 | const previousTag = getCurrentTag(projectRoot);
835 |
836 | // Switch to the new tag
837 | await switchCurrentTag(projectRoot, tagName);
838 |
839 | // Get task count for the new tag - read tasks specifically for this tag
840 | const tagData = readJSON(tasksPath, projectRoot, tagName);
841 | const tasks = tagData ? tagData.tasks || [] : [];
842 | const taskCount = tasks.length;
843 |
844 | // Find the next task to work on in this tag
845 | const nextTask = findNextTask(tasks);
846 |
847 | logFn.success(`Successfully switched to tag "${tagName}"`);
848 |
849 | // For JSON output, return structured data
850 | if (outputFormat === 'json') {
851 | return {
852 | previousTag,
853 | currentTag: tagName,
854 | switched: true,
855 | taskCount,
856 | nextTask
857 | };
858 | }
859 |
860 | // For text output, display success message
861 | if (outputFormat === 'text') {
862 | let nextTaskInfo = '';
863 | if (nextTask) {
864 | nextTaskInfo = `\nNext Task: ${chalk.cyan(`#${nextTask.id}`)} - ${chalk.white(nextTask.title)}`;
865 | } else {
866 | nextTaskInfo = `\nNext Task: ${chalk.gray('No eligible tasks available')}`;
867 | }
868 |
869 | console.log(
870 | boxen(
871 | chalk.green.bold('✓ Tag Switched Successfully') +
872 | `\n\nPrevious Tag: ${chalk.cyan(previousTag)}` +
873 | `\nCurrent Tag: ${chalk.green.bold(tagName)}` +
874 | `\nAvailable Tasks: ${chalk.yellow(taskCount)}` +
875 | nextTaskInfo,
876 | {
877 | padding: 1,
878 | borderColor: 'green',
879 | borderStyle: 'round',
880 | margin: { top: 1, bottom: 1 }
881 | }
882 | )
883 | );
884 | }
885 |
886 | return {
887 | previousTag,
888 | currentTag: tagName,
889 | switched: true,
890 | taskCount,
891 | nextTask
892 | };
893 | } catch (error) {
894 | logFn.error(`Error switching tag: ${error.message}`);
895 | throw error;
896 | }
897 | }
898 |
899 | /**
900 | * Rename an existing tag
901 | * @param {string} tasksPath - Path to the tasks.json file
902 | * @param {string} oldName - Current name of the tag
903 | * @param {string} newName - New name for the tag
904 | * @param {Object} options - Options object
905 | * @param {Object} context - Context object containing session and projectRoot
906 | * @param {string} [context.projectRoot] - Project root path
907 | * @param {Object} [context.mcpLog] - MCP logger object (optional)
908 | * @param {string} outputFormat - Output format (text or json)
909 | * @returns {Promise<Object>} Result object with rename details
910 | */
911 | async function renameTag(
912 | tasksPath,
913 | oldName,
914 | newName,
915 | options = {},
916 | context = {},
917 | outputFormat = 'text'
918 | ) {
919 | const { mcpLog, projectRoot } = context;
920 |
921 | // Create a consistent logFn object regardless of context
922 | const logFn = mcpLog || {
923 | info: (...args) => log('info', ...args),
924 | warn: (...args) => log('warn', ...args),
925 | error: (...args) => log('error', ...args),
926 | debug: (...args) => log('debug', ...args),
927 | success: (...args) => log('success', ...args)
928 | };
929 |
930 | try {
931 | // Validate parameters
932 | if (!oldName || typeof oldName !== 'string') {
933 | throw new Error('Old tag name is required and must be a string');
934 | }
935 | if (!newName || typeof newName !== 'string') {
936 | throw new Error('New tag name is required and must be a string');
937 | }
938 |
939 | // Validate new tag name format
940 | if (!/^[a-zA-Z0-9_-]+$/.test(newName)) {
941 | throw new Error(
942 | 'New tag name can only contain letters, numbers, hyphens, and underscores'
943 | );
944 | }
945 |
946 | // Prevent renaming master tag
947 | if (oldName === 'master') {
948 | throw new Error('Cannot rename the "master" tag');
949 | }
950 |
951 | // Reserved tag names
952 | const reservedNames = ['master', 'main', 'default'];
953 | if (reservedNames.includes(newName.toLowerCase())) {
954 | throw new Error(`"${newName}" is a reserved tag name`);
955 | }
956 |
957 | logFn.info(`Renaming tag from "${oldName}" to "${newName}"`);
958 |
959 | // Read current tasks data
960 | const data = readJSON(tasksPath, projectRoot);
961 | if (!data) {
962 | throw new Error(`Could not read tasks file at ${tasksPath}`);
963 | }
964 |
965 | // Use raw tagged data for tag operations
966 | const rawData = data._rawTaggedData || data;
967 |
968 | // Check if old tag exists
969 | if (!rawData[oldName]) {
970 | throw new Error(`Tag "${oldName}" does not exist`);
971 | }
972 |
973 | // Check if new tag name already exists
974 | if (rawData[newName]) {
975 | throw new Error(`Tag "${newName}" already exists`);
976 | }
977 |
978 | // Get current tag to check if we're renaming the active tag
979 | const currentTag = getCurrentTag(projectRoot);
980 | const isCurrentTag = currentTag === oldName;
981 |
982 | // Rename the tag by copying data and deleting old
983 | rawData[newName] = { ...rawData[oldName] };
984 |
985 | // Update metadata if it exists
986 | if (rawData[newName].metadata) {
987 | rawData[newName].metadata.renamed = {
988 | from: oldName,
989 | date: new Date().toISOString()
990 | };
991 | }
992 |
993 | delete rawData[oldName];
994 |
995 | // If we're renaming the current tag, update the current tag reference
996 | if (isCurrentTag) {
997 | await switchCurrentTag(projectRoot, newName);
998 | logFn.info(`Updated current tag reference to "${newName}"`);
999 | }
1000 |
1001 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption)
1002 | const cleanData = {};
1003 | for (const [key, value] of Object.entries(rawData)) {
1004 | if (key !== '_rawTaggedData') {
1005 | cleanData[key] = value;
1006 | }
1007 | }
1008 |
1009 | // Write the clean data back to file with proper context to avoid tag corruption
1010 | writeJSON(tasksPath, cleanData, projectRoot);
1011 |
1012 | // Get task count
1013 | const tasks = getTasksForTag(rawData, newName);
1014 | const taskCount = tasks.length;
1015 |
1016 | logFn.success(`Successfully renamed tag from "${oldName}" to "${newName}"`);
1017 |
1018 | // For JSON output, return structured data
1019 | if (outputFormat === 'json') {
1020 | return {
1021 | oldName,
1022 | newName,
1023 | renamed: true,
1024 | taskCount,
1025 | wasCurrentTag: isCurrentTag,
1026 | isCurrentTag: isCurrentTag
1027 | };
1028 | }
1029 |
1030 | // For text output, display success message
1031 | if (outputFormat === 'text') {
1032 | console.log(
1033 | boxen(
1034 | chalk.green.bold('✓ Tag Renamed Successfully') +
1035 | `\n\nOld Name: ${chalk.cyan(oldName)}` +
1036 | `\nNew Name: ${chalk.green.bold(newName)}` +
1037 | `\nTasks: ${chalk.yellow(taskCount)}` +
1038 | (isCurrentTag ? `\n${chalk.green('✓ Current tag updated')}` : ''),
1039 | {
1040 | padding: 1,
1041 | borderColor: 'green',
1042 | borderStyle: 'round',
1043 | margin: { top: 1, bottom: 1 }
1044 | }
1045 | )
1046 | );
1047 | }
1048 |
1049 | return {
1050 | oldName,
1051 | newName,
1052 | renamed: true,
1053 | taskCount,
1054 | wasCurrentTag: isCurrentTag,
1055 | isCurrentTag: isCurrentTag
1056 | };
1057 | } catch (error) {
1058 | logFn.error(`Error renaming tag: ${error.message}`);
1059 | throw error;
1060 | }
1061 | }
1062 |
1063 | /**
1064 | * Copy an existing tag to create a new tag with the same tasks
1065 | * @param {string} tasksPath - Path to the tasks.json file
1066 | * @param {string} sourceName - Name of the source tag to copy from
1067 | * @param {string} targetName - Name of the new tag to create
1068 | * @param {Object} options - Options object
1069 | * @param {string} [options.description] - Optional description for the new tag
1070 | * @param {Object} context - Context object containing session and projectRoot
1071 | * @param {string} [context.projectRoot] - Project root path
1072 | * @param {Object} [context.mcpLog] - MCP logger object (optional)
1073 | * @param {string} outputFormat - Output format (text or json)
1074 | * @returns {Promise<Object>} Result object with copy details
1075 | */
1076 | async function copyTag(
1077 | tasksPath,
1078 | sourceName,
1079 | targetName,
1080 | options = {},
1081 | context = {},
1082 | outputFormat = 'text'
1083 | ) {
1084 | const { mcpLog, projectRoot } = context;
1085 | const { description } = options;
1086 |
1087 | // Create a consistent logFn object regardless of context
1088 | const logFn = mcpLog || {
1089 | info: (...args) => log('info', ...args),
1090 | warn: (...args) => log('warn', ...args),
1091 | error: (...args) => log('error', ...args),
1092 | debug: (...args) => log('debug', ...args),
1093 | success: (...args) => log('success', ...args)
1094 | };
1095 |
1096 | try {
1097 | // Validate parameters
1098 | if (!sourceName || typeof sourceName !== 'string') {
1099 | throw new Error('Source tag name is required and must be a string');
1100 | }
1101 | if (!targetName || typeof targetName !== 'string') {
1102 | throw new Error('Target tag name is required and must be a string');
1103 | }
1104 |
1105 | // Validate target tag name format
1106 | if (!/^[a-zA-Z0-9_-]+$/.test(targetName)) {
1107 | throw new Error(
1108 | 'Target tag name can only contain letters, numbers, hyphens, and underscores'
1109 | );
1110 | }
1111 |
1112 | // Reserved tag names
1113 | const reservedNames = ['master', 'main', 'default'];
1114 | if (reservedNames.includes(targetName.toLowerCase())) {
1115 | throw new Error(`"${targetName}" is a reserved tag name`);
1116 | }
1117 |
1118 | logFn.info(`Copying tag from "${sourceName}" to "${targetName}"`);
1119 |
1120 | // Read current tasks data
1121 | const data = readJSON(tasksPath, projectRoot);
1122 | if (!data) {
1123 | throw new Error(`Could not read tasks file at ${tasksPath}`);
1124 | }
1125 |
1126 | // Use raw tagged data for tag operations
1127 | const rawData = data._rawTaggedData || data;
1128 |
1129 | // Check if source tag exists
1130 | if (!rawData[sourceName]) {
1131 | throw new Error(`Source tag "${sourceName}" does not exist`);
1132 | }
1133 |
1134 | // Check if target tag already exists
1135 | if (rawData[targetName]) {
1136 | throw new Error(`Target tag "${targetName}" already exists`);
1137 | }
1138 |
1139 | // Get source tasks
1140 | const sourceTasks = getTasksForTag(rawData, sourceName);
1141 |
1142 | // Create deep copy of the source tag data
1143 | rawData[targetName] = {
1144 | tasks: JSON.parse(JSON.stringify(sourceTasks)), // Deep copy tasks
1145 | metadata: {
1146 | created: new Date().toISOString(),
1147 | updated: new Date().toISOString(),
1148 | description:
1149 | description ||
1150 | `Copy of "${sourceName}" created on ${new Date().toLocaleDateString()}`,
1151 | copiedFrom: {
1152 | tag: sourceName,
1153 | date: new Date().toISOString()
1154 | }
1155 | }
1156 | };
1157 |
1158 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption)
1159 | const cleanData = {};
1160 | for (const [key, value] of Object.entries(rawData)) {
1161 | if (key !== '_rawTaggedData') {
1162 | cleanData[key] = value;
1163 | }
1164 | }
1165 |
1166 | // Write the clean data back to file with proper context to avoid tag corruption
1167 | writeJSON(tasksPath, cleanData, projectRoot);
1168 |
1169 | logFn.success(
1170 | `Successfully copied tag from "${sourceName}" to "${targetName}"`
1171 | );
1172 |
1173 | // For JSON output, return structured data
1174 | if (outputFormat === 'json') {
1175 | return {
1176 | sourceName,
1177 | targetName,
1178 | copied: true,
1179 | description:
1180 | description ||
1181 | `Copy of "${sourceName}" created on ${new Date().toLocaleDateString()}`
1182 | };
1183 | }
1184 |
1185 | // For text output, display success message
1186 | if (outputFormat === 'text') {
1187 | console.log(
1188 | boxen(
1189 | chalk.green.bold('✓ Tag Copied Successfully') +
1190 | `\n\nSource Tag: ${chalk.cyan(sourceName)}` +
1191 | `\nTarget Tag: ${chalk.green.bold(targetName)}` +
1192 | `\nTasks Copied: ${chalk.yellow(sourceTasks.length)}` +
1193 | (description ? `\nDescription: ${chalk.gray(description)}` : ''),
1194 | {
1195 | padding: 1,
1196 | borderColor: 'green',
1197 | borderStyle: 'round',
1198 | margin: { top: 1, bottom: 1 }
1199 | }
1200 | )
1201 | );
1202 | }
1203 |
1204 | return {
1205 | sourceName,
1206 | targetName,
1207 | copied: true,
1208 | description:
1209 | description ||
1210 | `Copy of "${sourceName}" created on ${new Date().toLocaleDateString()}`
1211 | };
1212 | } catch (error) {
1213 | logFn.error(`Error copying tag: ${error.message}`);
1214 | throw error;
1215 | }
1216 | }
1217 |
1218 | /**
1219 | * Helper function to switch the current tag in state.json
1220 | * @param {string} projectRoot - Project root directory
1221 | * @param {string} tagName - Name of the tag to switch to
1222 | * @returns {Promise<void>}
1223 | */
1224 | async function switchCurrentTag(projectRoot, tagName) {
1225 | try {
1226 | const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
1227 |
1228 | // Read current state or create default
1229 | let state = {};
1230 | if (fs.existsSync(statePath)) {
1231 | const rawState = fs.readFileSync(statePath, 'utf8');
1232 | state = JSON.parse(rawState);
1233 | }
1234 |
1235 | // Update current tag and timestamp
1236 | state.currentTag = tagName;
1237 | state.lastSwitched = new Date().toISOString();
1238 |
1239 | // Ensure other required state properties exist
1240 | if (!state.branchTagMapping) {
1241 | state.branchTagMapping = {};
1242 | }
1243 | if (state.migrationNoticeShown === undefined) {
1244 | state.migrationNoticeShown = false;
1245 | }
1246 |
1247 | // Write updated state
1248 | fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
1249 | } catch (error) {
1250 | log('warn', `Could not update current tag in state.json: ${error.message}`);
1251 | // Don't throw - this is not critical for tag operations
1252 | }
1253 | }
1254 |
1255 | /**
1256 | * Update branch-tag mapping in state.json
1257 | * @param {string} projectRoot - Project root directory
1258 | * @param {string} branchName - Git branch name
1259 | * @param {string} tagName - Tag name to map to
1260 | * @returns {Promise<void>}
1261 | */
1262 | async function updateBranchTagMapping(projectRoot, branchName, tagName) {
1263 | try {
1264 | const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
1265 |
1266 | // Read current state or create default
1267 | let state = {};
1268 | if (fs.existsSync(statePath)) {
1269 | const rawState = fs.readFileSync(statePath, 'utf8');
1270 | state = JSON.parse(rawState);
1271 | }
1272 |
1273 | // Ensure branchTagMapping exists
1274 | if (!state.branchTagMapping) {
1275 | state.branchTagMapping = {};
1276 | }
1277 |
1278 | // Update the mapping
1279 | state.branchTagMapping[branchName] = tagName;
1280 |
1281 | // Write updated state
1282 | fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
1283 | } catch (error) {
1284 | log('warn', `Could not update branch-tag mapping: ${error.message}`);
1285 | // Don't throw - this is not critical for tag operations
1286 | }
1287 | }
1288 |
1289 | /**
1290 | * Get tag name for a git branch from state.json mapping
1291 | * @param {string} projectRoot - Project root directory
1292 | * @param {string} branchName - Git branch name
1293 | * @returns {Promise<string|null>} Mapped tag name or null if not found
1294 | */
1295 | async function getTagForBranch(projectRoot, branchName) {
1296 | try {
1297 | const statePath = path.join(projectRoot, '.taskmaster', 'state.json');
1298 |
1299 | if (!fs.existsSync(statePath)) {
1300 | return null;
1301 | }
1302 |
1303 | const rawState = fs.readFileSync(statePath, 'utf8');
1304 | const state = JSON.parse(rawState);
1305 |
1306 | return state.branchTagMapping?.[branchName] || null;
1307 | } catch (error) {
1308 | return null;
1309 | }
1310 | }
1311 |
1312 | /**
1313 | * Create a tag from a git branch name
1314 | * @param {string} tasksPath - Path to the tasks.json file
1315 | * @param {string} branchName - Git branch name to create tag from
1316 | * @param {Object} options - Options object
1317 | * @param {boolean} [options.copyFromCurrent] - Copy tasks from current tag
1318 | * @param {string} [options.copyFromTag] - Copy tasks from specific tag
1319 | * @param {string} [options.description] - Custom description for the tag
1320 | * @param {boolean} [options.autoSwitch] - Automatically switch to the new tag
1321 | * @param {Object} context - Context object containing session and projectRoot
1322 | * @param {string} [context.projectRoot] - Project root path
1323 | * @param {Object} [context.mcpLog] - MCP logger object (optional)
1324 | * @param {string} outputFormat - Output format (text or json)
1325 | * @returns {Promise<Object>} Result object with creation details
1326 | */
1327 | async function createTagFromBranch(
1328 | tasksPath,
1329 | branchName,
1330 | options = {},
1331 | context = {},
1332 | outputFormat = 'text'
1333 | ) {
1334 | const { mcpLog, projectRoot } = context;
1335 | const { copyFromCurrent, copyFromTag, description, autoSwitch } = options;
1336 |
1337 | // Import git utilities
1338 | const { sanitizeBranchNameForTag, isValidBranchForTag } = await import(
1339 | '../utils/git-utils.js'
1340 | );
1341 |
1342 | // Create a consistent logFn object regardless of context
1343 | const logFn = mcpLog || {
1344 | info: (...args) => log('info', ...args),
1345 | warn: (...args) => log('warn', ...args),
1346 | error: (...args) => log('error', ...args),
1347 | debug: (...args) => log('debug', ...args),
1348 | success: (...args) => log('success', ...args)
1349 | };
1350 |
1351 | try {
1352 | // Validate branch name
1353 | if (!branchName || typeof branchName !== 'string') {
1354 | throw new Error('Branch name is required and must be a string');
1355 | }
1356 |
1357 | // Check if branch name is valid for tag creation
1358 | if (!isValidBranchForTag(branchName)) {
1359 | throw new Error(
1360 | `Branch "${branchName}" cannot be converted to a valid tag name`
1361 | );
1362 | }
1363 |
1364 | // Sanitize branch name to create tag name
1365 | const tagName = sanitizeBranchNameForTag(branchName);
1366 |
1367 | logFn.info(`Creating tag "${tagName}" from git branch "${branchName}"`);
1368 |
1369 | // Create the tag using existing createTag function
1370 | const createResult = await createTag(
1371 | tasksPath,
1372 | tagName,
1373 | {
1374 | copyFromCurrent,
1375 | copyFromTag,
1376 | description:
1377 | description || `Tag created from git branch "${branchName}"`
1378 | },
1379 | context,
1380 | outputFormat
1381 | );
1382 |
1383 | // Update branch-tag mapping
1384 | await updateBranchTagMapping(projectRoot, branchName, tagName);
1385 | logFn.info(`Updated branch-tag mapping: ${branchName} -> ${tagName}`);
1386 |
1387 | // Auto-switch to the new tag if requested
1388 | if (autoSwitch) {
1389 | await switchCurrentTag(projectRoot, tagName);
1390 | logFn.info(`Automatically switched to tag "${tagName}"`);
1391 | }
1392 |
1393 | // For JSON output, return structured data
1394 | if (outputFormat === 'json') {
1395 | return {
1396 | ...createResult,
1397 | branchName,
1398 | tagName,
1399 | mappingUpdated: true,
1400 | autoSwitched: autoSwitch || false
1401 | };
1402 | }
1403 |
1404 | // For text output, the createTag function already handles display
1405 | return {
1406 | branchName,
1407 | tagName,
1408 | created: true,
1409 | mappingUpdated: true,
1410 | autoSwitched: autoSwitch || false
1411 | };
1412 | } catch (error) {
1413 | logFn.error(`Error creating tag from branch: ${error.message}`);
1414 | throw error;
1415 | }
1416 | }
1417 |
1418 | /**
1419 | * Automatically switch tag based on current git branch
1420 | * @param {string} tasksPath - Path to the tasks.json file
1421 | * @param {Object} options - Options object
1422 | * @param {boolean} [options.createIfMissing] - Create tag if it doesn't exist
1423 | * @param {boolean} [options.copyFromCurrent] - Copy tasks when creating new tag
1424 | * @param {Object} context - Context object containing session and projectRoot
1425 | * @param {string} [context.projectRoot] - Project root path
1426 | * @param {Object} [context.mcpLog] - MCP logger object (optional)
1427 | * @param {string} outputFormat - Output format (text or json)
1428 | * @returns {Promise<Object>} Result object with switch details
1429 | */
1430 | async function autoSwitchTagForBranch(
1431 | tasksPath,
1432 | options = {},
1433 | context = {},
1434 | outputFormat = 'text'
1435 | ) {
1436 | const { mcpLog, projectRoot } = context;
1437 | const { createIfMissing, copyFromCurrent } = options;
1438 |
1439 | // Import git utilities
1440 | const {
1441 | getCurrentBranch,
1442 | isGitRepository,
1443 | sanitizeBranchNameForTag,
1444 | isValidBranchForTag
1445 | } = await import('../utils/git-utils.js');
1446 |
1447 | // Create a consistent logFn object regardless of context
1448 | const logFn = mcpLog || {
1449 | info: (...args) => log('info', ...args),
1450 | warn: (...args) => log('warn', ...args),
1451 | error: (...args) => log('error', ...args),
1452 | debug: (...args) => log('debug', ...args),
1453 | success: (...args) => log('success', ...args)
1454 | };
1455 |
1456 | try {
1457 | // Check if we're in a git repository
1458 | if (!(await isGitRepository(projectRoot))) {
1459 | logFn.warn('Not in a git repository, cannot auto-switch tags');
1460 | return { switched: false, reason: 'not_git_repo' };
1461 | }
1462 |
1463 | // Get current git branch
1464 | const currentBranch = await getCurrentBranch(projectRoot);
1465 | if (!currentBranch) {
1466 | logFn.warn('Could not determine current git branch');
1467 | return { switched: false, reason: 'no_current_branch' };
1468 | }
1469 |
1470 | logFn.info(`Current git branch: ${currentBranch}`);
1471 |
1472 | // Check if branch is valid for tag creation
1473 | if (!isValidBranchForTag(currentBranch)) {
1474 | logFn.info(`Branch "${currentBranch}" is not suitable for tag creation`);
1475 | return {
1476 | switched: false,
1477 | reason: 'invalid_branch_for_tag',
1478 | branchName: currentBranch
1479 | };
1480 | }
1481 |
1482 | // Check if there's already a mapping for this branch
1483 | let tagName = await getTagForBranch(projectRoot, currentBranch);
1484 |
1485 | if (!tagName) {
1486 | // No mapping exists, create tag name from branch
1487 | tagName = sanitizeBranchNameForTag(currentBranch);
1488 | }
1489 |
1490 | // Check if tag exists
1491 | const data = readJSON(tasksPath, projectRoot);
1492 | const rawData = data._rawTaggedData || data;
1493 | const tagExists = rawData[tagName];
1494 |
1495 | if (!tagExists && createIfMissing) {
1496 | // Create the tag from branch
1497 | logFn.info(`Creating new tag "${tagName}" for branch "${currentBranch}"`);
1498 |
1499 | const createResult = await createTagFromBranch(
1500 | tasksPath,
1501 | currentBranch,
1502 | {
1503 | copyFromCurrent,
1504 | autoSwitch: true
1505 | },
1506 | context,
1507 | outputFormat
1508 | );
1509 |
1510 | return {
1511 | switched: true,
1512 | created: true,
1513 | branchName: currentBranch,
1514 | tagName,
1515 | ...createResult
1516 | };
1517 | } else if (tagExists) {
1518 | // Tag exists, switch to it
1519 | logFn.info(
1520 | `Switching to existing tag "${tagName}" for branch "${currentBranch}"`
1521 | );
1522 |
1523 | const switchResult = await useTag(
1524 | tasksPath,
1525 | tagName,
1526 | {},
1527 | context,
1528 | outputFormat
1529 | );
1530 |
1531 | // Update mapping if it didn't exist
1532 | if (!(await getTagForBranch(projectRoot, currentBranch))) {
1533 | await updateBranchTagMapping(projectRoot, currentBranch, tagName);
1534 | }
1535 |
1536 | return {
1537 | switched: true,
1538 | created: false,
1539 | branchName: currentBranch,
1540 | tagName,
1541 | ...switchResult
1542 | };
1543 | } else {
1544 | // Tag doesn't exist and createIfMissing is false
1545 | logFn.warn(
1546 | `Tag "${tagName}" for branch "${currentBranch}" does not exist`
1547 | );
1548 | return {
1549 | switched: false,
1550 | reason: 'tag_not_found',
1551 | branchName: currentBranch,
1552 | tagName
1553 | };
1554 | }
1555 | } catch (error) {
1556 | logFn.error(`Error in auto-switch tag for branch: ${error.message}`);
1557 | throw error;
1558 | }
1559 | }
1560 |
1561 | /**
1562 | * Check git workflow configuration and perform auto-switch if enabled
1563 | * @param {string} projectRoot - Project root directory
1564 | * @param {string} tasksPath - Path to the tasks.json file
1565 | * @param {Object} context - Context object
1566 | * @returns {Promise<Object|null>} Switch result or null if not enabled
1567 | */
1568 | async function checkAndAutoSwitchTag(projectRoot, tasksPath, context = {}) {
1569 | try {
1570 | // Read configuration
1571 | const configPath = path.join(projectRoot, '.taskmaster', 'config.json');
1572 | if (!fs.existsSync(configPath)) {
1573 | return null;
1574 | }
1575 |
1576 | const rawConfig = fs.readFileSync(configPath, 'utf8');
1577 | const config = JSON.parse(rawConfig);
1578 |
1579 | // Git workflow has been removed - return null to disable auto-switching
1580 | return null;
1581 |
1582 | // Perform auto-switch
1583 | return await autoSwitchTagForBranch(
1584 | tasksPath,
1585 | { createIfMissing: true, copyFromCurrent: false },
1586 | context,
1587 | 'json'
1588 | );
1589 | } catch (error) {
1590 | // Silently fail - this is not critical
1591 | return null;
1592 | }
1593 | }
1594 |
1595 | // Export all tag management functions
1596 | export {
1597 | createTag,
1598 | deleteTag,
1599 | tags,
1600 | useTag,
1601 | renameTag,
1602 | copyTag,
1603 | switchCurrentTag,
1604 | updateBranchTagMapping,
1605 | getTagForBranch,
1606 | createTagFromBranch,
1607 | autoSwitchTagForBranch,
1608 | checkAndAutoSwitchTag
1609 | };
1610 |
```