This is page 43 of 52. Use http://codebase.md/eyaltoledano/claude-task-master?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .changeset │ ├── config.json │ └── README.md ├── .claude │ ├── agents │ │ ├── task-checker.md │ │ ├── task-executor.md │ │ └── task-orchestrator.md │ ├── commands │ │ ├── dedupe.md │ │ └── tm │ │ ├── add-dependency │ │ │ └── add-dependency.md │ │ ├── add-subtask │ │ │ ├── add-subtask.md │ │ │ └── convert-task-to-subtask.md │ │ ├── add-task │ │ │ └── add-task.md │ │ ├── analyze-complexity │ │ │ └── analyze-complexity.md │ │ ├── complexity-report │ │ │ └── complexity-report.md │ │ ├── expand │ │ │ ├── expand-all-tasks.md │ │ │ └── expand-task.md │ │ ├── fix-dependencies │ │ │ └── fix-dependencies.md │ │ ├── generate │ │ │ └── generate-tasks.md │ │ ├── help.md │ │ ├── init │ │ │ ├── init-project-quick.md │ │ │ └── init-project.md │ │ ├── learn.md │ │ ├── list │ │ │ ├── list-tasks-by-status.md │ │ │ ├── list-tasks-with-subtasks.md │ │ │ └── list-tasks.md │ │ ├── models │ │ │ ├── setup-models.md │ │ │ └── view-models.md │ │ ├── next │ │ │ └── next-task.md │ │ ├── parse-prd │ │ │ ├── parse-prd-with-research.md │ │ │ └── parse-prd.md │ │ ├── remove-dependency │ │ │ └── remove-dependency.md │ │ ├── remove-subtask │ │ │ └── remove-subtask.md │ │ ├── remove-subtasks │ │ │ ├── remove-all-subtasks.md │ │ │ └── remove-subtasks.md │ │ ├── remove-task │ │ │ └── remove-task.md │ │ ├── set-status │ │ │ ├── to-cancelled.md │ │ │ ├── to-deferred.md │ │ │ ├── to-done.md │ │ │ ├── to-in-progress.md │ │ │ ├── to-pending.md │ │ │ └── to-review.md │ │ ├── setup │ │ │ ├── install-taskmaster.md │ │ │ └── quick-install-taskmaster.md │ │ ├── show │ │ │ └── show-task.md │ │ ├── status │ │ │ └── project-status.md │ │ ├── sync-readme │ │ │ └── sync-readme.md │ │ ├── tm-main.md │ │ ├── update │ │ │ ├── update-single-task.md │ │ │ ├── update-task.md │ │ │ └── update-tasks-from-id.md │ │ ├── utils │ │ │ └── analyze-project.md │ │ ├── validate-dependencies │ │ │ └── validate-dependencies.md │ │ └── workflows │ │ ├── auto-implement-tasks.md │ │ ├── command-pipeline.md │ │ └── smart-workflow.md │ └── TM_COMMANDS_GUIDE.md ├── .coderabbit.yaml ├── .cursor │ ├── mcp.json │ └── rules │ ├── ai_providers.mdc │ ├── ai_services.mdc │ ├── architecture.mdc │ ├── changeset.mdc │ ├── commands.mdc │ ├── context_gathering.mdc │ ├── cursor_rules.mdc │ ├── dependencies.mdc │ ├── dev_workflow.mdc │ ├── git_workflow.mdc │ ├── glossary.mdc │ ├── mcp.mdc │ ├── new_features.mdc │ ├── self_improve.mdc │ ├── tags.mdc │ ├── taskmaster.mdc │ ├── tasks.mdc │ ├── telemetry.mdc │ ├── test_workflow.mdc │ ├── tests.mdc │ ├── ui.mdc │ └── utilities.mdc ├── .cursorignore ├── .env.example ├── .github │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.md │ │ ├── enhancements---feature-requests.md │ │ └── feedback.md │ ├── PULL_REQUEST_TEMPLATE │ │ ├── bugfix.md │ │ ├── config.yml │ │ ├── feature.md │ │ └── integration.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── scripts │ │ ├── auto-close-duplicates.mjs │ │ ├── backfill-duplicate-comments.mjs │ │ ├── check-pre-release-mode.mjs │ │ ├── parse-metrics.mjs │ │ ├── release.mjs │ │ ├── tag-extension.mjs │ │ └── utils.mjs │ └── workflows │ ├── auto-close-duplicates.yml │ ├── backfill-duplicate-comments.yml │ ├── ci.yml │ ├── claude-dedupe-issues.yml │ ├── claude-docs-trigger.yml │ ├── claude-docs-updater.yml │ ├── claude-issue-triage.yml │ ├── claude.yml │ ├── extension-ci.yml │ ├── extension-release.yml │ ├── log-issue-events.yml │ ├── pre-release.yml │ ├── release-check.yml │ ├── release.yml │ ├── update-models-md.yml │ └── weekly-metrics-discord.yml ├── .gitignore ├── .kiro │ ├── hooks │ │ ├── tm-code-change-task-tracker.kiro.hook │ │ ├── tm-complexity-analyzer.kiro.hook │ │ ├── tm-daily-standup-assistant.kiro.hook │ │ ├── tm-git-commit-task-linker.kiro.hook │ │ ├── tm-pr-readiness-checker.kiro.hook │ │ ├── tm-task-dependency-auto-progression.kiro.hook │ │ └── tm-test-success-task-completer.kiro.hook │ ├── settings │ │ └── mcp.json │ └── steering │ ├── dev_workflow.md │ ├── kiro_rules.md │ ├── self_improve.md │ ├── taskmaster_hooks_workflow.md │ └── taskmaster.md ├── .manypkg.json ├── .mcp.json ├── .npmignore ├── .nvmrc ├── .taskmaster │ ├── CLAUDE.md │ ├── config.json │ ├── docs │ │ ├── MIGRATION-ROADMAP.md │ │ ├── prd-tm-start.txt │ │ ├── prd.txt │ │ ├── README.md │ │ ├── research │ │ │ ├── 2025-06-14_how-can-i-improve-the-scope-up-and-scope-down-comm.md │ │ │ ├── 2025-06-14_should-i-be-using-any-specific-libraries-for-this.md │ │ │ ├── 2025-06-14_test-save-functionality.md │ │ │ ├── 2025-06-14_test-the-fix-for-duplicate-saves-final-test.md │ │ │ └── 2025-08-01_do-we-need-to-add-new-commands-or-can-we-just-weap.md │ │ ├── task-template-importing-prd.txt │ │ ├── test-prd.txt │ │ └── tm-core-phase-1.txt │ ├── reports │ │ ├── task-complexity-report_cc-kiro-hooks.json │ │ ├── task-complexity-report_test-prd-tag.json │ │ ├── task-complexity-report_tm-core-phase-1.json │ │ ├── task-complexity-report.json │ │ └── tm-core-complexity.json │ ├── state.json │ ├── tasks │ │ ├── task_001_tm-start.txt │ │ ├── task_002_tm-start.txt │ │ ├── task_003_tm-start.txt │ │ ├── task_004_tm-start.txt │ │ ├── task_007_tm-start.txt │ │ └── tasks.json │ └── templates │ └── example_prd.txt ├── .vscode │ ├── extensions.json │ └── settings.json ├── apps │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── auth.command.ts │ │ │ │ ├── context.command.ts │ │ │ │ ├── list.command.ts │ │ │ │ ├── set-status.command.ts │ │ │ │ ├── show.command.ts │ │ │ │ └── start.command.ts │ │ │ ├── index.ts │ │ │ ├── ui │ │ │ │ ├── components │ │ │ │ │ ├── dashboard.component.ts │ │ │ │ │ ├── header.component.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── next-task.component.ts │ │ │ │ │ ├── suggested-steps.component.ts │ │ │ │ │ └── task-detail.component.ts │ │ │ │ └── index.ts │ │ │ └── utils │ │ │ ├── auto-update.ts │ │ │ └── ui.ts │ │ └── tsconfig.json │ ├── docs │ │ ├── archive │ │ │ ├── ai-client-utils-example.mdx │ │ │ ├── ai-development-workflow.mdx │ │ │ ├── command-reference.mdx │ │ │ ├── configuration.mdx │ │ │ ├── cursor-setup.mdx │ │ │ ├── examples.mdx │ │ │ └── Installation.mdx │ │ ├── best-practices │ │ │ ├── advanced-tasks.mdx │ │ │ ├── configuration-advanced.mdx │ │ │ └── index.mdx │ │ ├── capabilities │ │ │ ├── cli-root-commands.mdx │ │ │ ├── index.mdx │ │ │ ├── mcp.mdx │ │ │ └── task-structure.mdx │ │ ├── CHANGELOG.md │ │ ├── docs.json │ │ ├── favicon.svg │ │ ├── getting-started │ │ │ ├── contribute.mdx │ │ │ ├── faq.mdx │ │ │ └── quick-start │ │ │ ├── configuration-quick.mdx │ │ │ ├── execute-quick.mdx │ │ │ ├── installation.mdx │ │ │ ├── moving-forward.mdx │ │ │ ├── prd-quick.mdx │ │ │ ├── quick-start.mdx │ │ │ ├── requirements.mdx │ │ │ ├── rules-quick.mdx │ │ │ └── tasks-quick.mdx │ │ ├── introduction.mdx │ │ ├── licensing.md │ │ ├── logo │ │ │ ├── dark.svg │ │ │ ├── light.svg │ │ │ └── task-master-logo.png │ │ ├── package.json │ │ ├── README.md │ │ ├── style.css │ │ ├── vercel.json │ │ └── whats-new.mdx │ └── extension │ ├── .vscodeignore │ ├── assets │ │ ├── banner.png │ │ ├── icon-dark.svg │ │ ├── icon-light.svg │ │ ├── icon.png │ │ ├── screenshots │ │ │ ├── kanban-board.png │ │ │ └── task-details.png │ │ └── sidebar-icon.svg │ ├── CHANGELOG.md │ ├── components.json │ ├── docs │ │ ├── extension-CI-setup.md │ │ └── extension-development-guide.md │ ├── esbuild.js │ ├── LICENSE │ ├── package.json │ ├── package.mjs │ ├── package.publish.json │ ├── README.md │ ├── src │ │ ├── components │ │ │ ├── ConfigView.tsx │ │ │ ├── constants.ts │ │ │ ├── TaskDetails │ │ │ │ ├── AIActionsSection.tsx │ │ │ │ ├── DetailsSection.tsx │ │ │ │ ├── PriorityBadge.tsx │ │ │ │ ├── SubtasksSection.tsx │ │ │ │ ├── TaskMetadataSidebar.tsx │ │ │ │ └── useTaskDetails.ts │ │ │ ├── TaskDetailsView.tsx │ │ │ ├── TaskMasterLogo.tsx │ │ │ └── ui │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── CollapsibleSection.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── label.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── separator.tsx │ │ │ ├── shadcn-io │ │ │ │ └── kanban │ │ │ │ └── index.tsx │ │ │ └── textarea.tsx │ │ ├── extension.ts │ │ ├── index.ts │ │ ├── lib │ │ │ └── utils.ts │ │ ├── services │ │ │ ├── config-service.ts │ │ │ ├── error-handler.ts │ │ │ ├── notification-preferences.ts │ │ │ ├── polling-service.ts │ │ │ ├── polling-strategies.ts │ │ │ ├── sidebar-webview-manager.ts │ │ │ ├── task-repository.ts │ │ │ ├── terminal-manager.ts │ │ │ └── webview-manager.ts │ │ ├── test │ │ │ └── extension.test.ts │ │ ├── utils │ │ │ ├── configManager.ts │ │ │ ├── connectionManager.ts │ │ │ ├── errorHandler.ts │ │ │ ├── event-emitter.ts │ │ │ ├── logger.ts │ │ │ ├── mcpClient.ts │ │ │ ├── notificationPreferences.ts │ │ │ └── task-master-api │ │ │ ├── cache │ │ │ │ └── cache-manager.ts │ │ │ ├── index.ts │ │ │ ├── mcp-client.ts │ │ │ ├── transformers │ │ │ │ └── task-transformer.ts │ │ │ └── types │ │ │ └── index.ts │ │ └── webview │ │ ├── App.tsx │ │ ├── components │ │ │ ├── AppContent.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── PollingStatus.tsx │ │ │ ├── PriorityBadge.tsx │ │ │ ├── SidebarView.tsx │ │ │ ├── TagDropdown.tsx │ │ │ ├── TaskCard.tsx │ │ │ ├── TaskEditModal.tsx │ │ │ ├── TaskMasterKanban.tsx │ │ │ ├── ToastContainer.tsx │ │ │ └── ToastNotification.tsx │ │ ├── constants │ │ │ └── index.ts │ │ ├── contexts │ │ │ └── VSCodeContext.tsx │ │ ├── hooks │ │ │ ├── useTaskQueries.ts │ │ │ ├── useVSCodeMessages.ts │ │ │ └── useWebviewHeight.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── providers │ │ │ └── QueryProvider.tsx │ │ ├── reducers │ │ │ └── appReducer.ts │ │ ├── sidebar.tsx │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ ├── logger.ts │ │ └── toast.ts │ └── tsconfig.json ├── assets │ ├── .windsurfrules │ ├── AGENTS.md │ ├── claude │ │ ├── agents │ │ │ ├── task-checker.md │ │ │ ├── task-executor.md │ │ │ └── task-orchestrator.md │ │ ├── commands │ │ │ └── tm │ │ │ ├── add-dependency │ │ │ │ └── add-dependency.md │ │ │ ├── add-subtask │ │ │ │ ├── add-subtask.md │ │ │ │ └── convert-task-to-subtask.md │ │ │ ├── add-task │ │ │ │ └── add-task.md │ │ │ ├── analyze-complexity │ │ │ │ └── analyze-complexity.md │ │ │ ├── clear-subtasks │ │ │ │ ├── clear-all-subtasks.md │ │ │ │ └── clear-subtasks.md │ │ │ ├── complexity-report │ │ │ │ └── complexity-report.md │ │ │ ├── expand │ │ │ │ ├── expand-all-tasks.md │ │ │ │ └── expand-task.md │ │ │ ├── fix-dependencies │ │ │ │ └── fix-dependencies.md │ │ │ ├── generate │ │ │ │ └── generate-tasks.md │ │ │ ├── help.md │ │ │ ├── init │ │ │ │ ├── init-project-quick.md │ │ │ │ └── init-project.md │ │ │ ├── learn.md │ │ │ ├── list │ │ │ │ ├── list-tasks-by-status.md │ │ │ │ ├── list-tasks-with-subtasks.md │ │ │ │ └── list-tasks.md │ │ │ ├── models │ │ │ │ ├── setup-models.md │ │ │ │ └── view-models.md │ │ │ ├── next │ │ │ │ └── next-task.md │ │ │ ├── parse-prd │ │ │ │ ├── parse-prd-with-research.md │ │ │ │ └── parse-prd.md │ │ │ ├── remove-dependency │ │ │ │ └── remove-dependency.md │ │ │ ├── remove-subtask │ │ │ │ └── remove-subtask.md │ │ │ ├── remove-subtasks │ │ │ │ ├── remove-all-subtasks.md │ │ │ │ └── remove-subtasks.md │ │ │ ├── remove-task │ │ │ │ └── remove-task.md │ │ │ ├── set-status │ │ │ │ ├── to-cancelled.md │ │ │ │ ├── to-deferred.md │ │ │ │ ├── to-done.md │ │ │ │ ├── to-in-progress.md │ │ │ │ ├── to-pending.md │ │ │ │ └── to-review.md │ │ │ ├── setup │ │ │ │ ├── install-taskmaster.md │ │ │ │ └── quick-install-taskmaster.md │ │ │ ├── show │ │ │ │ └── show-task.md │ │ │ ├── status │ │ │ │ └── project-status.md │ │ │ ├── sync-readme │ │ │ │ └── sync-readme.md │ │ │ ├── tm-main.md │ │ │ ├── update │ │ │ │ ├── update-single-task.md │ │ │ │ ├── update-task.md │ │ │ │ └── update-tasks-from-id.md │ │ │ ├── utils │ │ │ │ └── analyze-project.md │ │ │ ├── validate-dependencies │ │ │ │ └── validate-dependencies.md │ │ │ └── workflows │ │ │ ├── auto-implement-tasks.md │ │ │ ├── command-pipeline.md │ │ │ └── smart-workflow.md │ │ └── TM_COMMANDS_GUIDE.md │ ├── config.json │ ├── env.example │ ├── example_prd.txt │ ├── gitignore │ ├── kiro-hooks │ │ ├── tm-code-change-task-tracker.kiro.hook │ │ ├── tm-complexity-analyzer.kiro.hook │ │ ├── tm-daily-standup-assistant.kiro.hook │ │ ├── tm-git-commit-task-linker.kiro.hook │ │ ├── tm-pr-readiness-checker.kiro.hook │ │ ├── tm-task-dependency-auto-progression.kiro.hook │ │ └── tm-test-success-task-completer.kiro.hook │ ├── roocode │ │ ├── .roo │ │ │ ├── rules-architect │ │ │ │ └── architect-rules │ │ │ ├── rules-ask │ │ │ │ └── ask-rules │ │ │ ├── rules-code │ │ │ │ └── code-rules │ │ │ ├── rules-debug │ │ │ │ └── debug-rules │ │ │ ├── rules-orchestrator │ │ │ │ └── orchestrator-rules │ │ │ └── rules-test │ │ │ └── test-rules │ │ └── .roomodes │ ├── rules │ │ ├── cursor_rules.mdc │ │ ├── dev_workflow.mdc │ │ ├── self_improve.mdc │ │ ├── taskmaster_hooks_workflow.mdc │ │ └── taskmaster.mdc │ └── scripts_README.md ├── bin │ └── task-master.js ├── biome.json ├── CHANGELOG.md ├── CLAUDE.md ├── context │ ├── chats │ │ ├── add-task-dependencies-1.md │ │ └── max-min-tokens.txt.md │ ├── fastmcp-core.txt │ ├── fastmcp-docs.txt │ ├── MCP_INTEGRATION.md │ ├── mcp-js-sdk-docs.txt │ ├── mcp-protocol-repo.txt │ ├── mcp-protocol-schema-03262025.json │ └── mcp-protocol-spec.txt ├── CONTRIBUTING.md ├── docs │ ├── CLI-COMMANDER-PATTERN.md │ ├── command-reference.md │ ├── configuration.md │ ├── contributor-docs │ │ └── testing-roo-integration.md │ ├── cross-tag-task-movement.md │ ├── examples │ │ └── claude-code-usage.md │ ├── examples.md │ ├── licensing.md │ ├── mcp-provider-guide.md │ ├── mcp-provider.md │ ├── migration-guide.md │ ├── models.md │ ├── providers │ │ └── gemini-cli.md │ ├── README.md │ ├── scripts │ │ └── models-json-to-markdown.js │ ├── task-structure.md │ └── tutorial.md ├── images │ └── logo.png ├── index.js ├── jest.config.js ├── jest.resolver.cjs ├── LICENSE ├── llms-install.md ├── mcp-server │ ├── server.js │ └── src │ ├── core │ │ ├── __tests__ │ │ │ └── context-manager.test.js │ │ ├── context-manager.js │ │ ├── direct-functions │ │ │ ├── add-dependency.js │ │ │ ├── add-subtask.js │ │ │ ├── add-tag.js │ │ │ ├── add-task.js │ │ │ ├── analyze-task-complexity.js │ │ │ ├── cache-stats.js │ │ │ ├── clear-subtasks.js │ │ │ ├── complexity-report.js │ │ │ ├── copy-tag.js │ │ │ ├── create-tag-from-branch.js │ │ │ ├── delete-tag.js │ │ │ ├── expand-all-tasks.js │ │ │ ├── expand-task.js │ │ │ ├── fix-dependencies.js │ │ │ ├── generate-task-files.js │ │ │ ├── initialize-project.js │ │ │ ├── list-tags.js │ │ │ ├── list-tasks.js │ │ │ ├── models.js │ │ │ ├── move-task-cross-tag.js │ │ │ ├── move-task.js │ │ │ ├── next-task.js │ │ │ ├── parse-prd.js │ │ │ ├── remove-dependency.js │ │ │ ├── remove-subtask.js │ │ │ ├── remove-task.js │ │ │ ├── rename-tag.js │ │ │ ├── research.js │ │ │ ├── response-language.js │ │ │ ├── rules.js │ │ │ ├── scope-down.js │ │ │ ├── scope-up.js │ │ │ ├── set-task-status.js │ │ │ ├── show-task.js │ │ │ ├── update-subtask-by-id.js │ │ │ ├── update-task-by-id.js │ │ │ ├── update-tasks.js │ │ │ ├── use-tag.js │ │ │ └── validate-dependencies.js │ │ ├── task-master-core.js │ │ └── utils │ │ ├── env-utils.js │ │ └── path-utils.js │ ├── custom-sdk │ │ ├── errors.js │ │ ├── index.js │ │ ├── json-extractor.js │ │ ├── language-model.js │ │ ├── message-converter.js │ │ └── schema-converter.js │ ├── index.js │ ├── logger.js │ ├── providers │ │ └── mcp-provider.js │ └── tools │ ├── add-dependency.js │ ├── add-subtask.js │ ├── add-tag.js │ ├── add-task.js │ ├── analyze.js │ ├── clear-subtasks.js │ ├── complexity-report.js │ ├── copy-tag.js │ ├── delete-tag.js │ ├── expand-all.js │ ├── expand-task.js │ ├── fix-dependencies.js │ ├── generate.js │ ├── get-operation-status.js │ ├── get-task.js │ ├── get-tasks.js │ ├── index.js │ ├── initialize-project.js │ ├── list-tags.js │ ├── models.js │ ├── move-task.js │ ├── next-task.js │ ├── parse-prd.js │ ├── remove-dependency.js │ ├── remove-subtask.js │ ├── remove-task.js │ ├── rename-tag.js │ ├── research.js │ ├── response-language.js │ ├── rules.js │ ├── scope-down.js │ ├── scope-up.js │ ├── set-task-status.js │ ├── update-subtask.js │ ├── update-task.js │ ├── update.js │ ├── use-tag.js │ ├── utils.js │ └── validate-dependencies.js ├── mcp-test.js ├── output.json ├── package-lock.json ├── package.json ├── packages │ ├── build-config │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ └── tsdown.base.ts │ │ └── tsconfig.json │ └── tm-core │ ├── .gitignore │ ├── CHANGELOG.md │ ├── docs │ │ └── listTasks-architecture.md │ ├── package.json │ ├── POC-STATUS.md │ ├── README.md │ ├── src │ │ ├── auth │ │ │ ├── auth-manager.test.ts │ │ │ ├── auth-manager.ts │ │ │ ├── config.ts │ │ │ ├── credential-store.test.ts │ │ │ ├── credential-store.ts │ │ │ ├── index.ts │ │ │ ├── oauth-service.ts │ │ │ ├── supabase-session-storage.ts │ │ │ └── types.ts │ │ ├── clients │ │ │ ├── index.ts │ │ │ └── supabase-client.ts │ │ ├── config │ │ │ ├── config-manager.spec.ts │ │ │ ├── config-manager.ts │ │ │ ├── index.ts │ │ │ └── services │ │ │ ├── config-loader.service.spec.ts │ │ │ ├── config-loader.service.ts │ │ │ ├── config-merger.service.spec.ts │ │ │ ├── config-merger.service.ts │ │ │ ├── config-persistence.service.spec.ts │ │ │ ├── config-persistence.service.ts │ │ │ ├── environment-config-provider.service.spec.ts │ │ │ ├── environment-config-provider.service.ts │ │ │ ├── index.ts │ │ │ ├── runtime-state-manager.service.spec.ts │ │ │ └── runtime-state-manager.service.ts │ │ ├── constants │ │ │ └── index.ts │ │ ├── entities │ │ │ └── task.entity.ts │ │ ├── errors │ │ │ ├── index.ts │ │ │ └── task-master-error.ts │ │ ├── executors │ │ │ ├── base-executor.ts │ │ │ ├── claude-executor.ts │ │ │ ├── executor-factory.ts │ │ │ ├── executor-service.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── ai-provider.interface.ts │ │ │ ├── configuration.interface.ts │ │ │ ├── index.ts │ │ │ └── storage.interface.ts │ │ ├── logger │ │ │ ├── factory.ts │ │ │ ├── index.ts │ │ │ └── logger.ts │ │ ├── mappers │ │ │ └── TaskMapper.ts │ │ ├── parser │ │ │ └── index.ts │ │ ├── providers │ │ │ ├── ai │ │ │ │ ├── base-provider.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── supabase-task-repository.ts │ │ │ └── task-repository.interface.ts │ │ ├── services │ │ │ ├── index.ts │ │ │ ├── organization.service.ts │ │ │ ├── task-execution-service.ts │ │ │ └── task-service.ts │ │ ├── storage │ │ │ ├── api-storage.ts │ │ │ ├── file-storage │ │ │ │ ├── file-operations.ts │ │ │ │ ├── file-storage.ts │ │ │ │ ├── format-handler.ts │ │ │ │ ├── index.ts │ │ │ │ └── path-resolver.ts │ │ │ ├── index.ts │ │ │ └── storage-factory.ts │ │ ├── subpath-exports.test.ts │ │ ├── task-master-core.ts │ │ ├── types │ │ │ ├── database.types.ts │ │ │ ├── index.ts │ │ │ └── legacy.ts │ │ └── utils │ │ ├── id-generator.ts │ │ └── index.ts │ ├── tests │ │ ├── integration │ │ │ └── list-tasks.test.ts │ │ ├── mocks │ │ │ └── mock-provider.ts │ │ ├── setup.ts │ │ └── unit │ │ ├── base-provider.test.ts │ │ ├── executor.test.ts │ │ └── smoke.test.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── README-task-master.md ├── README.md ├── scripts │ ├── dev.js │ ├── init.js │ ├── modules │ │ ├── ai-services-unified.js │ │ ├── commands.js │ │ ├── config-manager.js │ │ ├── dependency-manager.js │ │ ├── index.js │ │ ├── prompt-manager.js │ │ ├── supported-models.json │ │ ├── sync-readme.js │ │ ├── task-manager │ │ │ ├── add-subtask.js │ │ │ ├── add-task.js │ │ │ ├── analyze-task-complexity.js │ │ │ ├── clear-subtasks.js │ │ │ ├── expand-all-tasks.js │ │ │ ├── expand-task.js │ │ │ ├── find-next-task.js │ │ │ ├── generate-task-files.js │ │ │ ├── is-task-dependent.js │ │ │ ├── list-tasks.js │ │ │ ├── migrate.js │ │ │ ├── models.js │ │ │ ├── move-task.js │ │ │ ├── parse-prd │ │ │ │ ├── index.js │ │ │ │ ├── parse-prd-config.js │ │ │ │ ├── parse-prd-helpers.js │ │ │ │ ├── parse-prd-non-streaming.js │ │ │ │ ├── parse-prd-streaming.js │ │ │ │ └── parse-prd.js │ │ │ ├── remove-subtask.js │ │ │ ├── remove-task.js │ │ │ ├── research.js │ │ │ ├── response-language.js │ │ │ ├── scope-adjustment.js │ │ │ ├── set-task-status.js │ │ │ ├── tag-management.js │ │ │ ├── task-exists.js │ │ │ ├── update-single-task-status.js │ │ │ ├── update-subtask-by-id.js │ │ │ ├── update-task-by-id.js │ │ │ └── update-tasks.js │ │ ├── task-manager.js │ │ ├── ui.js │ │ ├── update-config-tokens.js │ │ ├── utils │ │ │ ├── contextGatherer.js │ │ │ ├── fuzzyTaskSearch.js │ │ │ └── git-utils.js │ │ └── utils.js │ ├── task-complexity-report.json │ ├── test-claude-errors.js │ └── test-claude.js ├── src │ ├── ai-providers │ │ ├── anthropic.js │ │ ├── azure.js │ │ ├── base-provider.js │ │ ├── bedrock.js │ │ ├── claude-code.js │ │ ├── custom-sdk │ │ │ ├── claude-code │ │ │ │ ├── errors.js │ │ │ │ ├── index.js │ │ │ │ ├── json-extractor.js │ │ │ │ ├── language-model.js │ │ │ │ ├── message-converter.js │ │ │ │ └── types.js │ │ │ └── grok-cli │ │ │ ├── errors.js │ │ │ ├── index.js │ │ │ ├── json-extractor.js │ │ │ ├── language-model.js │ │ │ ├── message-converter.js │ │ │ └── types.js │ │ ├── gemini-cli.js │ │ ├── google-vertex.js │ │ ├── google.js │ │ ├── grok-cli.js │ │ ├── groq.js │ │ ├── index.js │ │ ├── ollama.js │ │ ├── openai.js │ │ ├── openrouter.js │ │ ├── perplexity.js │ │ └── xai.js │ ├── constants │ │ ├── commands.js │ │ ├── paths.js │ │ ├── profiles.js │ │ ├── providers.js │ │ ├── rules-actions.js │ │ ├── task-priority.js │ │ └── task-status.js │ ├── profiles │ │ ├── amp.js │ │ ├── base-profile.js │ │ ├── claude.js │ │ ├── cline.js │ │ ├── codex.js │ │ ├── cursor.js │ │ ├── gemini.js │ │ ├── index.js │ │ ├── kilo.js │ │ ├── kiro.js │ │ ├── opencode.js │ │ ├── roo.js │ │ ├── trae.js │ │ ├── vscode.js │ │ ├── windsurf.js │ │ └── zed.js │ ├── progress │ │ ├── base-progress-tracker.js │ │ ├── cli-progress-factory.js │ │ ├── parse-prd-tracker.js │ │ ├── progress-tracker-builder.js │ │ └── tracker-ui.js │ ├── prompts │ │ ├── add-task.json │ │ ├── analyze-complexity.json │ │ ├── expand-task.json │ │ ├── parse-prd.json │ │ ├── README.md │ │ ├── research.json │ │ ├── schemas │ │ │ ├── parameter.schema.json │ │ │ ├── prompt-template.schema.json │ │ │ ├── README.md │ │ │ └── variant.schema.json │ │ ├── update-subtask.json │ │ ├── update-task.json │ │ └── update-tasks.json │ ├── provider-registry │ │ └── index.js │ ├── task-master.js │ ├── ui │ │ ├── confirm.js │ │ ├── indicators.js │ │ └── parse-prd.js │ └── utils │ ├── asset-resolver.js │ ├── create-mcp-config.js │ ├── format.js │ ├── getVersion.js │ ├── logger-utils.js │ ├── manage-gitignore.js │ ├── path-utils.js │ ├── profiles.js │ ├── rule-transformer.js │ ├── stream-parser.js │ └── timeout-manager.js ├── test-clean-tags.js ├── test-config-manager.js ├── test-prd.txt ├── test-tag-functions.js ├── test-version-check-full.js ├── test-version-check.js ├── tests │ ├── e2e │ │ ├── e2e_helpers.sh │ │ ├── parse_llm_output.cjs │ │ ├── run_e2e.sh │ │ ├── run_fallback_verification.sh │ │ └── test_llm_analysis.sh │ ├── fixture │ │ └── test-tasks.json │ ├── fixtures │ │ ├── .taskmasterconfig │ │ ├── sample-claude-response.js │ │ ├── sample-prd.txt │ │ └── sample-tasks.js │ ├── integration │ │ ├── claude-code-optional.test.js │ │ ├── cli │ │ │ ├── commands.test.js │ │ │ ├── complex-cross-tag-scenarios.test.js │ │ │ └── move-cross-tag.test.js │ │ ├── manage-gitignore.test.js │ │ ├── mcp-server │ │ │ └── direct-functions.test.js │ │ ├── move-task-cross-tag.integration.test.js │ │ ├── move-task-simple.integration.test.js │ │ └── profiles │ │ ├── amp-init-functionality.test.js │ │ ├── claude-init-functionality.test.js │ │ ├── cline-init-functionality.test.js │ │ ├── codex-init-functionality.test.js │ │ ├── cursor-init-functionality.test.js │ │ ├── gemini-init-functionality.test.js │ │ ├── opencode-init-functionality.test.js │ │ ├── roo-files-inclusion.test.js │ │ ├── roo-init-functionality.test.js │ │ ├── rules-files-inclusion.test.js │ │ ├── trae-init-functionality.test.js │ │ ├── vscode-init-functionality.test.js │ │ └── windsurf-init-functionality.test.js │ ├── manual │ │ ├── progress │ │ │ ├── parse-prd-analysis.js │ │ │ ├── test-parse-prd.js │ │ │ └── TESTING_GUIDE.md │ │ └── prompts │ │ ├── prompt-test.js │ │ └── README.md │ ├── README.md │ ├── setup.js │ └── unit │ ├── ai-providers │ │ ├── claude-code.test.js │ │ ├── custom-sdk │ │ │ └── claude-code │ │ │ └── language-model.test.js │ │ ├── gemini-cli.test.js │ │ ├── mcp-components.test.js │ │ └── openai.test.js │ ├── ai-services-unified.test.js │ ├── commands.test.js │ ├── config-manager.test.js │ ├── config-manager.test.mjs │ ├── dependency-manager.test.js │ ├── init.test.js │ ├── initialize-project.test.js │ ├── kebab-case-validation.test.js │ ├── manage-gitignore.test.js │ ├── mcp │ │ └── tools │ │ ├── __mocks__ │ │ │ └── move-task.js │ │ ├── add-task.test.js │ │ ├── analyze-complexity.test.js │ │ ├── expand-all.test.js │ │ ├── get-tasks.test.js │ │ ├── initialize-project.test.js │ │ ├── move-task-cross-tag-options.test.js │ │ ├── move-task-cross-tag.test.js │ │ └── remove-task.test.js │ ├── mcp-providers │ │ ├── mcp-components.test.js │ │ └── mcp-provider.test.js │ ├── parse-prd.test.js │ ├── profiles │ │ ├── amp-integration.test.js │ │ ├── claude-integration.test.js │ │ ├── cline-integration.test.js │ │ ├── codex-integration.test.js │ │ ├── cursor-integration.test.js │ │ ├── gemini-integration.test.js │ │ ├── kilo-integration.test.js │ │ ├── kiro-integration.test.js │ │ ├── mcp-config-validation.test.js │ │ ├── opencode-integration.test.js │ │ ├── profile-safety-check.test.js │ │ ├── roo-integration.test.js │ │ ├── rule-transformer-cline.test.js │ │ ├── rule-transformer-cursor.test.js │ │ ├── rule-transformer-gemini.test.js │ │ ├── rule-transformer-kilo.test.js │ │ ├── rule-transformer-kiro.test.js │ │ ├── rule-transformer-opencode.test.js │ │ ├── rule-transformer-roo.test.js │ │ ├── rule-transformer-trae.test.js │ │ ├── rule-transformer-vscode.test.js │ │ ├── rule-transformer-windsurf.test.js │ │ ├── rule-transformer-zed.test.js │ │ ├── rule-transformer.test.js │ │ ├── selective-profile-removal.test.js │ │ ├── subdirectory-support.test.js │ │ ├── trae-integration.test.js │ │ ├── vscode-integration.test.js │ │ ├── windsurf-integration.test.js │ │ └── zed-integration.test.js │ ├── progress │ │ └── base-progress-tracker.test.js │ ├── prompt-manager.test.js │ ├── prompts │ │ └── expand-task-prompt.test.js │ ├── providers │ │ └── provider-registry.test.js │ ├── scripts │ │ └── modules │ │ ├── commands │ │ │ ├── move-cross-tag.test.js │ │ │ └── README.md │ │ ├── dependency-manager │ │ │ ├── circular-dependencies.test.js │ │ │ ├── cross-tag-dependencies.test.js │ │ │ └── fix-dependencies-command.test.js │ │ ├── task-manager │ │ │ ├── add-subtask.test.js │ │ │ ├── add-task.test.js │ │ │ ├── analyze-task-complexity.test.js │ │ │ ├── clear-subtasks.test.js │ │ │ ├── complexity-report-tag-isolation.test.js │ │ │ ├── expand-all-tasks.test.js │ │ │ ├── expand-task.test.js │ │ │ ├── find-next-task.test.js │ │ │ ├── generate-task-files.test.js │ │ │ ├── list-tasks.test.js │ │ │ ├── move-task-cross-tag.test.js │ │ │ ├── move-task.test.js │ │ │ ├── parse-prd.test.js │ │ │ ├── remove-subtask.test.js │ │ │ ├── remove-task.test.js │ │ │ ├── research.test.js │ │ │ ├── scope-adjustment.test.js │ │ │ ├── set-task-status.test.js │ │ │ ├── setup.js │ │ │ ├── update-single-task-status.test.js │ │ │ ├── update-subtask-by-id.test.js │ │ │ ├── update-task-by-id.test.js │ │ │ └── update-tasks.test.js │ │ ├── ui │ │ │ └── cross-tag-error-display.test.js │ │ └── utils-tag-aware-paths.test.js │ ├── task-finder.test.js │ ├── task-manager │ │ ├── clear-subtasks.test.js │ │ ├── move-task.test.js │ │ ├── tag-boundary.test.js │ │ └── tag-management.test.js │ ├── task-master.test.js │ ├── ui │ │ └── indicators.test.js │ ├── ui.test.js │ ├── utils-strip-ansi.test.js │ └── utils.test.js ├── tsconfig.json ├── tsdown.config.ts └── turbo.json ``` # Files -------------------------------------------------------------------------------- /scripts/modules/task-manager/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 | 22 | /** 23 | * Create a new tag context 24 | * @param {string} tasksPath - Path to the tasks.json file 25 | * @param {string} tagName - Name of the new tag to create 26 | * @param {Object} options - Options object 27 | * @param {boolean} [options.copyFromCurrent=false] - Whether to copy tasks from current tag 28 | * @param {string} [options.copyFromTag] - Specific tag to copy tasks from 29 | * @param {string} [options.description] - Optional description for the tag 30 | * @param {Object} context - Context object containing session and projectRoot 31 | * @param {string} [context.projectRoot] - Project root path 32 | * @param {Object} [context.mcpLog] - MCP logger object (optional) 33 | * @param {string} outputFormat - Output format (text or json) 34 | * @returns {Promise<Object>} Result object with tag creation details 35 | */ 36 | async function createTag( 37 | tasksPath, 38 | tagName, 39 | options = {}, 40 | context = {}, 41 | outputFormat = 'text' 42 | ) { 43 | const { mcpLog, projectRoot } = context; 44 | const { copyFromCurrent = false, copyFromTag, description } = options; 45 | 46 | // Create a consistent logFn object regardless of context 47 | const logFn = mcpLog || { 48 | info: (...args) => log('info', ...args), 49 | warn: (...args) => log('warn', ...args), 50 | error: (...args) => log('error', ...args), 51 | debug: (...args) => log('debug', ...args), 52 | success: (...args) => log('success', ...args) 53 | }; 54 | 55 | try { 56 | // Validate tag name 57 | if (!tagName || typeof tagName !== 'string') { 58 | throw new Error('Tag name is required and must be a string'); 59 | } 60 | 61 | // Validate tag name format (alphanumeric, hyphens, underscores only) 62 | if (!/^[a-zA-Z0-9_-]+$/.test(tagName)) { 63 | throw new Error( 64 | 'Tag name can only contain letters, numbers, hyphens, and underscores' 65 | ); 66 | } 67 | 68 | // Reserved tag names 69 | const reservedNames = ['master', 'main', 'default']; 70 | if (reservedNames.includes(tagName.toLowerCase())) { 71 | throw new Error(`"${tagName}" is a reserved tag name`); 72 | } 73 | 74 | logFn.info(`Creating new tag: ${tagName}`); 75 | 76 | // Read current tasks data 77 | const data = readJSON(tasksPath, projectRoot); 78 | if (!data) { 79 | throw new Error(`Could not read tasks file at ${tasksPath}`); 80 | } 81 | 82 | // Use raw tagged data for tag operations - ensure we get the actual tagged structure 83 | let rawData; 84 | if (data._rawTaggedData) { 85 | // If we have _rawTaggedData, use it (this is the clean tagged structure) 86 | rawData = data._rawTaggedData; 87 | } else if (data.tasks && !data.master) { 88 | // This is legacy format - create a master tag structure 89 | rawData = { 90 | master: { 91 | tasks: data.tasks, 92 | metadata: data.metadata || { 93 | created: new Date().toISOString(), 94 | updated: new Date().toISOString(), 95 | description: 'Tasks live here by default' 96 | } 97 | } 98 | }; 99 | } else { 100 | // This is already in tagged format, use it directly but exclude internal fields 101 | rawData = {}; 102 | for (const [key, value] of Object.entries(data)) { 103 | if (key !== '_rawTaggedData' && key !== 'tag') { 104 | rawData[key] = value; 105 | } 106 | } 107 | } 108 | 109 | // Check if tag already exists 110 | if (rawData[tagName]) { 111 | throw new Error(`Tag "${tagName}" already exists`); 112 | } 113 | 114 | // Determine source for copying tasks (only if explicitly requested) 115 | let sourceTasks = []; 116 | if (copyFromCurrent || copyFromTag) { 117 | const sourceTag = copyFromTag || getCurrentTag(projectRoot); 118 | sourceTasks = getTasksForTag(rawData, sourceTag); 119 | 120 | if (copyFromTag && sourceTasks.length === 0) { 121 | logFn.warn(`Source tag "${copyFromTag}" not found or has no tasks`); 122 | } 123 | 124 | logFn.info(`Copying ${sourceTasks.length} tasks from tag "${sourceTag}"`); 125 | } else { 126 | logFn.info('Creating empty tag (no tasks copied)'); 127 | } 128 | 129 | // Create the new tag structure in raw data 130 | rawData[tagName] = { 131 | tasks: [...sourceTasks], // Create a copy of the tasks array 132 | metadata: { 133 | created: new Date().toISOString(), 134 | updated: new Date().toISOString(), 135 | description: 136 | description || `Tag created on ${new Date().toLocaleDateString()}` 137 | } 138 | }; 139 | 140 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption) 141 | const cleanData = {}; 142 | for (const [key, value] of Object.entries(rawData)) { 143 | if (key !== '_rawTaggedData') { 144 | cleanData[key] = value; 145 | } 146 | } 147 | 148 | // Write the clean data back to file with proper context to avoid tag corruption 149 | writeJSON(tasksPath, cleanData, projectRoot); 150 | 151 | logFn.success(`Successfully created tag "${tagName}"`); 152 | 153 | // For JSON output, return structured data 154 | if (outputFormat === 'json') { 155 | return { 156 | tagName, 157 | created: true, 158 | tasksCopied: sourceTasks.length, 159 | sourceTag: 160 | copyFromCurrent || copyFromTag 161 | ? copyFromTag || getCurrentTag(projectRoot) 162 | : null, 163 | description: 164 | description || `Tag created on ${new Date().toLocaleDateString()}` 165 | }; 166 | } 167 | 168 | // For text output, display success message 169 | if (outputFormat === 'text') { 170 | console.log( 171 | boxen( 172 | chalk.green.bold('✓ Tag Created Successfully') + 173 | `\n\nTag Name: ${chalk.cyan(tagName)}` + 174 | `\nTasks Copied: ${chalk.yellow(sourceTasks.length)}` + 175 | (copyFromCurrent || copyFromTag 176 | ? `\nSource Tag: ${chalk.cyan(copyFromTag || getCurrentTag(projectRoot))}` 177 | : '') + 178 | (description ? `\nDescription: ${chalk.gray(description)}` : ''), 179 | { 180 | padding: 1, 181 | borderColor: 'green', 182 | borderStyle: 'round', 183 | margin: { top: 1, bottom: 1 } 184 | } 185 | ) 186 | ); 187 | } 188 | 189 | return { 190 | tagName, 191 | created: true, 192 | tasksCopied: sourceTasks.length, 193 | sourceTag: 194 | copyFromCurrent || copyFromTag 195 | ? copyFromTag || getCurrentTag(projectRoot) 196 | : null, 197 | description: 198 | description || `Tag created on ${new Date().toLocaleDateString()}` 199 | }; 200 | } catch (error) { 201 | logFn.error(`Error creating tag: ${error.message}`); 202 | throw error; 203 | } 204 | } 205 | 206 | /** 207 | * Delete an existing tag 208 | * @param {string} tasksPath - Path to the tasks.json file 209 | * @param {string} tagName - Name of the tag to delete 210 | * @param {Object} options - Options object 211 | * @param {boolean} [options.yes=false] - Skip confirmation prompts 212 | * @param {Object} context - Context object containing session and projectRoot 213 | * @param {string} [context.projectRoot] - Project root path 214 | * @param {Object} [context.mcpLog] - MCP logger object (optional) 215 | * @param {string} outputFormat - Output format (text or json) 216 | * @returns {Promise<Object>} Result object with deletion details 217 | */ 218 | async function deleteTag( 219 | tasksPath, 220 | tagName, 221 | options = {}, 222 | context = {}, 223 | outputFormat = 'text' 224 | ) { 225 | const { mcpLog, projectRoot } = context; 226 | const { yes = false } = options; 227 | 228 | // Create a consistent logFn object regardless of context 229 | const logFn = mcpLog || { 230 | info: (...args) => log('info', ...args), 231 | warn: (...args) => log('warn', ...args), 232 | error: (...args) => log('error', ...args), 233 | debug: (...args) => log('debug', ...args), 234 | success: (...args) => log('success', ...args) 235 | }; 236 | 237 | try { 238 | // Validate tag name 239 | if (!tagName || typeof tagName !== 'string') { 240 | throw new Error('Tag name is required and must be a string'); 241 | } 242 | 243 | // Prevent deletion of master tag 244 | if (tagName === 'master') { 245 | throw new Error('Cannot delete the "master" tag'); 246 | } 247 | 248 | logFn.info(`Deleting tag: ${tagName}`); 249 | 250 | // Read current tasks data 251 | const data = readJSON(tasksPath, projectRoot); 252 | if (!data) { 253 | throw new Error(`Could not read tasks file at ${tasksPath}`); 254 | } 255 | 256 | // Use raw tagged data for tag operations - ensure we get the actual tagged structure 257 | let rawData; 258 | if (data._rawTaggedData) { 259 | // If we have _rawTaggedData, use it (this is the clean tagged structure) 260 | rawData = data._rawTaggedData; 261 | } else if (data.tasks && !data.master) { 262 | // This is legacy format - create a master tag structure 263 | rawData = { 264 | master: { 265 | tasks: data.tasks, 266 | metadata: data.metadata || { 267 | created: new Date().toISOString(), 268 | updated: new Date().toISOString(), 269 | description: 'Tasks live here by default' 270 | } 271 | } 272 | }; 273 | } else { 274 | // This is already in tagged format, use it directly but exclude internal fields 275 | rawData = {}; 276 | for (const [key, value] of Object.entries(data)) { 277 | if (key !== '_rawTaggedData' && key !== 'tag') { 278 | rawData[key] = value; 279 | } 280 | } 281 | } 282 | 283 | // Check if tag exists 284 | if (!rawData[tagName]) { 285 | throw new Error(`Tag "${tagName}" does not exist`); 286 | } 287 | 288 | // Get current tag to check if we're deleting the active tag 289 | const currentTag = getCurrentTag(projectRoot); 290 | const isCurrentTag = currentTag === tagName; 291 | 292 | // Get task count for confirmation 293 | const tasks = getTasksForTag(rawData, tagName); 294 | const taskCount = tasks.length; 295 | 296 | // If not forced and has tasks, require confirmation (for CLI) 297 | if (!yes && taskCount > 0 && outputFormat === 'text') { 298 | console.log( 299 | boxen( 300 | chalk.yellow.bold('⚠ WARNING: Tag Deletion') + 301 | `\n\nYou are about to delete tag "${chalk.cyan(tagName)}"` + 302 | `\nThis will permanently delete ${chalk.red.bold(taskCount)} tasks` + 303 | '\n\nThis action cannot be undone!', 304 | { 305 | padding: 1, 306 | borderColor: 'yellow', 307 | borderStyle: 'round', 308 | margin: { top: 1, bottom: 1 } 309 | } 310 | ) 311 | ); 312 | 313 | // First confirmation 314 | const firstConfirm = await inquirer.prompt([ 315 | { 316 | type: 'confirm', 317 | name: 'proceed', 318 | message: `Are you sure you want to delete tag "${tagName}" and its ${taskCount} tasks?`, 319 | default: false 320 | } 321 | ]); 322 | 323 | if (!firstConfirm.proceed) { 324 | logFn.info('Tag deletion cancelled by user'); 325 | throw new Error('Tag deletion cancelled'); 326 | } 327 | 328 | // Second confirmation (double-check) 329 | const secondConfirm = await inquirer.prompt([ 330 | { 331 | type: 'input', 332 | name: 'tagNameConfirm', 333 | message: `To confirm deletion, please type the tag name "${tagName}":`, 334 | validate: (input) => { 335 | if (input === tagName) { 336 | return true; 337 | } 338 | return `Please type exactly "${tagName}" to confirm deletion`; 339 | } 340 | } 341 | ]); 342 | 343 | if (secondConfirm.tagNameConfirm !== tagName) { 344 | logFn.info('Tag deletion cancelled - incorrect tag name confirmation'); 345 | throw new Error('Tag deletion cancelled'); 346 | } 347 | 348 | logFn.info('Double confirmation received, proceeding with deletion...'); 349 | } 350 | 351 | // Delete the tag 352 | delete rawData[tagName]; 353 | 354 | // If we're deleting the current tag, switch to master 355 | if (isCurrentTag) { 356 | await switchCurrentTag(projectRoot, 'master'); 357 | logFn.info('Switched current tag to "master"'); 358 | } 359 | 360 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption) 361 | const cleanData = {}; 362 | for (const [key, value] of Object.entries(rawData)) { 363 | if (key !== '_rawTaggedData') { 364 | cleanData[key] = value; 365 | } 366 | } 367 | 368 | // Write the clean data back to file with proper context to avoid tag corruption 369 | writeJSON(tasksPath, cleanData, projectRoot); 370 | 371 | logFn.success(`Successfully deleted tag "${tagName}"`); 372 | 373 | // For JSON output, return structured data 374 | if (outputFormat === 'json') { 375 | return { 376 | tagName, 377 | deleted: true, 378 | tasksDeleted: taskCount, 379 | wasCurrentTag: isCurrentTag, 380 | switchedToMaster: isCurrentTag 381 | }; 382 | } 383 | 384 | // For text output, display success message 385 | if (outputFormat === 'text') { 386 | console.log( 387 | boxen( 388 | chalk.red.bold('✓ Tag Deleted Successfully') + 389 | `\n\nTag Name: ${chalk.cyan(tagName)}` + 390 | `\nTasks Deleted: ${chalk.yellow(taskCount)}` + 391 | (isCurrentTag 392 | ? `\n${chalk.yellow('⚠ Switched current tag to "master"')}` 393 | : ''), 394 | { 395 | padding: 1, 396 | borderColor: 'red', 397 | borderStyle: 'round', 398 | margin: { top: 1, bottom: 1 } 399 | } 400 | ) 401 | ); 402 | } 403 | 404 | return { 405 | tagName, 406 | deleted: true, 407 | tasksDeleted: taskCount, 408 | wasCurrentTag: isCurrentTag, 409 | switchedToMaster: isCurrentTag 410 | }; 411 | } catch (error) { 412 | logFn.error(`Error deleting tag: ${error.message}`); 413 | throw error; 414 | } 415 | } 416 | 417 | /** 418 | * Enhance existing tags with metadata if they don't have it 419 | * @param {string} tasksPath - Path to the tasks.json file 420 | * @param {Object} rawData - The raw tagged data 421 | * @param {Object} context - Context object 422 | * @returns {Promise<boolean>} True if any tags were enhanced 423 | */ 424 | async function enhanceTagsWithMetadata(tasksPath, rawData, context = {}) { 425 | let enhanced = false; 426 | 427 | try { 428 | // Get file stats for creation date fallback 429 | let fileCreatedDate; 430 | try { 431 | const stats = fs.statSync(tasksPath); 432 | fileCreatedDate = 433 | stats.birthtime < stats.mtime ? stats.birthtime : stats.mtime; 434 | } catch (error) { 435 | fileCreatedDate = new Date(); 436 | } 437 | 438 | for (const [tagName, tagData] of Object.entries(rawData)) { 439 | // Skip non-tag properties 440 | if ( 441 | tagName === 'tasks' || 442 | tagName === 'tag' || 443 | tagName === '_rawTaggedData' || 444 | !tagData || 445 | typeof tagData !== 'object' || 446 | !Array.isArray(tagData.tasks) 447 | ) { 448 | continue; 449 | } 450 | 451 | // Check if tag needs metadata enhancement 452 | if (!tagData.metadata) { 453 | tagData.metadata = {}; 454 | enhanced = true; 455 | } 456 | 457 | // Add missing metadata fields 458 | if (!tagData.metadata.created) { 459 | tagData.metadata.created = fileCreatedDate.toISOString(); 460 | enhanced = true; 461 | } 462 | 463 | if (!tagData.metadata.description) { 464 | if (tagName === 'master') { 465 | tagData.metadata.description = 'Tasks live here by default'; 466 | } else { 467 | tagData.metadata.description = `Tag created on ${new Date(tagData.metadata.created).toLocaleDateString()}`; 468 | } 469 | enhanced = true; 470 | } 471 | 472 | // Add updated field if missing (set to created date initially) 473 | if (!tagData.metadata.updated) { 474 | tagData.metadata.updated = tagData.metadata.created; 475 | enhanced = true; 476 | } 477 | } 478 | 479 | // If we enhanced any tags, write the data back 480 | if (enhanced) { 481 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption) 482 | const cleanData = {}; 483 | for (const [key, value] of Object.entries(rawData)) { 484 | if (key !== '_rawTaggedData') { 485 | cleanData[key] = value; 486 | } 487 | } 488 | writeJSON(tasksPath, cleanData, context.projectRoot); 489 | } 490 | } catch (error) { 491 | // Don't throw - just log and continue 492 | const logFn = context.mcpLog || { 493 | warn: (...args) => log('warn', ...args) 494 | }; 495 | logFn.warn(`Could not enhance tag metadata: ${error.message}`); 496 | } 497 | 498 | return enhanced; 499 | } 500 | 501 | /** 502 | * List all available tags with metadata 503 | * @param {string} tasksPath - Path to the tasks.json file 504 | * @param {Object} options - Options object 505 | * @param {boolean} [options.showTaskCounts=true] - Whether to show task counts 506 | * @param {boolean} [options.showMetadata=false] - Whether to show metadata 507 | * @param {Object} context - Context object containing session and projectRoot 508 | * @param {string} [context.projectRoot] - Project root path 509 | * @param {Object} [context.mcpLog] - MCP logger object (optional) 510 | * @param {string} outputFormat - Output format (text or json) 511 | * @returns {Promise<Object>} Result object with tags list 512 | */ 513 | async function tags( 514 | tasksPath, 515 | options = {}, 516 | context = {}, 517 | outputFormat = 'text' 518 | ) { 519 | const { mcpLog, projectRoot } = context; 520 | const { showTaskCounts = true, showMetadata = false } = options; 521 | 522 | // Create a consistent logFn object regardless of context 523 | const logFn = mcpLog || { 524 | info: (...args) => log('info', ...args), 525 | warn: (...args) => log('warn', ...args), 526 | error: (...args) => log('error', ...args), 527 | debug: (...args) => log('debug', ...args), 528 | success: (...args) => log('success', ...args) 529 | }; 530 | 531 | try { 532 | logFn.info('Listing available tags'); 533 | 534 | // Read current tasks data 535 | const data = readJSON(tasksPath, projectRoot); 536 | if (!data) { 537 | throw new Error(`Could not read tasks file at ${tasksPath}`); 538 | } 539 | 540 | // Get current tag 541 | const currentTag = getCurrentTag(projectRoot); 542 | 543 | // Use raw tagged data if available, otherwise use the data directly 544 | const rawData = data._rawTaggedData || data; 545 | 546 | // Enhance existing tags with metadata if they don't have it 547 | await enhanceTagsWithMetadata(tasksPath, rawData, context); 548 | 549 | // Extract all tags 550 | const tagList = []; 551 | for (const [tagName, tagData] of Object.entries(rawData)) { 552 | // Skip non-tag properties (like legacy 'tasks' array, 'tag', '_rawTaggedData') 553 | if ( 554 | tagName === 'tasks' || 555 | tagName === 'tag' || 556 | tagName === '_rawTaggedData' || 557 | !tagData || 558 | typeof tagData !== 'object' || 559 | !Array.isArray(tagData.tasks) 560 | ) { 561 | continue; 562 | } 563 | 564 | const tasks = tagData.tasks || []; 565 | const metadata = tagData.metadata || {}; 566 | 567 | tagList.push({ 568 | name: tagName, 569 | isCurrent: tagName === currentTag, 570 | completedTasks: tasks.filter( 571 | (t) => t.status === 'done' || t.status === 'completed' 572 | ).length, 573 | tasks: tasks || [], 574 | created: metadata.created || 'Unknown', 575 | description: metadata.description || 'No description' 576 | }); 577 | } 578 | 579 | // Sort tags: current tag first, then alphabetically 580 | tagList.sort((a, b) => { 581 | if (a.isCurrent) return -1; 582 | if (b.isCurrent) return 1; 583 | return a.name.localeCompare(b.name); 584 | }); 585 | 586 | logFn.success(`Found ${tagList.length} tags`); 587 | 588 | // For JSON output, return structured data 589 | if (outputFormat === 'json') { 590 | return { 591 | tags: tagList, 592 | currentTag, 593 | totalTags: tagList.length 594 | }; 595 | } 596 | 597 | // For text output, display formatted table 598 | if (outputFormat === 'text') { 599 | if (tagList.length === 0) { 600 | console.log( 601 | boxen(chalk.yellow('No tags found'), { 602 | padding: 1, 603 | borderColor: 'yellow', 604 | borderStyle: 'round', 605 | margin: { top: 1, bottom: 1 } 606 | }) 607 | ); 608 | return { tags: [], currentTag, totalTags: 0 }; 609 | } 610 | 611 | // Create table headers based on options 612 | const headers = [chalk.cyan.bold('Tag Name')]; 613 | if (showTaskCounts) { 614 | headers.push(chalk.cyan.bold('Tasks')); 615 | headers.push(chalk.cyan.bold('Completed')); 616 | } 617 | if (showMetadata) { 618 | headers.push(chalk.cyan.bold('Created')); 619 | headers.push(chalk.cyan.bold('Description')); 620 | } 621 | 622 | const table = new Table({ 623 | head: headers, 624 | colWidths: showMetadata ? [20, 10, 12, 15, 50] : [25, 10, 12] 625 | }); 626 | 627 | // Add rows 628 | tagList.forEach((tag) => { 629 | const row = []; 630 | 631 | // Tag name with current indicator 632 | const tagDisplay = tag.isCurrent 633 | ? `${chalk.green('●')} ${chalk.green.bold(tag.name)} ${chalk.gray('(current)')}` 634 | : ` ${tag.name}`; 635 | row.push(tagDisplay); 636 | 637 | if (showTaskCounts) { 638 | row.push(chalk.white(tag.tasks.length.toString())); 639 | row.push(chalk.green(tag.completedTasks.toString())); 640 | } 641 | 642 | if (showMetadata) { 643 | const createdDate = 644 | tag.created !== 'Unknown' 645 | ? new Date(tag.created).toLocaleDateString() 646 | : 'Unknown'; 647 | row.push(chalk.gray(createdDate)); 648 | row.push(chalk.gray(truncate(tag.description, 50))); 649 | } 650 | 651 | table.push(row); 652 | }); 653 | 654 | // console.log( 655 | // boxen( 656 | // chalk.white.bold('Available Tags') + 657 | // `\n\nCurrent Tag: ${chalk.green.bold(currentTag)}`, 658 | // { 659 | // padding: { top: 0, bottom: 1, left: 1, right: 1 }, 660 | // borderColor: 'blue', 661 | // borderStyle: 'round', 662 | // margin: { top: 1, bottom: 0 } 663 | // } 664 | // ) 665 | // ); 666 | 667 | console.log(table.toString()); 668 | } 669 | 670 | return { 671 | tags: tagList, 672 | currentTag, 673 | totalTags: tagList.length 674 | }; 675 | } catch (error) { 676 | logFn.error(`Error listing tags: ${error.message}`); 677 | throw error; 678 | } 679 | } 680 | 681 | /** 682 | * Switch to a different tag context 683 | * @param {string} tasksPath - Path to the tasks.json file 684 | * @param {string} tagName - Name of the tag to switch to 685 | * @param {Object} options - Options object 686 | * @param {Object} context - Context object containing session and projectRoot 687 | * @param {string} [context.projectRoot] - Project root path 688 | * @param {Object} [context.mcpLog] - MCP logger object (optional) 689 | * @param {string} outputFormat - Output format (text or json) 690 | * @returns {Promise<Object>} Result object with switch details 691 | */ 692 | async function useTag( 693 | tasksPath, 694 | tagName, 695 | options = {}, 696 | context = {}, 697 | outputFormat = 'text' 698 | ) { 699 | const { mcpLog, projectRoot } = context; 700 | 701 | // Create a consistent logFn object regardless of context 702 | const logFn = mcpLog || { 703 | info: (...args) => log('info', ...args), 704 | warn: (...args) => log('warn', ...args), 705 | error: (...args) => log('error', ...args), 706 | debug: (...args) => log('debug', ...args), 707 | success: (...args) => log('success', ...args) 708 | }; 709 | 710 | try { 711 | // Validate tag name 712 | if (!tagName || typeof tagName !== 'string') { 713 | throw new Error('Tag name is required and must be a string'); 714 | } 715 | 716 | logFn.info(`Switching to tag: ${tagName}`); 717 | 718 | // Read current tasks data to verify tag exists 719 | const data = readJSON(tasksPath, projectRoot); 720 | if (!data) { 721 | throw new Error(`Could not read tasks file at ${tasksPath}`); 722 | } 723 | 724 | // Use raw tagged data to check if tag exists 725 | const rawData = data._rawTaggedData || data; 726 | 727 | // Check if tag exists 728 | if (!rawData[tagName]) { 729 | throw new Error(`Tag "${tagName}" does not exist`); 730 | } 731 | 732 | // Get current tag 733 | const previousTag = getCurrentTag(projectRoot); 734 | 735 | // Switch to the new tag 736 | await switchCurrentTag(projectRoot, tagName); 737 | 738 | // Get task count for the new tag - read tasks specifically for this tag 739 | const tagData = readJSON(tasksPath, projectRoot, tagName); 740 | const tasks = tagData ? tagData.tasks || [] : []; 741 | const taskCount = tasks.length; 742 | 743 | // Find the next task to work on in this tag 744 | const nextTask = findNextTask(tasks); 745 | 746 | logFn.success(`Successfully switched to tag "${tagName}"`); 747 | 748 | // For JSON output, return structured data 749 | if (outputFormat === 'json') { 750 | return { 751 | previousTag, 752 | currentTag: tagName, 753 | switched: true, 754 | taskCount, 755 | nextTask 756 | }; 757 | } 758 | 759 | // For text output, display success message 760 | if (outputFormat === 'text') { 761 | let nextTaskInfo = ''; 762 | if (nextTask) { 763 | nextTaskInfo = `\nNext Task: ${chalk.cyan(`#${nextTask.id}`)} - ${chalk.white(nextTask.title)}`; 764 | } else { 765 | nextTaskInfo = `\nNext Task: ${chalk.gray('No eligible tasks available')}`; 766 | } 767 | 768 | console.log( 769 | boxen( 770 | chalk.green.bold('✓ Tag Switched Successfully') + 771 | `\n\nPrevious Tag: ${chalk.cyan(previousTag)}` + 772 | `\nCurrent Tag: ${chalk.green.bold(tagName)}` + 773 | `\nAvailable Tasks: ${chalk.yellow(taskCount)}` + 774 | nextTaskInfo, 775 | { 776 | padding: 1, 777 | borderColor: 'green', 778 | borderStyle: 'round', 779 | margin: { top: 1, bottom: 1 } 780 | } 781 | ) 782 | ); 783 | } 784 | 785 | return { 786 | previousTag, 787 | currentTag: tagName, 788 | switched: true, 789 | taskCount, 790 | nextTask 791 | }; 792 | } catch (error) { 793 | logFn.error(`Error switching tag: ${error.message}`); 794 | throw error; 795 | } 796 | } 797 | 798 | /** 799 | * Rename an existing tag 800 | * @param {string} tasksPath - Path to the tasks.json file 801 | * @param {string} oldName - Current name of the tag 802 | * @param {string} newName - New name for the tag 803 | * @param {Object} options - Options object 804 | * @param {Object} context - Context object containing session and projectRoot 805 | * @param {string} [context.projectRoot] - Project root path 806 | * @param {Object} [context.mcpLog] - MCP logger object (optional) 807 | * @param {string} outputFormat - Output format (text or json) 808 | * @returns {Promise<Object>} Result object with rename details 809 | */ 810 | async function renameTag( 811 | tasksPath, 812 | oldName, 813 | newName, 814 | options = {}, 815 | context = {}, 816 | outputFormat = 'text' 817 | ) { 818 | const { mcpLog, projectRoot } = context; 819 | 820 | // Create a consistent logFn object regardless of context 821 | const logFn = mcpLog || { 822 | info: (...args) => log('info', ...args), 823 | warn: (...args) => log('warn', ...args), 824 | error: (...args) => log('error', ...args), 825 | debug: (...args) => log('debug', ...args), 826 | success: (...args) => log('success', ...args) 827 | }; 828 | 829 | try { 830 | // Validate parameters 831 | if (!oldName || typeof oldName !== 'string') { 832 | throw new Error('Old tag name is required and must be a string'); 833 | } 834 | if (!newName || typeof newName !== 'string') { 835 | throw new Error('New tag name is required and must be a string'); 836 | } 837 | 838 | // Validate new tag name format 839 | if (!/^[a-zA-Z0-9_-]+$/.test(newName)) { 840 | throw new Error( 841 | 'New tag name can only contain letters, numbers, hyphens, and underscores' 842 | ); 843 | } 844 | 845 | // Prevent renaming master tag 846 | if (oldName === 'master') { 847 | throw new Error('Cannot rename the "master" tag'); 848 | } 849 | 850 | // Reserved tag names 851 | const reservedNames = ['master', 'main', 'default']; 852 | if (reservedNames.includes(newName.toLowerCase())) { 853 | throw new Error(`"${newName}" is a reserved tag name`); 854 | } 855 | 856 | logFn.info(`Renaming tag from "${oldName}" to "${newName}"`); 857 | 858 | // Read current tasks data 859 | const data = readJSON(tasksPath, projectRoot); 860 | if (!data) { 861 | throw new Error(`Could not read tasks file at ${tasksPath}`); 862 | } 863 | 864 | // Use raw tagged data for tag operations 865 | const rawData = data._rawTaggedData || data; 866 | 867 | // Check if old tag exists 868 | if (!rawData[oldName]) { 869 | throw new Error(`Tag "${oldName}" does not exist`); 870 | } 871 | 872 | // Check if new tag name already exists 873 | if (rawData[newName]) { 874 | throw new Error(`Tag "${newName}" already exists`); 875 | } 876 | 877 | // Get current tag to check if we're renaming the active tag 878 | const currentTag = getCurrentTag(projectRoot); 879 | const isCurrentTag = currentTag === oldName; 880 | 881 | // Rename the tag by copying data and deleting old 882 | rawData[newName] = { ...rawData[oldName] }; 883 | 884 | // Update metadata if it exists 885 | if (rawData[newName].metadata) { 886 | rawData[newName].metadata.renamed = { 887 | from: oldName, 888 | date: new Date().toISOString() 889 | }; 890 | } 891 | 892 | delete rawData[oldName]; 893 | 894 | // If we're renaming the current tag, update the current tag reference 895 | if (isCurrentTag) { 896 | await switchCurrentTag(projectRoot, newName); 897 | logFn.info(`Updated current tag reference to "${newName}"`); 898 | } 899 | 900 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption) 901 | const cleanData = {}; 902 | for (const [key, value] of Object.entries(rawData)) { 903 | if (key !== '_rawTaggedData') { 904 | cleanData[key] = value; 905 | } 906 | } 907 | 908 | // Write the clean data back to file with proper context to avoid tag corruption 909 | writeJSON(tasksPath, cleanData, projectRoot); 910 | 911 | // Get task count 912 | const tasks = getTasksForTag(rawData, newName); 913 | const taskCount = tasks.length; 914 | 915 | logFn.success(`Successfully renamed tag from "${oldName}" to "${newName}"`); 916 | 917 | // For JSON output, return structured data 918 | if (outputFormat === 'json') { 919 | return { 920 | oldName, 921 | newName, 922 | renamed: true, 923 | taskCount, 924 | wasCurrentTag: isCurrentTag, 925 | isCurrentTag: isCurrentTag 926 | }; 927 | } 928 | 929 | // For text output, display success message 930 | if (outputFormat === 'text') { 931 | console.log( 932 | boxen( 933 | chalk.green.bold('✓ Tag Renamed Successfully') + 934 | `\n\nOld Name: ${chalk.cyan(oldName)}` + 935 | `\nNew Name: ${chalk.green.bold(newName)}` + 936 | `\nTasks: ${chalk.yellow(taskCount)}` + 937 | (isCurrentTag ? `\n${chalk.green('✓ Current tag updated')}` : ''), 938 | { 939 | padding: 1, 940 | borderColor: 'green', 941 | borderStyle: 'round', 942 | margin: { top: 1, bottom: 1 } 943 | } 944 | ) 945 | ); 946 | } 947 | 948 | return { 949 | oldName, 950 | newName, 951 | renamed: true, 952 | taskCount, 953 | wasCurrentTag: isCurrentTag, 954 | isCurrentTag: isCurrentTag 955 | }; 956 | } catch (error) { 957 | logFn.error(`Error renaming tag: ${error.message}`); 958 | throw error; 959 | } 960 | } 961 | 962 | /** 963 | * Copy an existing tag to create a new tag with the same tasks 964 | * @param {string} tasksPath - Path to the tasks.json file 965 | * @param {string} sourceName - Name of the source tag to copy from 966 | * @param {string} targetName - Name of the new tag to create 967 | * @param {Object} options - Options object 968 | * @param {string} [options.description] - Optional description for the new tag 969 | * @param {Object} context - Context object containing session and projectRoot 970 | * @param {string} [context.projectRoot] - Project root path 971 | * @param {Object} [context.mcpLog] - MCP logger object (optional) 972 | * @param {string} outputFormat - Output format (text or json) 973 | * @returns {Promise<Object>} Result object with copy details 974 | */ 975 | async function copyTag( 976 | tasksPath, 977 | sourceName, 978 | targetName, 979 | options = {}, 980 | context = {}, 981 | outputFormat = 'text' 982 | ) { 983 | const { mcpLog, projectRoot } = context; 984 | const { description } = options; 985 | 986 | // Create a consistent logFn object regardless of context 987 | const logFn = mcpLog || { 988 | info: (...args) => log('info', ...args), 989 | warn: (...args) => log('warn', ...args), 990 | error: (...args) => log('error', ...args), 991 | debug: (...args) => log('debug', ...args), 992 | success: (...args) => log('success', ...args) 993 | }; 994 | 995 | try { 996 | // Validate parameters 997 | if (!sourceName || typeof sourceName !== 'string') { 998 | throw new Error('Source tag name is required and must be a string'); 999 | } 1000 | if (!targetName || typeof targetName !== 'string') { 1001 | throw new Error('Target tag name is required and must be a string'); 1002 | } 1003 | 1004 | // Validate target tag name format 1005 | if (!/^[a-zA-Z0-9_-]+$/.test(targetName)) { 1006 | throw new Error( 1007 | 'Target tag name can only contain letters, numbers, hyphens, and underscores' 1008 | ); 1009 | } 1010 | 1011 | // Reserved tag names 1012 | const reservedNames = ['master', 'main', 'default']; 1013 | if (reservedNames.includes(targetName.toLowerCase())) { 1014 | throw new Error(`"${targetName}" is a reserved tag name`); 1015 | } 1016 | 1017 | logFn.info(`Copying tag from "${sourceName}" to "${targetName}"`); 1018 | 1019 | // Read current tasks data 1020 | const data = readJSON(tasksPath, projectRoot); 1021 | if (!data) { 1022 | throw new Error(`Could not read tasks file at ${tasksPath}`); 1023 | } 1024 | 1025 | // Use raw tagged data for tag operations 1026 | const rawData = data._rawTaggedData || data; 1027 | 1028 | // Check if source tag exists 1029 | if (!rawData[sourceName]) { 1030 | throw new Error(`Source tag "${sourceName}" does not exist`); 1031 | } 1032 | 1033 | // Check if target tag already exists 1034 | if (rawData[targetName]) { 1035 | throw new Error(`Target tag "${targetName}" already exists`); 1036 | } 1037 | 1038 | // Get source tasks 1039 | const sourceTasks = getTasksForTag(rawData, sourceName); 1040 | 1041 | // Create deep copy of the source tag data 1042 | rawData[targetName] = { 1043 | tasks: JSON.parse(JSON.stringify(sourceTasks)), // Deep copy tasks 1044 | metadata: { 1045 | created: new Date().toISOString(), 1046 | updated: new Date().toISOString(), 1047 | description: 1048 | description || 1049 | `Copy of "${sourceName}" created on ${new Date().toLocaleDateString()}`, 1050 | copiedFrom: { 1051 | tag: sourceName, 1052 | date: new Date().toISOString() 1053 | } 1054 | } 1055 | }; 1056 | 1057 | // Create clean data for writing (exclude _rawTaggedData to prevent corruption) 1058 | const cleanData = {}; 1059 | for (const [key, value] of Object.entries(rawData)) { 1060 | if (key !== '_rawTaggedData') { 1061 | cleanData[key] = value; 1062 | } 1063 | } 1064 | 1065 | // Write the clean data back to file with proper context to avoid tag corruption 1066 | writeJSON(tasksPath, cleanData, projectRoot); 1067 | 1068 | logFn.success( 1069 | `Successfully copied tag from "${sourceName}" to "${targetName}"` 1070 | ); 1071 | 1072 | // For JSON output, return structured data 1073 | if (outputFormat === 'json') { 1074 | return { 1075 | sourceName, 1076 | targetName, 1077 | copied: true, 1078 | description: 1079 | description || 1080 | `Copy of "${sourceName}" created on ${new Date().toLocaleDateString()}` 1081 | }; 1082 | } 1083 | 1084 | // For text output, display success message 1085 | if (outputFormat === 'text') { 1086 | console.log( 1087 | boxen( 1088 | chalk.green.bold('✓ Tag Copied Successfully') + 1089 | `\n\nSource Tag: ${chalk.cyan(sourceName)}` + 1090 | `\nTarget Tag: ${chalk.green.bold(targetName)}` + 1091 | `\nTasks Copied: ${chalk.yellow(sourceTasks.length)}` + 1092 | (description ? `\nDescription: ${chalk.gray(description)}` : ''), 1093 | { 1094 | padding: 1, 1095 | borderColor: 'green', 1096 | borderStyle: 'round', 1097 | margin: { top: 1, bottom: 1 } 1098 | } 1099 | ) 1100 | ); 1101 | } 1102 | 1103 | return { 1104 | sourceName, 1105 | targetName, 1106 | copied: true, 1107 | description: 1108 | description || 1109 | `Copy of "${sourceName}" created on ${new Date().toLocaleDateString()}` 1110 | }; 1111 | } catch (error) { 1112 | logFn.error(`Error copying tag: ${error.message}`); 1113 | throw error; 1114 | } 1115 | } 1116 | 1117 | /** 1118 | * Helper function to switch the current tag in state.json 1119 | * @param {string} projectRoot - Project root directory 1120 | * @param {string} tagName - Name of the tag to switch to 1121 | * @returns {Promise<void>} 1122 | */ 1123 | async function switchCurrentTag(projectRoot, tagName) { 1124 | try { 1125 | const statePath = path.join(projectRoot, '.taskmaster', 'state.json'); 1126 | 1127 | // Read current state or create default 1128 | let state = {}; 1129 | if (fs.existsSync(statePath)) { 1130 | const rawState = fs.readFileSync(statePath, 'utf8'); 1131 | state = JSON.parse(rawState); 1132 | } 1133 | 1134 | // Update current tag and timestamp 1135 | state.currentTag = tagName; 1136 | state.lastSwitched = new Date().toISOString(); 1137 | 1138 | // Ensure other required state properties exist 1139 | if (!state.branchTagMapping) { 1140 | state.branchTagMapping = {}; 1141 | } 1142 | if (state.migrationNoticeShown === undefined) { 1143 | state.migrationNoticeShown = false; 1144 | } 1145 | 1146 | // Write updated state 1147 | fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8'); 1148 | } catch (error) { 1149 | log('warn', `Could not update current tag in state.json: ${error.message}`); 1150 | // Don't throw - this is not critical for tag operations 1151 | } 1152 | } 1153 | 1154 | /** 1155 | * Update branch-tag mapping in state.json 1156 | * @param {string} projectRoot - Project root directory 1157 | * @param {string} branchName - Git branch name 1158 | * @param {string} tagName - Tag name to map to 1159 | * @returns {Promise<void>} 1160 | */ 1161 | async function updateBranchTagMapping(projectRoot, branchName, tagName) { 1162 | try { 1163 | const statePath = path.join(projectRoot, '.taskmaster', 'state.json'); 1164 | 1165 | // Read current state or create default 1166 | let state = {}; 1167 | if (fs.existsSync(statePath)) { 1168 | const rawState = fs.readFileSync(statePath, 'utf8'); 1169 | state = JSON.parse(rawState); 1170 | } 1171 | 1172 | // Ensure branchTagMapping exists 1173 | if (!state.branchTagMapping) { 1174 | state.branchTagMapping = {}; 1175 | } 1176 | 1177 | // Update the mapping 1178 | state.branchTagMapping[branchName] = tagName; 1179 | 1180 | // Write updated state 1181 | fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8'); 1182 | } catch (error) { 1183 | log('warn', `Could not update branch-tag mapping: ${error.message}`); 1184 | // Don't throw - this is not critical for tag operations 1185 | } 1186 | } 1187 | 1188 | /** 1189 | * Get tag name for a git branch from state.json mapping 1190 | * @param {string} projectRoot - Project root directory 1191 | * @param {string} branchName - Git branch name 1192 | * @returns {Promise<string|null>} Mapped tag name or null if not found 1193 | */ 1194 | async function getTagForBranch(projectRoot, branchName) { 1195 | try { 1196 | const statePath = path.join(projectRoot, '.taskmaster', 'state.json'); 1197 | 1198 | if (!fs.existsSync(statePath)) { 1199 | return null; 1200 | } 1201 | 1202 | const rawState = fs.readFileSync(statePath, 'utf8'); 1203 | const state = JSON.parse(rawState); 1204 | 1205 | return state.branchTagMapping?.[branchName] || null; 1206 | } catch (error) { 1207 | return null; 1208 | } 1209 | } 1210 | 1211 | /** 1212 | * Create a tag from a git branch name 1213 | * @param {string} tasksPath - Path to the tasks.json file 1214 | * @param {string} branchName - Git branch name to create tag from 1215 | * @param {Object} options - Options object 1216 | * @param {boolean} [options.copyFromCurrent] - Copy tasks from current tag 1217 | * @param {string} [options.copyFromTag] - Copy tasks from specific tag 1218 | * @param {string} [options.description] - Custom description for the tag 1219 | * @param {boolean} [options.autoSwitch] - Automatically switch to the new tag 1220 | * @param {Object} context - Context object containing session and projectRoot 1221 | * @param {string} [context.projectRoot] - Project root path 1222 | * @param {Object} [context.mcpLog] - MCP logger object (optional) 1223 | * @param {string} outputFormat - Output format (text or json) 1224 | * @returns {Promise<Object>} Result object with creation details 1225 | */ 1226 | async function createTagFromBranch( 1227 | tasksPath, 1228 | branchName, 1229 | options = {}, 1230 | context = {}, 1231 | outputFormat = 'text' 1232 | ) { 1233 | const { mcpLog, projectRoot } = context; 1234 | const { copyFromCurrent, copyFromTag, description, autoSwitch } = options; 1235 | 1236 | // Import git utilities 1237 | const { sanitizeBranchNameForTag, isValidBranchForTag } = await import( 1238 | '../utils/git-utils.js' 1239 | ); 1240 | 1241 | // Create a consistent logFn object regardless of context 1242 | const logFn = mcpLog || { 1243 | info: (...args) => log('info', ...args), 1244 | warn: (...args) => log('warn', ...args), 1245 | error: (...args) => log('error', ...args), 1246 | debug: (...args) => log('debug', ...args), 1247 | success: (...args) => log('success', ...args) 1248 | }; 1249 | 1250 | try { 1251 | // Validate branch name 1252 | if (!branchName || typeof branchName !== 'string') { 1253 | throw new Error('Branch name is required and must be a string'); 1254 | } 1255 | 1256 | // Check if branch name is valid for tag creation 1257 | if (!isValidBranchForTag(branchName)) { 1258 | throw new Error( 1259 | `Branch "${branchName}" cannot be converted to a valid tag name` 1260 | ); 1261 | } 1262 | 1263 | // Sanitize branch name to create tag name 1264 | const tagName = sanitizeBranchNameForTag(branchName); 1265 | 1266 | logFn.info(`Creating tag "${tagName}" from git branch "${branchName}"`); 1267 | 1268 | // Create the tag using existing createTag function 1269 | const createResult = await createTag( 1270 | tasksPath, 1271 | tagName, 1272 | { 1273 | copyFromCurrent, 1274 | copyFromTag, 1275 | description: 1276 | description || `Tag created from git branch "${branchName}"` 1277 | }, 1278 | context, 1279 | outputFormat 1280 | ); 1281 | 1282 | // Update branch-tag mapping 1283 | await updateBranchTagMapping(projectRoot, branchName, tagName); 1284 | logFn.info(`Updated branch-tag mapping: ${branchName} -> ${tagName}`); 1285 | 1286 | // Auto-switch to the new tag if requested 1287 | if (autoSwitch) { 1288 | await switchCurrentTag(projectRoot, tagName); 1289 | logFn.info(`Automatically switched to tag "${tagName}"`); 1290 | } 1291 | 1292 | // For JSON output, return structured data 1293 | if (outputFormat === 'json') { 1294 | return { 1295 | ...createResult, 1296 | branchName, 1297 | tagName, 1298 | mappingUpdated: true, 1299 | autoSwitched: autoSwitch || false 1300 | }; 1301 | } 1302 | 1303 | // For text output, the createTag function already handles display 1304 | return { 1305 | branchName, 1306 | tagName, 1307 | created: true, 1308 | mappingUpdated: true, 1309 | autoSwitched: autoSwitch || false 1310 | }; 1311 | } catch (error) { 1312 | logFn.error(`Error creating tag from branch: ${error.message}`); 1313 | throw error; 1314 | } 1315 | } 1316 | 1317 | /** 1318 | * Automatically switch tag based on current git branch 1319 | * @param {string} tasksPath - Path to the tasks.json file 1320 | * @param {Object} options - Options object 1321 | * @param {boolean} [options.createIfMissing] - Create tag if it doesn't exist 1322 | * @param {boolean} [options.copyFromCurrent] - Copy tasks when creating new tag 1323 | * @param {Object} context - Context object containing session and projectRoot 1324 | * @param {string} [context.projectRoot] - Project root path 1325 | * @param {Object} [context.mcpLog] - MCP logger object (optional) 1326 | * @param {string} outputFormat - Output format (text or json) 1327 | * @returns {Promise<Object>} Result object with switch details 1328 | */ 1329 | async function autoSwitchTagForBranch( 1330 | tasksPath, 1331 | options = {}, 1332 | context = {}, 1333 | outputFormat = 'text' 1334 | ) { 1335 | const { mcpLog, projectRoot } = context; 1336 | const { createIfMissing, copyFromCurrent } = options; 1337 | 1338 | // Import git utilities 1339 | const { 1340 | getCurrentBranch, 1341 | isGitRepository, 1342 | sanitizeBranchNameForTag, 1343 | isValidBranchForTag 1344 | } = await import('../utils/git-utils.js'); 1345 | 1346 | // Create a consistent logFn object regardless of context 1347 | const logFn = mcpLog || { 1348 | info: (...args) => log('info', ...args), 1349 | warn: (...args) => log('warn', ...args), 1350 | error: (...args) => log('error', ...args), 1351 | debug: (...args) => log('debug', ...args), 1352 | success: (...args) => log('success', ...args) 1353 | }; 1354 | 1355 | try { 1356 | // Check if we're in a git repository 1357 | if (!(await isGitRepository(projectRoot))) { 1358 | logFn.warn('Not in a git repository, cannot auto-switch tags'); 1359 | return { switched: false, reason: 'not_git_repo' }; 1360 | } 1361 | 1362 | // Get current git branch 1363 | const currentBranch = await getCurrentBranch(projectRoot); 1364 | if (!currentBranch) { 1365 | logFn.warn('Could not determine current git branch'); 1366 | return { switched: false, reason: 'no_current_branch' }; 1367 | } 1368 | 1369 | logFn.info(`Current git branch: ${currentBranch}`); 1370 | 1371 | // Check if branch is valid for tag creation 1372 | if (!isValidBranchForTag(currentBranch)) { 1373 | logFn.info(`Branch "${currentBranch}" is not suitable for tag creation`); 1374 | return { 1375 | switched: false, 1376 | reason: 'invalid_branch_for_tag', 1377 | branchName: currentBranch 1378 | }; 1379 | } 1380 | 1381 | // Check if there's already a mapping for this branch 1382 | let tagName = await getTagForBranch(projectRoot, currentBranch); 1383 | 1384 | if (!tagName) { 1385 | // No mapping exists, create tag name from branch 1386 | tagName = sanitizeBranchNameForTag(currentBranch); 1387 | } 1388 | 1389 | // Check if tag exists 1390 | const data = readJSON(tasksPath, projectRoot); 1391 | const rawData = data._rawTaggedData || data; 1392 | const tagExists = rawData[tagName]; 1393 | 1394 | if (!tagExists && createIfMissing) { 1395 | // Create the tag from branch 1396 | logFn.info(`Creating new tag "${tagName}" for branch "${currentBranch}"`); 1397 | 1398 | const createResult = await createTagFromBranch( 1399 | tasksPath, 1400 | currentBranch, 1401 | { 1402 | copyFromCurrent, 1403 | autoSwitch: true 1404 | }, 1405 | context, 1406 | outputFormat 1407 | ); 1408 | 1409 | return { 1410 | switched: true, 1411 | created: true, 1412 | branchName: currentBranch, 1413 | tagName, 1414 | ...createResult 1415 | }; 1416 | } else if (tagExists) { 1417 | // Tag exists, switch to it 1418 | logFn.info( 1419 | `Switching to existing tag "${tagName}" for branch "${currentBranch}"` 1420 | ); 1421 | 1422 | const switchResult = await useTag( 1423 | tasksPath, 1424 | tagName, 1425 | {}, 1426 | context, 1427 | outputFormat 1428 | ); 1429 | 1430 | // Update mapping if it didn't exist 1431 | if (!(await getTagForBranch(projectRoot, currentBranch))) { 1432 | await updateBranchTagMapping(projectRoot, currentBranch, tagName); 1433 | } 1434 | 1435 | return { 1436 | switched: true, 1437 | created: false, 1438 | branchName: currentBranch, 1439 | tagName, 1440 | ...switchResult 1441 | }; 1442 | } else { 1443 | // Tag doesn't exist and createIfMissing is false 1444 | logFn.warn( 1445 | `Tag "${tagName}" for branch "${currentBranch}" does not exist` 1446 | ); 1447 | return { 1448 | switched: false, 1449 | reason: 'tag_not_found', 1450 | branchName: currentBranch, 1451 | tagName 1452 | }; 1453 | } 1454 | } catch (error) { 1455 | logFn.error(`Error in auto-switch tag for branch: ${error.message}`); 1456 | throw error; 1457 | } 1458 | } 1459 | 1460 | /** 1461 | * Check git workflow configuration and perform auto-switch if enabled 1462 | * @param {string} projectRoot - Project root directory 1463 | * @param {string} tasksPath - Path to the tasks.json file 1464 | * @param {Object} context - Context object 1465 | * @returns {Promise<Object|null>} Switch result or null if not enabled 1466 | */ 1467 | async function checkAndAutoSwitchTag(projectRoot, tasksPath, context = {}) { 1468 | try { 1469 | // Read configuration 1470 | const configPath = path.join(projectRoot, '.taskmaster', 'config.json'); 1471 | if (!fs.existsSync(configPath)) { 1472 | return null; 1473 | } 1474 | 1475 | const rawConfig = fs.readFileSync(configPath, 'utf8'); 1476 | const config = JSON.parse(rawConfig); 1477 | 1478 | // Git workflow has been removed - return null to disable auto-switching 1479 | return null; 1480 | 1481 | // Perform auto-switch 1482 | return await autoSwitchTagForBranch( 1483 | tasksPath, 1484 | { createIfMissing: true, copyFromCurrent: false }, 1485 | context, 1486 | 'json' 1487 | ); 1488 | } catch (error) { 1489 | // Silently fail - this is not critical 1490 | return null; 1491 | } 1492 | } 1493 | 1494 | // Export all tag management functions 1495 | export { 1496 | createTag, 1497 | deleteTag, 1498 | tags, 1499 | useTag, 1500 | renameTag, 1501 | copyTag, 1502 | switchCurrentTag, 1503 | updateBranchTagMapping, 1504 | getTagForBranch, 1505 | createTagFromBranch, 1506 | autoSwitchTagForBranch, 1507 | checkAndAutoSwitchTag 1508 | }; 1509 | ``` -------------------------------------------------------------------------------- /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 | ```