This is page 37 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/update-task-by-id.js: -------------------------------------------------------------------------------- ```javascript 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import boxen from 'boxen'; 5 | import Table from 'cli-table3'; 6 | import { z } from 'zod'; // Keep Zod for post-parse validation 7 | 8 | import { 9 | log as consoleLog, 10 | readJSON, 11 | writeJSON, 12 | truncate, 13 | isSilentMode, 14 | flattenTasksWithSubtasks, 15 | findProjectRoot 16 | } from '../utils.js'; 17 | 18 | import { 19 | getStatusWithColor, 20 | startLoadingIndicator, 21 | stopLoadingIndicator, 22 | displayAiUsageSummary 23 | } from '../ui.js'; 24 | 25 | import { generateTextService } from '../ai-services-unified.js'; 26 | import { 27 | getDebugFlag, 28 | isApiKeySet, 29 | hasCodebaseAnalysis 30 | } from '../config-manager.js'; 31 | import { getPromptManager } from '../prompt-manager.js'; 32 | import { ContextGatherer } from '../utils/contextGatherer.js'; 33 | import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; 34 | 35 | // Zod schema for post-parsing validation of the updated task object 36 | const updatedTaskSchema = z 37 | .object({ 38 | id: z.number().int(), 39 | title: z.string(), // Title should be preserved, but check it exists 40 | description: z.string(), 41 | status: z.string(), 42 | dependencies: z.array(z.union([z.number().int(), z.string()])), 43 | priority: z.string().nullable().default('medium'), 44 | details: z.string().nullable().default(''), 45 | testStrategy: z.string().nullable().default(''), 46 | subtasks: z 47 | .array( 48 | z.object({ 49 | id: z 50 | .number() 51 | .int() 52 | .positive() 53 | .describe('Sequential subtask ID starting from 1'), 54 | title: z.string(), 55 | description: z.string(), 56 | status: z.string(), 57 | dependencies: z.array(z.number().int()).nullable().default([]), 58 | details: z.string().nullable().default(''), 59 | testStrategy: z.string().nullable().default('') 60 | }) 61 | ) 62 | .nullable() 63 | .default([]) 64 | }) 65 | .strip(); // Allows parsing even if AI adds extra fields, but validation focuses on schema 66 | 67 | /** 68 | * Parses a single updated task object from AI's text response. 69 | * @param {string} text - Response text from AI. 70 | * @param {number} expectedTaskId - The ID of the task expected. 71 | * @param {Function | Object} logFn - Logging function or MCP logger. 72 | * @param {boolean} isMCP - Flag indicating MCP context. 73 | * @returns {Object} Parsed and validated task object. 74 | * @throws {Error} If parsing or validation fails. 75 | */ 76 | function parseUpdatedTaskFromText(text, expectedTaskId, logFn, isMCP) { 77 | // Report helper consistent with the established pattern 78 | const report = (level, ...args) => { 79 | if (isMCP) { 80 | if (typeof logFn[level] === 'function') logFn[level](...args); 81 | else logFn.info(...args); 82 | } else if (!isSilentMode()) { 83 | logFn(level, ...args); 84 | } 85 | }; 86 | 87 | report( 88 | 'info', 89 | 'Attempting to parse updated task object from text response...' 90 | ); 91 | if (!text || text.trim() === '') 92 | throw new Error('AI response text is empty.'); 93 | 94 | let cleanedResponse = text.trim(); 95 | const originalResponseForDebug = cleanedResponse; 96 | let parseMethodUsed = 'raw'; // Keep track of which method worked 97 | 98 | // --- NEW Step 1: Try extracting between {} first --- 99 | const firstBraceIndex = cleanedResponse.indexOf('{'); 100 | const lastBraceIndex = cleanedResponse.lastIndexOf('}'); 101 | let potentialJsonFromBraces = null; 102 | 103 | if (firstBraceIndex !== -1 && lastBraceIndex > firstBraceIndex) { 104 | potentialJsonFromBraces = cleanedResponse.substring( 105 | firstBraceIndex, 106 | lastBraceIndex + 1 107 | ); 108 | if (potentialJsonFromBraces.length <= 2) { 109 | potentialJsonFromBraces = null; // Ignore empty braces {} 110 | } 111 | } 112 | 113 | // If {} extraction yielded something, try parsing it immediately 114 | if (potentialJsonFromBraces) { 115 | try { 116 | const testParse = JSON.parse(potentialJsonFromBraces); 117 | // It worked! Use this as the primary cleaned response. 118 | cleanedResponse = potentialJsonFromBraces; 119 | parseMethodUsed = 'braces'; 120 | } catch (e) { 121 | report( 122 | 'info', 123 | 'Content between {} looked promising but failed initial parse. Proceeding to other methods.' 124 | ); 125 | // Reset cleanedResponse to original if brace parsing failed 126 | cleanedResponse = originalResponseForDebug; 127 | } 128 | } 129 | 130 | // --- Step 2: If brace parsing didn't work or wasn't applicable, try code block extraction --- 131 | if (parseMethodUsed === 'raw') { 132 | const codeBlockMatch = cleanedResponse.match( 133 | /```(?:json|javascript)?\s*([\s\S]*?)\s*```/i 134 | ); 135 | if (codeBlockMatch) { 136 | cleanedResponse = codeBlockMatch[1].trim(); 137 | parseMethodUsed = 'codeblock'; 138 | report('info', 'Extracted JSON content from Markdown code block.'); 139 | } else { 140 | // --- Step 3: If code block failed, try stripping prefixes --- 141 | const commonPrefixes = [ 142 | 'json\n', 143 | 'javascript\n' 144 | // ... other prefixes ... 145 | ]; 146 | let prefixFound = false; 147 | for (const prefix of commonPrefixes) { 148 | if (cleanedResponse.toLowerCase().startsWith(prefix)) { 149 | cleanedResponse = cleanedResponse.substring(prefix.length).trim(); 150 | parseMethodUsed = 'prefix'; 151 | report('info', `Stripped prefix: "${prefix.trim()}"`); 152 | prefixFound = true; 153 | break; 154 | } 155 | } 156 | if (!prefixFound) { 157 | report( 158 | 'warn', 159 | 'Response does not appear to contain {}, code block, or known prefix. Attempting raw parse.' 160 | ); 161 | } 162 | } 163 | } 164 | 165 | // --- Step 4: Attempt final parse --- 166 | let parsedTask; 167 | try { 168 | parsedTask = JSON.parse(cleanedResponse); 169 | } catch (parseError) { 170 | report('error', `Failed to parse JSON object: ${parseError.message}`); 171 | report( 172 | 'error', 173 | `Problematic JSON string (first 500 chars): ${cleanedResponse.substring(0, 500)}` 174 | ); 175 | report( 176 | 'error', 177 | `Original Raw Response (first 500 chars): ${originalResponseForDebug.substring(0, 500)}` 178 | ); 179 | throw new Error( 180 | `Failed to parse JSON response object: ${parseError.message}` 181 | ); 182 | } 183 | 184 | if (!parsedTask || typeof parsedTask !== 'object') { 185 | report( 186 | 'error', 187 | `Parsed content is not an object. Type: ${typeof parsedTask}` 188 | ); 189 | report( 190 | 'error', 191 | `Parsed content sample: ${JSON.stringify(parsedTask).substring(0, 200)}` 192 | ); 193 | throw new Error('Parsed AI response is not a valid JSON object.'); 194 | } 195 | 196 | // Preprocess the task to ensure subtasks have proper structure 197 | const preprocessedTask = { 198 | ...parsedTask, 199 | status: parsedTask.status || 'pending', 200 | dependencies: Array.isArray(parsedTask.dependencies) 201 | ? parsedTask.dependencies 202 | : [], 203 | details: 204 | typeof parsedTask.details === 'string' 205 | ? parsedTask.details 206 | : String(parsedTask.details || ''), 207 | testStrategy: 208 | typeof parsedTask.testStrategy === 'string' 209 | ? parsedTask.testStrategy 210 | : String(parsedTask.testStrategy || ''), 211 | // Ensure subtasks is an array and each subtask has required fields 212 | subtasks: Array.isArray(parsedTask.subtasks) 213 | ? parsedTask.subtasks.map((subtask) => ({ 214 | ...subtask, 215 | title: subtask.title || '', 216 | description: subtask.description || '', 217 | status: subtask.status || 'pending', 218 | dependencies: Array.isArray(subtask.dependencies) 219 | ? subtask.dependencies 220 | : [], 221 | details: 222 | typeof subtask.details === 'string' 223 | ? subtask.details 224 | : String(subtask.details || ''), 225 | testStrategy: 226 | typeof subtask.testStrategy === 'string' 227 | ? subtask.testStrategy 228 | : String(subtask.testStrategy || '') 229 | })) 230 | : [] 231 | }; 232 | 233 | // Validate the parsed task object using Zod 234 | const validationResult = updatedTaskSchema.safeParse(preprocessedTask); 235 | if (!validationResult.success) { 236 | report('error', 'Parsed task object failed Zod validation.'); 237 | validationResult.error.errors.forEach((err) => { 238 | report('error', ` - Field '${err.path.join('.')}': ${err.message}`); 239 | }); 240 | throw new Error( 241 | `AI response failed task structure validation: ${validationResult.error.message}` 242 | ); 243 | } 244 | 245 | // Final check: ensure ID matches expected ID (AI might hallucinate) 246 | if (validationResult.data.id !== expectedTaskId) { 247 | report( 248 | 'warn', 249 | `AI returned task with ID ${validationResult.data.id}, but expected ${expectedTaskId}. Overwriting ID.` 250 | ); 251 | validationResult.data.id = expectedTaskId; // Enforce correct ID 252 | } 253 | 254 | report('info', 'Successfully validated updated task structure.'); 255 | return validationResult.data; // Return the validated task data 256 | } 257 | 258 | /** 259 | * Update a task by ID with new information using the unified AI service. 260 | * @param {string} tasksPath - Path to the tasks.json file 261 | * @param {number} taskId - ID of the task to update 262 | * @param {string} prompt - Prompt for generating updated task information 263 | * @param {boolean} [useResearch=false] - Whether to use the research AI role. 264 | * @param {Object} context - Context object containing session and mcpLog. 265 | * @param {Object} [context.session] - Session object from MCP server. 266 | * @param {Object} [context.mcpLog] - MCP logger object. 267 | * @param {string} [context.projectRoot] - Project root path. 268 | * @param {string} [context.tag] - Tag for the task 269 | * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). 270 | * @param {boolean} [appendMode=false] - If true, append to details instead of full update. 271 | * @returns {Promise<Object|null>} - The updated task or null if update failed. 272 | */ 273 | async function updateTaskById( 274 | tasksPath, 275 | taskId, 276 | prompt, 277 | useResearch = false, 278 | context = {}, 279 | outputFormat = 'text', 280 | appendMode = false 281 | ) { 282 | const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context; 283 | const logFn = mcpLog || consoleLog; 284 | const isMCP = !!mcpLog; 285 | 286 | // Use report helper for logging 287 | const report = (level, ...args) => { 288 | if (isMCP) { 289 | if (typeof logFn[level] === 'function') logFn[level](...args); 290 | else logFn.info(...args); 291 | } else if (!isSilentMode()) { 292 | logFn(level, ...args); 293 | } 294 | }; 295 | 296 | try { 297 | report('info', `Updating single task ${taskId} with prompt: "${prompt}"`); 298 | 299 | // --- Input Validations (Keep existing) --- 300 | if (!Number.isInteger(taskId) || taskId <= 0) 301 | throw new Error( 302 | `Invalid task ID: ${taskId}. Task ID must be a positive integer.` 303 | ); 304 | if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') 305 | throw new Error('Prompt cannot be empty.'); 306 | if (useResearch && !isApiKeySet('perplexity', session)) { 307 | report( 308 | 'warn', 309 | 'Perplexity research requested but API key not set. Falling back.' 310 | ); 311 | if (outputFormat === 'text') 312 | console.log( 313 | chalk.yellow('Perplexity AI not available. Falling back to main AI.') 314 | ); 315 | useResearch = false; 316 | } 317 | if (!fs.existsSync(tasksPath)) 318 | throw new Error(`Tasks file not found: ${tasksPath}`); 319 | // --- End Input Validations --- 320 | 321 | // Determine project root 322 | const projectRoot = providedProjectRoot || findProjectRoot(); 323 | if (!projectRoot) { 324 | throw new Error('Could not determine project root directory'); 325 | } 326 | 327 | // --- Task Loading and Status Check (Keep existing) --- 328 | const data = readJSON(tasksPath, projectRoot, tag); 329 | if (!data || !data.tasks) 330 | throw new Error(`No valid tasks found in ${tasksPath}.`); 331 | const taskIndex = data.tasks.findIndex((task) => task.id === taskId); 332 | if (taskIndex === -1) throw new Error(`Task with ID ${taskId} not found.`); 333 | const taskToUpdate = data.tasks[taskIndex]; 334 | if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') { 335 | report( 336 | 'warn', 337 | `Task ${taskId} is already marked as done and cannot be updated` 338 | ); 339 | 340 | // Only show warning box for text output (CLI) 341 | if (outputFormat === 'text') { 342 | console.log( 343 | boxen( 344 | chalk.yellow( 345 | `Task ${taskId} is already marked as ${taskToUpdate.status} and cannot be updated.` 346 | ) + 347 | '\n\n' + 348 | chalk.white( 349 | 'Completed tasks are locked to maintain consistency. To modify a completed task, you must first:' 350 | ) + 351 | '\n' + 352 | chalk.white( 353 | '1. Change its status to "pending" or "in-progress"' 354 | ) + 355 | '\n' + 356 | chalk.white('2. Then run the update-task command'), 357 | { padding: 1, borderColor: 'yellow', borderStyle: 'round' } 358 | ) 359 | ); 360 | } 361 | return null; 362 | } 363 | // --- End Task Loading --- 364 | 365 | // --- Context Gathering --- 366 | let gatheredContext = ''; 367 | try { 368 | const contextGatherer = new ContextGatherer(projectRoot, tag); 369 | const allTasksFlat = flattenTasksWithSubtasks(data.tasks); 370 | const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-task'); 371 | const searchQuery = `${taskToUpdate.title} ${taskToUpdate.description} ${prompt}`; 372 | const searchResults = fuzzySearch.findRelevantTasks(searchQuery, { 373 | maxResults: 5, 374 | includeSelf: true 375 | }); 376 | const relevantTaskIds = fuzzySearch.getTaskIds(searchResults); 377 | 378 | const finalTaskIds = [ 379 | ...new Set([taskId.toString(), ...relevantTaskIds]) 380 | ]; 381 | 382 | if (finalTaskIds.length > 0) { 383 | const contextResult = await contextGatherer.gather({ 384 | tasks: finalTaskIds, 385 | format: 'research' 386 | }); 387 | gatheredContext = contextResult.context || ''; 388 | } 389 | } catch (contextError) { 390 | report('warn', `Could not gather context: ${contextError.message}`); 391 | } 392 | // --- End Context Gathering --- 393 | 394 | // --- Display Task Info (CLI Only - Keep existing) --- 395 | if (outputFormat === 'text') { 396 | // Show the task that will be updated 397 | const table = new Table({ 398 | head: [ 399 | chalk.cyan.bold('ID'), 400 | chalk.cyan.bold('Title'), 401 | chalk.cyan.bold('Status') 402 | ], 403 | colWidths: [5, 60, 10] 404 | }); 405 | 406 | table.push([ 407 | taskToUpdate.id, 408 | truncate(taskToUpdate.title, 57), 409 | getStatusWithColor(taskToUpdate.status) 410 | ]); 411 | 412 | console.log( 413 | boxen(chalk.white.bold(`Updating Task #${taskId}`), { 414 | padding: 1, 415 | borderColor: 'blue', 416 | borderStyle: 'round', 417 | margin: { top: 1, bottom: 0 } 418 | }) 419 | ); 420 | 421 | console.log(table.toString()); 422 | 423 | // Display a message about how completed subtasks are handled 424 | console.log( 425 | boxen( 426 | chalk.cyan.bold('How Completed Subtasks Are Handled:') + 427 | '\n\n' + 428 | chalk.white( 429 | '• Subtasks marked as "done" or "completed" will be preserved\n' 430 | ) + 431 | chalk.white( 432 | '• New subtasks will build upon what has already been completed\n' 433 | ) + 434 | chalk.white( 435 | '• If completed work needs revision, a new subtask will be created instead of modifying done items\n' 436 | ) + 437 | chalk.white( 438 | '• This approach maintains a clear record of completed work and new requirements' 439 | ), 440 | { 441 | padding: 1, 442 | borderColor: 'blue', 443 | borderStyle: 'round', 444 | margin: { top: 1, bottom: 1 } 445 | } 446 | ) 447 | ); 448 | } 449 | 450 | // --- Build Prompts using PromptManager --- 451 | const promptManager = getPromptManager(); 452 | 453 | const promptParams = { 454 | task: taskToUpdate, 455 | taskJson: JSON.stringify(taskToUpdate, null, 2), 456 | updatePrompt: prompt, 457 | appendMode: appendMode, 458 | useResearch: useResearch, 459 | currentDetails: taskToUpdate.details || '(No existing details)', 460 | gatheredContext: gatheredContext || '', 461 | hasCodebaseAnalysis: hasCodebaseAnalysis( 462 | useResearch, 463 | projectRoot, 464 | session 465 | ), 466 | projectRoot: projectRoot 467 | }; 468 | 469 | const variantKey = appendMode 470 | ? 'append' 471 | : useResearch 472 | ? 'research' 473 | : 'default'; 474 | 475 | report( 476 | 'info', 477 | `Loading prompt template with variant: ${variantKey}, appendMode: ${appendMode}, useResearch: ${useResearch}` 478 | ); 479 | 480 | let systemPrompt; 481 | let userPrompt; 482 | try { 483 | const promptResult = await promptManager.loadPrompt( 484 | 'update-task', 485 | promptParams, 486 | variantKey 487 | ); 488 | report( 489 | 'info', 490 | `Prompt result type: ${typeof promptResult}, keys: ${promptResult ? Object.keys(promptResult).join(', ') : 'null'}` 491 | ); 492 | 493 | // Extract prompts - loadPrompt returns { systemPrompt, userPrompt, metadata } 494 | systemPrompt = promptResult.systemPrompt; 495 | userPrompt = promptResult.userPrompt; 496 | 497 | report( 498 | 'info', 499 | `Loaded prompts - systemPrompt length: ${systemPrompt?.length}, userPrompt length: ${userPrompt?.length}` 500 | ); 501 | } catch (error) { 502 | report('error', `Failed to load prompt template: ${error.message}`); 503 | throw new Error(`Failed to load prompt template: ${error.message}`); 504 | } 505 | 506 | // If prompts are still not set, throw an error 507 | if (!systemPrompt || !userPrompt) { 508 | throw new Error( 509 | `Failed to load prompts: systemPrompt=${!!systemPrompt}, userPrompt=${!!userPrompt}` 510 | ); 511 | } 512 | // --- End Build Prompts --- 513 | 514 | let loadingIndicator = null; 515 | let aiServiceResponse = null; 516 | 517 | if (!isMCP && outputFormat === 'text') { 518 | loadingIndicator = startLoadingIndicator( 519 | useResearch ? 'Updating task with research...\n' : 'Updating task...\n' 520 | ); 521 | } 522 | 523 | try { 524 | const serviceRole = useResearch ? 'research' : 'main'; 525 | aiServiceResponse = await generateTextService({ 526 | role: serviceRole, 527 | session: session, 528 | projectRoot: projectRoot, 529 | systemPrompt: systemPrompt, 530 | prompt: userPrompt, 531 | commandName: 'update-task', 532 | outputType: isMCP ? 'mcp' : 'cli' 533 | }); 534 | 535 | if (loadingIndicator) 536 | stopLoadingIndicator(loadingIndicator, 'AI update complete.'); 537 | 538 | if (appendMode) { 539 | // Append mode: handle as plain text 540 | const generatedContentString = aiServiceResponse.mainResult; 541 | let newlyAddedSnippet = ''; 542 | 543 | if (generatedContentString && generatedContentString.trim()) { 544 | const timestamp = new Date().toISOString(); 545 | const formattedBlock = `<info added on ${timestamp}>\n${generatedContentString.trim()}\n</info added on ${timestamp}>`; 546 | newlyAddedSnippet = formattedBlock; 547 | 548 | // Append to task details 549 | taskToUpdate.details = 550 | (taskToUpdate.details ? taskToUpdate.details + '\n' : '') + 551 | formattedBlock; 552 | } else { 553 | report( 554 | 'warn', 555 | 'AI response was empty or whitespace after trimming. Original details remain unchanged.' 556 | ); 557 | newlyAddedSnippet = 'No new details were added by the AI.'; 558 | } 559 | 560 | // Update description with timestamp if prompt is short 561 | if (prompt.length < 100) { 562 | if (taskToUpdate.description) { 563 | taskToUpdate.description += ` [Updated: ${new Date().toLocaleDateString()}]`; 564 | } 565 | } 566 | 567 | // Write the updated task back to file 568 | data.tasks[taskIndex] = taskToUpdate; 569 | writeJSON(tasksPath, data, projectRoot, tag); 570 | report('success', `Successfully appended to task ${taskId}`); 571 | 572 | // Display success message for CLI 573 | if (outputFormat === 'text') { 574 | console.log( 575 | boxen( 576 | chalk.green(`Successfully appended to task #${taskId}`) + 577 | '\n\n' + 578 | chalk.white.bold('Title:') + 579 | ' ' + 580 | taskToUpdate.title + 581 | '\n\n' + 582 | chalk.white.bold('Newly Added Content:') + 583 | '\n' + 584 | chalk.white(newlyAddedSnippet), 585 | { padding: 1, borderColor: 'green', borderStyle: 'round' } 586 | ) 587 | ); 588 | } 589 | 590 | // Display AI usage telemetry for CLI users 591 | if (outputFormat === 'text' && aiServiceResponse.telemetryData) { 592 | displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli'); 593 | } 594 | 595 | // Return the updated task 596 | return { 597 | updatedTask: taskToUpdate, 598 | telemetryData: aiServiceResponse.telemetryData, 599 | tagInfo: aiServiceResponse.tagInfo 600 | }; 601 | } 602 | 603 | // Full update mode: Use mainResult (text) for parsing 604 | const updatedTask = parseUpdatedTaskFromText( 605 | aiServiceResponse.mainResult, 606 | taskId, 607 | logFn, 608 | isMCP 609 | ); 610 | 611 | // --- Task Validation/Correction (Keep existing logic) --- 612 | if (!updatedTask || typeof updatedTask !== 'object') 613 | throw new Error('Received invalid task object from AI.'); 614 | if (!updatedTask.title || !updatedTask.description) 615 | throw new Error('Updated task missing required fields.'); 616 | // Preserve ID if AI changed it 617 | if (updatedTask.id !== taskId) { 618 | report('warn', `AI changed task ID. Restoring original ID ${taskId}.`); 619 | updatedTask.id = taskId; 620 | } 621 | // Preserve status if AI changed it 622 | if ( 623 | updatedTask.status !== taskToUpdate.status && 624 | !prompt.toLowerCase().includes('status') 625 | ) { 626 | report( 627 | 'warn', 628 | `AI changed task status. Restoring original status '${taskToUpdate.status}'.` 629 | ); 630 | updatedTask.status = taskToUpdate.status; 631 | } 632 | // Fix subtask IDs if they exist (ensure they are numeric and sequential) 633 | if (updatedTask.subtasks && Array.isArray(updatedTask.subtasks)) { 634 | let currentSubtaskId = 1; 635 | updatedTask.subtasks = updatedTask.subtasks.map((subtask) => { 636 | // Fix AI-generated subtask IDs that might be strings or use parent ID as prefix 637 | const correctedSubtask = { 638 | ...subtask, 639 | id: currentSubtaskId, // Override AI-generated ID with correct sequential ID 640 | dependencies: Array.isArray(subtask.dependencies) 641 | ? subtask.dependencies 642 | .map((dep) => 643 | typeof dep === 'string' ? parseInt(dep, 10) : dep 644 | ) 645 | .filter( 646 | (depId) => 647 | !Number.isNaN(depId) && 648 | depId >= 1 && 649 | depId < currentSubtaskId 650 | ) 651 | : [], 652 | status: subtask.status || 'pending' 653 | }; 654 | currentSubtaskId++; 655 | return correctedSubtask; 656 | }); 657 | report( 658 | 'info', 659 | `Fixed ${updatedTask.subtasks.length} subtask IDs to be sequential numeric IDs.` 660 | ); 661 | } 662 | 663 | // Preserve completed subtasks (Keep existing logic) 664 | if (taskToUpdate.subtasks?.length > 0) { 665 | if (!updatedTask.subtasks) { 666 | report( 667 | 'warn', 668 | 'Subtasks removed by AI. Restoring original subtasks.' 669 | ); 670 | updatedTask.subtasks = taskToUpdate.subtasks; 671 | } else { 672 | const completedOriginal = taskToUpdate.subtasks.filter( 673 | (st) => st.status === 'done' || st.status === 'completed' 674 | ); 675 | completedOriginal.forEach((compSub) => { 676 | const updatedSub = updatedTask.subtasks.find( 677 | (st) => st.id === compSub.id 678 | ); 679 | if ( 680 | !updatedSub || 681 | JSON.stringify(updatedSub) !== JSON.stringify(compSub) 682 | ) { 683 | report( 684 | 'warn', 685 | `Completed subtask ${compSub.id} was modified or removed. Restoring.` 686 | ); 687 | // Remove potentially modified version 688 | updatedTask.subtasks = updatedTask.subtasks.filter( 689 | (st) => st.id !== compSub.id 690 | ); 691 | // Add back original 692 | updatedTask.subtasks.push(compSub); 693 | } 694 | }); 695 | // Deduplicate just in case 696 | const subtaskIds = new Set(); 697 | updatedTask.subtasks = updatedTask.subtasks.filter((st) => { 698 | if (!subtaskIds.has(st.id)) { 699 | subtaskIds.add(st.id); 700 | return true; 701 | } 702 | report('warn', `Duplicate subtask ID ${st.id} removed.`); 703 | return false; 704 | }); 705 | } 706 | } 707 | // --- End Task Validation/Correction --- 708 | 709 | // --- Update Task Data (Keep existing) --- 710 | data.tasks[taskIndex] = updatedTask; 711 | // --- End Update Task Data --- 712 | 713 | // --- Write File and Generate (Unchanged) --- 714 | writeJSON(tasksPath, data, projectRoot, tag); 715 | report('success', `Successfully updated task ${taskId}`); 716 | // await generateTaskFiles(tasksPath, path.dirname(tasksPath)); 717 | // --- End Write File --- 718 | 719 | // --- Display CLI Telemetry --- 720 | if (outputFormat === 'text' && aiServiceResponse.telemetryData) { 721 | displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli'); // <<< ADD display 722 | } 723 | 724 | // --- Return Success with Telemetry --- 725 | return { 726 | updatedTask: updatedTask, // Return the updated task object 727 | telemetryData: aiServiceResponse.telemetryData, // <<< ADD telemetryData 728 | tagInfo: aiServiceResponse.tagInfo 729 | }; 730 | } catch (error) { 731 | // Catch errors from generateTextService 732 | if (loadingIndicator) stopLoadingIndicator(loadingIndicator); 733 | report('error', `Error during AI service call: ${error.message}`); 734 | if (error.message.includes('API key')) { 735 | report('error', 'Please ensure API keys are configured correctly.'); 736 | } 737 | throw error; // Re-throw error 738 | } 739 | } catch (error) { 740 | // General error catch 741 | // --- General Error Handling (Keep existing) --- 742 | report('error', `Error updating task: ${error.message}`); 743 | if (outputFormat === 'text') { 744 | console.error(chalk.red(`Error: ${error.message}`)); 745 | // ... helpful hints ... 746 | if (getDebugFlag(session)) console.error(error); 747 | process.exit(1); 748 | } else { 749 | throw error; // Re-throw for MCP 750 | } 751 | return null; // Indicate failure in CLI case if process doesn't exit 752 | // --- End General Error Handling --- 753 | } 754 | } 755 | 756 | export default updateTaskById; 757 | ``` -------------------------------------------------------------------------------- /tests/unit/dependency-manager.test.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * Dependency Manager module tests 3 | */ 4 | 5 | import { jest } from '@jest/globals'; 6 | import { 7 | validateTaskDependencies, 8 | isCircularDependency, 9 | removeDuplicateDependencies, 10 | cleanupSubtaskDependencies, 11 | ensureAtLeastOneIndependentSubtask, 12 | validateAndFixDependencies, 13 | canMoveWithDependencies 14 | } from '../../scripts/modules/dependency-manager.js'; 15 | import * as utils from '../../scripts/modules/utils.js'; 16 | import { sampleTasks } from '../fixtures/sample-tasks.js'; 17 | 18 | // Mock dependencies 19 | jest.mock('path'); 20 | jest.mock('chalk', () => ({ 21 | green: jest.fn((text) => `<green>${text}</green>`), 22 | yellow: jest.fn((text) => `<yellow>${text}</yellow>`), 23 | red: jest.fn((text) => `<red>${text}</red>`), 24 | cyan: jest.fn((text) => `<cyan>${text}</cyan>`), 25 | bold: jest.fn((text) => `<bold>${text}</bold>`) 26 | })); 27 | 28 | jest.mock('boxen', () => jest.fn((text) => `[boxed: ${text}]`)); 29 | 30 | jest.mock('@anthropic-ai/sdk', () => ({ 31 | Anthropic: jest.fn().mockImplementation(() => ({})) 32 | })); 33 | 34 | // Mock utils module 35 | const mockTaskExists = jest.fn(); 36 | const mockFormatTaskId = jest.fn(); 37 | const mockFindCycles = jest.fn(); 38 | const mockLog = jest.fn(); 39 | const mockReadJSON = jest.fn(); 40 | const mockWriteJSON = jest.fn(); 41 | 42 | jest.mock('../../scripts/modules/utils.js', () => ({ 43 | log: mockLog, 44 | readJSON: mockReadJSON, 45 | writeJSON: mockWriteJSON, 46 | taskExists: mockTaskExists, 47 | formatTaskId: mockFormatTaskId, 48 | findCycles: mockFindCycles 49 | })); 50 | 51 | jest.mock('../../scripts/modules/ui.js', () => ({ 52 | displayBanner: jest.fn() 53 | })); 54 | 55 | jest.mock('../../scripts/modules/task-manager.js', () => ({ 56 | generateTaskFiles: jest.fn() 57 | })); 58 | 59 | // Create a path for test files 60 | const TEST_TASKS_PATH = 'tests/fixture/test-tasks.json'; 61 | 62 | describe('Dependency Manager Module', () => { 63 | beforeEach(() => { 64 | jest.clearAllMocks(); 65 | 66 | // Set default implementations 67 | mockTaskExists.mockImplementation((tasks, id) => { 68 | if (Array.isArray(tasks)) { 69 | if (typeof id === 'string' && id.includes('.')) { 70 | const [taskId, subtaskId] = id.split('.').map(Number); 71 | const task = tasks.find((t) => t.id === taskId); 72 | return ( 73 | task && 74 | task.subtasks && 75 | task.subtasks.some((st) => st.id === subtaskId) 76 | ); 77 | } 78 | return tasks.some( 79 | (task) => task.id === (typeof id === 'string' ? parseInt(id, 10) : id) 80 | ); 81 | } 82 | return false; 83 | }); 84 | 85 | mockFormatTaskId.mockImplementation((id) => { 86 | if (typeof id === 'string' && id.includes('.')) { 87 | return id; 88 | } 89 | return parseInt(id, 10); 90 | }); 91 | 92 | mockFindCycles.mockImplementation((tasks) => { 93 | // Simplified cycle detection for testing 94 | const dependencyMap = new Map(); 95 | 96 | // Build dependency map 97 | tasks.forEach((task) => { 98 | if (task.dependencies) { 99 | dependencyMap.set(task.id, task.dependencies); 100 | } 101 | }); 102 | 103 | const visited = new Set(); 104 | const recursionStack = new Set(); 105 | 106 | function dfs(taskId) { 107 | visited.add(taskId); 108 | recursionStack.add(taskId); 109 | 110 | const dependencies = dependencyMap.get(taskId) || []; 111 | for (const depId of dependencies) { 112 | if (!visited.has(depId)) { 113 | if (dfs(depId)) return true; 114 | } else if (recursionStack.has(depId)) { 115 | return true; 116 | } 117 | } 118 | 119 | recursionStack.delete(taskId); 120 | return false; 121 | } 122 | 123 | // Check for cycles starting from each unvisited node 124 | for (const taskId of dependencyMap.keys()) { 125 | if (!visited.has(taskId)) { 126 | if (dfs(taskId)) return true; 127 | } 128 | } 129 | 130 | return false; 131 | }); 132 | }); 133 | 134 | describe('isCircularDependency function', () => { 135 | test('should detect a direct circular dependency', () => { 136 | const tasks = [ 137 | { id: 1, dependencies: [2] }, 138 | { id: 2, dependencies: [1] } 139 | ]; 140 | 141 | const result = isCircularDependency(tasks, 1); 142 | expect(result).toBe(true); 143 | }); 144 | 145 | test('should detect an indirect circular dependency', () => { 146 | const tasks = [ 147 | { id: 1, dependencies: [2] }, 148 | { id: 2, dependencies: [3] }, 149 | { id: 3, dependencies: [1] } 150 | ]; 151 | 152 | const result = isCircularDependency(tasks, 1); 153 | expect(result).toBe(true); 154 | }); 155 | 156 | test('should return false for non-circular dependencies', () => { 157 | const tasks = [ 158 | { id: 1, dependencies: [2] }, 159 | { id: 2, dependencies: [3] }, 160 | { id: 3, dependencies: [] } 161 | ]; 162 | 163 | const result = isCircularDependency(tasks, 1); 164 | expect(result).toBe(false); 165 | }); 166 | 167 | test('should handle a task with no dependencies', () => { 168 | const tasks = [ 169 | { id: 1, dependencies: [] }, 170 | { id: 2, dependencies: [1] } 171 | ]; 172 | 173 | const result = isCircularDependency(tasks, 1); 174 | expect(result).toBe(false); 175 | }); 176 | 177 | test('should handle a task depending on itself', () => { 178 | const tasks = [{ id: 1, dependencies: [1] }]; 179 | 180 | const result = isCircularDependency(tasks, 1); 181 | expect(result).toBe(true); 182 | }); 183 | 184 | test('should handle subtask dependencies correctly', () => { 185 | const tasks = [ 186 | { 187 | id: 1, 188 | dependencies: [], 189 | subtasks: [ 190 | { id: 1, dependencies: ['1.2'] }, 191 | { id: 2, dependencies: ['1.3'] }, 192 | { id: 3, dependencies: ['1.1'] } 193 | ] 194 | } 195 | ]; 196 | 197 | // This creates a circular dependency: 1.1 -> 1.2 -> 1.3 -> 1.1 198 | const result = isCircularDependency(tasks, '1.1', ['1.3', '1.2']); 199 | expect(result).toBe(true); 200 | }); 201 | 202 | test('should allow non-circular subtask dependencies within same parent', () => { 203 | const tasks = [ 204 | { 205 | id: 1, 206 | dependencies: [], 207 | subtasks: [ 208 | { id: 1, dependencies: [] }, 209 | { id: 2, dependencies: ['1.1'] }, 210 | { id: 3, dependencies: ['1.2'] } 211 | ] 212 | } 213 | ]; 214 | 215 | // This is a valid dependency chain: 1.3 -> 1.2 -> 1.1 216 | const result = isCircularDependency(tasks, '1.1', []); 217 | expect(result).toBe(false); 218 | }); 219 | 220 | test('should properly handle dependencies between subtasks of the same parent', () => { 221 | const tasks = [ 222 | { 223 | id: 1, 224 | dependencies: [], 225 | subtasks: [ 226 | { id: 1, dependencies: [] }, 227 | { id: 2, dependencies: ['1.1'] }, 228 | { id: 3, dependencies: [] } 229 | ] 230 | } 231 | ]; 232 | 233 | // Check if adding a dependency from subtask 1.3 to 1.2 creates a circular dependency 234 | // This should be false as 1.3 -> 1.2 -> 1.1 is a valid chain 235 | mockTaskExists.mockImplementation(() => true); 236 | const result = isCircularDependency(tasks, '1.3', ['1.2']); 237 | expect(result).toBe(false); 238 | }); 239 | 240 | test('should correctly detect circular dependencies in subtasks of the same parent', () => { 241 | const tasks = [ 242 | { 243 | id: 1, 244 | dependencies: [], 245 | subtasks: [ 246 | { id: 1, dependencies: ['1.3'] }, 247 | { id: 2, dependencies: ['1.1'] }, 248 | { id: 3, dependencies: ['1.2'] } 249 | ] 250 | } 251 | ]; 252 | 253 | // This creates a circular dependency: 1.1 -> 1.3 -> 1.2 -> 1.1 254 | mockTaskExists.mockImplementation(() => true); 255 | const result = isCircularDependency(tasks, '1.2', ['1.1']); 256 | expect(result).toBe(true); 257 | }); 258 | }); 259 | 260 | describe('validateTaskDependencies function', () => { 261 | test('should detect missing dependencies', () => { 262 | const tasks = [ 263 | { id: 1, dependencies: [99] }, // 99 doesn't exist 264 | { id: 2, dependencies: [1] } 265 | ]; 266 | 267 | const result = validateTaskDependencies(tasks); 268 | 269 | expect(result.valid).toBe(false); 270 | expect(result.issues.length).toBeGreaterThan(0); 271 | expect(result.issues[0].type).toBe('missing'); 272 | expect(result.issues[0].taskId).toBe(1); 273 | expect(result.issues[0].dependencyId).toBe(99); 274 | }); 275 | 276 | test('should detect circular dependencies', () => { 277 | const tasks = [ 278 | { id: 1, dependencies: [2] }, 279 | { id: 2, dependencies: [1] } 280 | ]; 281 | 282 | const result = validateTaskDependencies(tasks); 283 | 284 | expect(result.valid).toBe(false); 285 | expect(result.issues.some((issue) => issue.type === 'circular')).toBe( 286 | true 287 | ); 288 | }); 289 | 290 | test('should detect self-dependencies', () => { 291 | const tasks = [{ id: 1, dependencies: [1] }]; 292 | 293 | const result = validateTaskDependencies(tasks); 294 | 295 | expect(result.valid).toBe(false); 296 | expect( 297 | result.issues.some( 298 | (issue) => issue.type === 'self' && issue.taskId === 1 299 | ) 300 | ).toBe(true); 301 | }); 302 | 303 | test('should return valid for correct dependencies', () => { 304 | const tasks = [ 305 | { id: 1, dependencies: [] }, 306 | { id: 2, dependencies: [1] }, 307 | { id: 3, dependencies: [1, 2] } 308 | ]; 309 | 310 | const result = validateTaskDependencies(tasks); 311 | 312 | expect(result.valid).toBe(true); 313 | expect(result.issues.length).toBe(0); 314 | }); 315 | 316 | test('should handle tasks with no dependencies property', () => { 317 | const tasks = [ 318 | { id: 1 }, // Missing dependencies property 319 | { id: 2, dependencies: [1] } 320 | ]; 321 | 322 | const result = validateTaskDependencies(tasks); 323 | 324 | // Should be valid since a missing dependencies property is interpreted as an empty array 325 | expect(result.valid).toBe(true); 326 | }); 327 | 328 | test('should handle subtask dependencies correctly', () => { 329 | const tasks = [ 330 | { 331 | id: 1, 332 | dependencies: [], 333 | subtasks: [ 334 | { id: 1, dependencies: [] }, 335 | { id: 2, dependencies: ['1.1'] }, // Valid - depends on another subtask 336 | { id: 3, dependencies: ['1.2'] } // Valid - depends on another subtask 337 | ] 338 | }, 339 | { 340 | id: 2, 341 | dependencies: ['1.3'], // Valid - depends on a subtask from task 1 342 | subtasks: [] 343 | } 344 | ]; 345 | 346 | // Set up mock to handle subtask validation 347 | mockTaskExists.mockImplementation((tasks, id) => { 348 | if (typeof id === 'string' && id.includes('.')) { 349 | const [taskId, subtaskId] = id.split('.').map(Number); 350 | const task = tasks.find((t) => t.id === taskId); 351 | return ( 352 | task && 353 | task.subtasks && 354 | task.subtasks.some((st) => st.id === subtaskId) 355 | ); 356 | } 357 | return tasks.some((task) => task.id === parseInt(id, 10)); 358 | }); 359 | 360 | const result = validateTaskDependencies(tasks); 361 | 362 | expect(result.valid).toBe(true); 363 | expect(result.issues.length).toBe(0); 364 | }); 365 | 366 | test('should detect missing subtask dependencies', () => { 367 | const tasks = [ 368 | { 369 | id: 1, 370 | dependencies: [], 371 | subtasks: [ 372 | { id: 1, dependencies: ['1.4'] }, // Invalid - subtask 4 doesn't exist 373 | { id: 2, dependencies: ['2.1'] } // Invalid - task 2 has no subtasks 374 | ] 375 | }, 376 | { 377 | id: 2, 378 | dependencies: [], 379 | subtasks: [] 380 | } 381 | ]; 382 | 383 | // Mock taskExists to correctly identify missing subtasks 384 | mockTaskExists.mockImplementation((taskArray, depId) => { 385 | if (typeof depId === 'string' && depId === '1.4') { 386 | return false; // Subtask 1.4 doesn't exist 387 | } 388 | if (typeof depId === 'string' && depId === '2.1') { 389 | return false; // Subtask 2.1 doesn't exist 390 | } 391 | return true; // All other dependencies exist 392 | }); 393 | 394 | const result = validateTaskDependencies(tasks); 395 | 396 | expect(result.valid).toBe(false); 397 | expect(result.issues.length).toBeGreaterThan(0); 398 | // Should detect missing subtask dependencies 399 | expect( 400 | result.issues.some( 401 | (issue) => 402 | issue.type === 'missing' && 403 | String(issue.taskId) === '1.1' && 404 | String(issue.dependencyId) === '1.4' 405 | ) 406 | ).toBe(true); 407 | }); 408 | 409 | test('should detect circular dependencies between subtasks', () => { 410 | const tasks = [ 411 | { 412 | id: 1, 413 | dependencies: [], 414 | subtasks: [ 415 | { id: 1, dependencies: ['1.2'] }, 416 | { id: 2, dependencies: ['1.1'] } // Creates a circular dependency with 1.1 417 | ] 418 | } 419 | ]; 420 | 421 | // Mock isCircularDependency for subtasks 422 | mockFindCycles.mockReturnValue(true); 423 | 424 | const result = validateTaskDependencies(tasks); 425 | 426 | expect(result.valid).toBe(false); 427 | expect(result.issues.some((issue) => issue.type === 'circular')).toBe( 428 | true 429 | ); 430 | }); 431 | 432 | test('should properly validate dependencies between subtasks of the same parent', () => { 433 | const tasks = [ 434 | { 435 | id: 23, 436 | dependencies: [], 437 | subtasks: [ 438 | { id: 8, dependencies: ['23.13'] }, 439 | { id: 10, dependencies: ['23.8'] }, 440 | { id: 13, dependencies: [] } 441 | ] 442 | } 443 | ]; 444 | 445 | // Mock taskExists to validate the subtask dependencies 446 | mockTaskExists.mockImplementation((taskArray, id) => { 447 | if (typeof id === 'string') { 448 | if (id === '23.8' || id === '23.10' || id === '23.13') { 449 | return true; 450 | } 451 | } 452 | return false; 453 | }); 454 | 455 | const result = validateTaskDependencies(tasks); 456 | 457 | expect(result.valid).toBe(true); 458 | expect(result.issues.length).toBe(0); 459 | }); 460 | }); 461 | 462 | describe('removeDuplicateDependencies function', () => { 463 | test('should remove duplicate dependencies from tasks', () => { 464 | const tasksData = { 465 | tasks: [ 466 | { id: 1, dependencies: [2, 2, 3, 3, 3] }, 467 | { id: 2, dependencies: [3] }, 468 | { id: 3, dependencies: [] } 469 | ] 470 | }; 471 | 472 | const result = removeDuplicateDependencies(tasksData); 473 | 474 | expect(result.tasks[0].dependencies).toEqual([2, 3]); 475 | expect(result.tasks[1].dependencies).toEqual([3]); 476 | expect(result.tasks[2].dependencies).toEqual([]); 477 | }); 478 | 479 | test('should handle empty dependencies array', () => { 480 | const tasksData = { 481 | tasks: [ 482 | { id: 1, dependencies: [] }, 483 | { id: 2, dependencies: [1] } 484 | ] 485 | }; 486 | 487 | const result = removeDuplicateDependencies(tasksData); 488 | 489 | expect(result.tasks[0].dependencies).toEqual([]); 490 | expect(result.tasks[1].dependencies).toEqual([1]); 491 | }); 492 | 493 | test('should handle tasks with no dependencies property', () => { 494 | const tasksData = { 495 | tasks: [ 496 | { id: 1 }, // No dependencies property 497 | { id: 2, dependencies: [1] } 498 | ] 499 | }; 500 | 501 | const result = removeDuplicateDependencies(tasksData); 502 | 503 | expect(result.tasks[0]).not.toHaveProperty('dependencies'); 504 | expect(result.tasks[1].dependencies).toEqual([1]); 505 | }); 506 | }); 507 | 508 | describe('cleanupSubtaskDependencies function', () => { 509 | test('should remove dependencies to non-existent subtasks', () => { 510 | const tasksData = { 511 | tasks: [ 512 | { 513 | id: 1, 514 | dependencies: [], 515 | subtasks: [ 516 | { id: 1, dependencies: [] }, 517 | { id: 2, dependencies: [3] } // Dependency 3 doesn't exist 518 | ] 519 | }, 520 | { 521 | id: 2, 522 | dependencies: ['1.2'], // Valid subtask dependency 523 | subtasks: [ 524 | { id: 1, dependencies: ['1.1'] } // Valid subtask dependency 525 | ] 526 | } 527 | ] 528 | }; 529 | 530 | const result = cleanupSubtaskDependencies(tasksData); 531 | 532 | // Should remove the invalid dependency to subtask 3 533 | expect(result.tasks[0].subtasks[1].dependencies).toEqual([]); 534 | // Should keep valid dependencies 535 | expect(result.tasks[1].dependencies).toEqual(['1.2']); 536 | expect(result.tasks[1].subtasks[0].dependencies).toEqual(['1.1']); 537 | }); 538 | 539 | test('should handle tasks without subtasks', () => { 540 | const tasksData = { 541 | tasks: [ 542 | { id: 1, dependencies: [] }, 543 | { id: 2, dependencies: [1] } 544 | ] 545 | }; 546 | 547 | const result = cleanupSubtaskDependencies(tasksData); 548 | 549 | // Should return the original data unchanged 550 | expect(result).toEqual(tasksData); 551 | }); 552 | }); 553 | 554 | describe('ensureAtLeastOneIndependentSubtask function', () => { 555 | test('should clear dependencies of first subtask if none are independent', () => { 556 | const tasksData = { 557 | tasks: [ 558 | { 559 | id: 1, 560 | subtasks: [ 561 | { id: 1, dependencies: [2] }, 562 | { id: 2, dependencies: [1] } 563 | ] 564 | } 565 | ] 566 | }; 567 | 568 | const result = ensureAtLeastOneIndependentSubtask(tasksData); 569 | 570 | expect(result).toBe(true); 571 | expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual([]); 572 | expect(tasksData.tasks[0].subtasks[1].dependencies).toEqual([1]); 573 | }); 574 | 575 | test('should not modify tasks if at least one subtask is independent', () => { 576 | const tasksData = { 577 | tasks: [ 578 | { 579 | id: 1, 580 | subtasks: [ 581 | { id: 1, dependencies: [] }, 582 | { id: 2, dependencies: [1] } 583 | ] 584 | } 585 | ] 586 | }; 587 | 588 | const result = ensureAtLeastOneIndependentSubtask(tasksData); 589 | 590 | expect(result).toBe(false); 591 | expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual([]); 592 | expect(tasksData.tasks[0].subtasks[1].dependencies).toEqual([1]); 593 | }); 594 | 595 | test('should handle tasks without subtasks', () => { 596 | const tasksData = { 597 | tasks: [{ id: 1 }, { id: 2, dependencies: [1] }] 598 | }; 599 | 600 | const result = ensureAtLeastOneIndependentSubtask(tasksData); 601 | 602 | expect(result).toBe(false); 603 | expect(tasksData).toEqual({ 604 | tasks: [{ id: 1 }, { id: 2, dependencies: [1] }] 605 | }); 606 | }); 607 | 608 | test('should handle empty subtasks array', () => { 609 | const tasksData = { 610 | tasks: [{ id: 1, subtasks: [] }] 611 | }; 612 | 613 | const result = ensureAtLeastOneIndependentSubtask(tasksData); 614 | 615 | expect(result).toBe(false); 616 | expect(tasksData).toEqual({ 617 | tasks: [{ id: 1, subtasks: [] }] 618 | }); 619 | }); 620 | }); 621 | 622 | describe('validateAndFixDependencies function', () => { 623 | test('should fix multiple dependency issues and return true if changes made', () => { 624 | const tasksData = { 625 | tasks: [ 626 | { 627 | id: 1, 628 | dependencies: [1, 1, 99], // Self-dependency and duplicate and invalid dependency 629 | subtasks: [ 630 | { id: 1, dependencies: [2, 2] }, // Duplicate dependencies 631 | { id: 2, dependencies: [1] } 632 | ] 633 | }, 634 | { 635 | id: 2, 636 | dependencies: [1], 637 | subtasks: [ 638 | { id: 1, dependencies: [99] } // Invalid dependency 639 | ] 640 | } 641 | ] 642 | }; 643 | 644 | // Mock taskExists for validating dependencies 645 | mockTaskExists.mockImplementation((tasks, id) => { 646 | // Convert id to string for comparison 647 | const idStr = String(id); 648 | 649 | // Handle subtask references (e.g., "1.2") 650 | if (idStr.includes('.')) { 651 | const [parentId, subtaskId] = idStr.split('.').map(Number); 652 | const task = tasks.find((t) => t.id === parentId); 653 | return ( 654 | task && 655 | task.subtasks && 656 | task.subtasks.some((st) => st.id === subtaskId) 657 | ); 658 | } 659 | 660 | // Handle regular task references 661 | const taskId = parseInt(idStr, 10); 662 | return taskId === 1 || taskId === 2; // Only tasks 1 and 2 exist 663 | }); 664 | 665 | // Make a copy for verification that original is modified 666 | const originalData = JSON.parse(JSON.stringify(tasksData)); 667 | 668 | const result = validateAndFixDependencies(tasksData); 669 | 670 | expect(result).toBe(true); 671 | // Check that data has been modified 672 | expect(tasksData).not.toEqual(originalData); 673 | 674 | // Check specific changes 675 | // 1. Self-dependency removed 676 | expect(tasksData.tasks[0].dependencies).not.toContain(1); 677 | // 2. Invalid dependency removed 678 | expect(tasksData.tasks[0].dependencies).not.toContain(99); 679 | // 3. Dependencies have been deduplicated 680 | if (tasksData.tasks[0].subtasks[0].dependencies.length > 0) { 681 | expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual( 682 | expect.arrayContaining([]) 683 | ); 684 | } 685 | // 4. Invalid subtask dependency removed 686 | expect(tasksData.tasks[1].subtasks[0].dependencies).toEqual([]); 687 | 688 | // IMPORTANT: Verify no calls to writeJSON with actual tasks.json 689 | expect(mockWriteJSON).not.toHaveBeenCalledWith( 690 | 'tasks/tasks.json', 691 | expect.anything() 692 | ); 693 | }); 694 | 695 | test('should return false if no changes needed', () => { 696 | const tasksData = { 697 | tasks: [ 698 | { 699 | id: 1, 700 | dependencies: [], 701 | subtasks: [ 702 | { id: 1, dependencies: [] }, // Already has an independent subtask 703 | { id: 2, dependencies: ['1.1'] } 704 | ] 705 | }, 706 | { 707 | id: 2, 708 | dependencies: [1] 709 | } 710 | ] 711 | }; 712 | 713 | // Mock taskExists to validate all dependencies as valid 714 | mockTaskExists.mockImplementation((tasks, id) => { 715 | // Convert id to string for comparison 716 | const idStr = String(id); 717 | 718 | // Handle subtask references 719 | if (idStr.includes('.')) { 720 | const [parentId, subtaskId] = idStr.split('.').map(Number); 721 | const task = tasks.find((t) => t.id === parentId); 722 | return ( 723 | task && 724 | task.subtasks && 725 | task.subtasks.some((st) => st.id === subtaskId) 726 | ); 727 | } 728 | 729 | // Handle regular task references 730 | const taskId = parseInt(idStr, 10); 731 | return taskId === 1 || taskId === 2; 732 | }); 733 | 734 | const originalData = JSON.parse(JSON.stringify(tasksData)); 735 | const result = validateAndFixDependencies(tasksData); 736 | 737 | expect(result).toBe(false); 738 | // Verify data is unchanged 739 | expect(tasksData).toEqual(originalData); 740 | 741 | // IMPORTANT: Verify no calls to writeJSON with actual tasks.json 742 | expect(mockWriteJSON).not.toHaveBeenCalledWith( 743 | 'tasks/tasks.json', 744 | expect.anything() 745 | ); 746 | }); 747 | 748 | test('should handle invalid input', () => { 749 | expect(validateAndFixDependencies(null)).toBe(false); 750 | expect(validateAndFixDependencies({})).toBe(false); 751 | expect(validateAndFixDependencies({ tasks: null })).toBe(false); 752 | expect(validateAndFixDependencies({ tasks: 'not an array' })).toBe(false); 753 | 754 | // IMPORTANT: Verify no calls to writeJSON with actual tasks.json 755 | expect(mockWriteJSON).not.toHaveBeenCalledWith( 756 | 'tasks/tasks.json', 757 | expect.anything() 758 | ); 759 | }); 760 | 761 | test('should save changes when tasksPath is provided', () => { 762 | const tasksData = { 763 | tasks: [ 764 | { 765 | id: 1, 766 | dependencies: [1, 1], // Self-dependency and duplicate 767 | subtasks: [ 768 | { id: 1, dependencies: [99] } // Invalid dependency 769 | ] 770 | } 771 | ] 772 | }; 773 | 774 | // Mock taskExists for this specific test 775 | mockTaskExists.mockImplementation((tasks, id) => { 776 | // Convert id to string for comparison 777 | const idStr = String(id); 778 | 779 | // Handle subtask references 780 | if (idStr.includes('.')) { 781 | const [parentId, subtaskId] = idStr.split('.').map(Number); 782 | const task = tasks.find((t) => t.id === parentId); 783 | return ( 784 | task && 785 | task.subtasks && 786 | task.subtasks.some((st) => st.id === subtaskId) 787 | ); 788 | } 789 | 790 | // Handle regular task references 791 | const taskId = parseInt(idStr, 10); 792 | return taskId === 1; // Only task 1 exists 793 | }); 794 | 795 | // Copy the original data to verify changes 796 | const originalData = JSON.parse(JSON.stringify(tasksData)); 797 | 798 | // Call the function with our test path instead of the actual tasks.json 799 | const result = validateAndFixDependencies(tasksData, TEST_TASKS_PATH); 800 | 801 | // First verify that the result is true (changes were made) 802 | expect(result).toBe(true); 803 | 804 | // Verify the data was modified 805 | expect(tasksData).not.toEqual(originalData); 806 | 807 | // IMPORTANT: Verify no calls to writeJSON with actual tasks.json 808 | expect(mockWriteJSON).not.toHaveBeenCalledWith( 809 | 'tasks/tasks.json', 810 | expect.anything() 811 | ); 812 | }); 813 | }); 814 | 815 | describe('canMoveWithDependencies', () => { 816 | it('should return canMove: false when conflicts exist', () => { 817 | const allTasks = [ 818 | { 819 | id: 1, 820 | tag: 'source', 821 | dependencies: [2], 822 | title: 'Task 1' 823 | }, 824 | { 825 | id: 2, 826 | tag: 'other', 827 | dependencies: [], 828 | title: 'Task 2' 829 | } 830 | ]; 831 | 832 | const result = canMoveWithDependencies('1', 'source', 'target', allTasks); 833 | 834 | expect(result.canMove).toBe(false); 835 | expect(result.conflicts).toBeDefined(); 836 | expect(result.conflicts.length).toBeGreaterThan(0); 837 | expect(result.dependentTaskIds).toBeDefined(); 838 | }); 839 | 840 | it('should return canMove: true when no conflicts exist', () => { 841 | const allTasks = [ 842 | { 843 | id: 1, 844 | tag: 'source', 845 | dependencies: [], 846 | title: 'Task 1' 847 | }, 848 | { 849 | id: 2, 850 | tag: 'target', 851 | dependencies: [], 852 | title: 'Task 2' 853 | } 854 | ]; 855 | 856 | const result = canMoveWithDependencies('1', 'source', 'target', allTasks); 857 | 858 | expect(result.canMove).toBe(true); 859 | expect(result.conflicts).toBeDefined(); 860 | expect(result.conflicts.length).toBe(0); 861 | expect(result.dependentTaskIds).toBeDefined(); 862 | expect(result.dependentTaskIds.length).toBe(0); 863 | }); 864 | 865 | it('should handle subtask lookup correctly', () => { 866 | const allTasks = [ 867 | { 868 | id: 1, 869 | tag: 'source', 870 | dependencies: [], 871 | title: 'Parent Task', 872 | subtasks: [ 873 | { 874 | id: 1, 875 | dependencies: [2], 876 | title: 'Subtask 1' 877 | } 878 | ] 879 | }, 880 | { 881 | id: 2, 882 | tag: 'other', 883 | dependencies: [], 884 | title: 'Task 2' 885 | } 886 | ]; 887 | 888 | const result = canMoveWithDependencies( 889 | '1.1', 890 | 'source', 891 | 'target', 892 | allTasks 893 | ); 894 | 895 | expect(result.canMove).toBe(false); 896 | expect(result.conflicts).toBeDefined(); 897 | expect(result.conflicts.length).toBeGreaterThan(0); 898 | }); 899 | 900 | it('should return error when task not found', () => { 901 | const allTasks = [ 902 | { 903 | id: 1, 904 | tag: 'source', 905 | dependencies: [], 906 | title: 'Task 1' 907 | } 908 | ]; 909 | 910 | const result = canMoveWithDependencies( 911 | '999', 912 | 'source', 913 | 'target', 914 | allTasks 915 | ); 916 | 917 | expect(result.canMove).toBe(false); 918 | expect(result.error).toBe('Task not found'); 919 | expect(result.dependentTaskIds).toEqual([]); 920 | expect(result.conflicts).toEqual([]); 921 | }); 922 | }); 923 | }); 924 | ``` -------------------------------------------------------------------------------- /scripts/modules/task-manager/scope-adjustment.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * scope-adjustment.js 3 | * Core logic for dynamic task complexity adjustment (scope-up and scope-down) 4 | */ 5 | 6 | import { z } from 'zod'; 7 | import { 8 | log, 9 | readJSON, 10 | writeJSON, 11 | getCurrentTag, 12 | readComplexityReport, 13 | findTaskInComplexityReport 14 | } from '../utils.js'; 15 | import { 16 | generateObjectService, 17 | generateTextService 18 | } from '../ai-services-unified.js'; 19 | import { findTaskById, taskExists } from '../task-manager.js'; 20 | import analyzeTaskComplexity from './analyze-task-complexity.js'; 21 | import { findComplexityReportPath } from '../../../src/utils/path-utils.js'; 22 | 23 | /** 24 | * Valid strength levels for scope adjustments 25 | */ 26 | const VALID_STRENGTHS = ['light', 'regular', 'heavy']; 27 | 28 | /** 29 | * Statuses that should be preserved during subtask regeneration 30 | * These represent work that has been started or intentionally set by the user 31 | */ 32 | const PRESERVE_STATUSES = [ 33 | 'done', 34 | 'in-progress', 35 | 'review', 36 | 'cancelled', 37 | 'deferred', 38 | 'blocked' 39 | ]; 40 | 41 | /** 42 | * Statuses that should be regenerated during subtask regeneration 43 | * These represent work that hasn't been started yet 44 | */ 45 | const REGENERATE_STATUSES = ['pending']; 46 | 47 | /** 48 | * Validates strength parameter 49 | * @param {string} strength - The strength level to validate 50 | * @returns {boolean} True if valid, false otherwise 51 | */ 52 | export function validateStrength(strength) { 53 | return VALID_STRENGTHS.includes(strength); 54 | } 55 | 56 | /** 57 | * Re-analyzes the complexity of a single task after scope adjustment 58 | * @param {Object} task - The task to analyze 59 | * @param {string} tasksPath - Path to tasks.json 60 | * @param {Object} context - Context containing projectRoot, tag, session 61 | * @returns {Promise<number|null>} New complexity score or null if analysis failed 62 | */ 63 | async function reanalyzeTaskComplexity(task, tasksPath, context) { 64 | const { projectRoot, tag, session } = context; 65 | 66 | try { 67 | // Create a minimal tasks data structure for analysis 68 | const tasksForAnalysis = { 69 | tasks: [task], 70 | metadata: { analyzedAt: new Date().toISOString() } 71 | }; 72 | 73 | // Find the complexity report path for this tag 74 | const complexityReportPath = findComplexityReportPath( 75 | null, 76 | { projectRoot, tag }, 77 | null 78 | ); 79 | 80 | if (!complexityReportPath) { 81 | log('warn', 'No complexity report found - cannot re-analyze complexity'); 82 | return null; 83 | } 84 | 85 | // Use analyze-task-complexity to re-analyze just this task 86 | const analysisOptions = { 87 | file: tasksPath, 88 | output: complexityReportPath, 89 | id: task.id.toString(), // Analyze only this specific task 90 | projectRoot, 91 | tag, 92 | _filteredTasksData: tasksForAnalysis, // Pass pre-filtered data 93 | _originalTaskCount: 1 94 | }; 95 | 96 | // Run the analysis with proper context 97 | await analyzeTaskComplexity(analysisOptions, { session }); 98 | 99 | // Read the updated complexity report to get the new score 100 | const updatedReport = readComplexityReport(complexityReportPath); 101 | if (updatedReport) { 102 | const taskAnalysis = findTaskInComplexityReport(updatedReport, task.id); 103 | if (taskAnalysis) { 104 | log( 105 | 'info', 106 | `Re-analyzed task ${task.id} complexity: ${taskAnalysis.complexityScore}/10` 107 | ); 108 | return taskAnalysis.complexityScore; 109 | } 110 | } 111 | 112 | log( 113 | 'warn', 114 | `Could not find updated complexity analysis for task ${task.id}` 115 | ); 116 | return null; 117 | } catch (error) { 118 | log('error', `Failed to re-analyze task complexity: ${error.message}`); 119 | return null; 120 | } 121 | } 122 | 123 | /** 124 | * Gets the current complexity score for a task from the complexity report 125 | * @param {number} taskId - Task ID to look up 126 | * @param {Object} context - Context containing projectRoot, tag 127 | * @returns {number|null} Current complexity score or null if not found 128 | */ 129 | function getCurrentComplexityScore(taskId, context) { 130 | const { projectRoot, tag } = context; 131 | 132 | try { 133 | // Find the complexity report path for this tag 134 | const complexityReportPath = findComplexityReportPath( 135 | null, 136 | { projectRoot, tag }, 137 | null 138 | ); 139 | 140 | if (!complexityReportPath) { 141 | return null; 142 | } 143 | 144 | // Read the current complexity report 145 | const complexityReport = readComplexityReport(complexityReportPath); 146 | if (!complexityReport) { 147 | return null; 148 | } 149 | 150 | // Find this task's current complexity 151 | const taskAnalysis = findTaskInComplexityReport(complexityReport, taskId); 152 | return taskAnalysis ? taskAnalysis.complexityScore : null; 153 | } catch (error) { 154 | log('debug', `Could not read current complexity score: ${error.message}`); 155 | return null; 156 | } 157 | } 158 | 159 | /** 160 | * Regenerates subtasks for a task based on new complexity while preserving completed work 161 | * @param {Object} task - The updated task object 162 | * @param {string} tasksPath - Path to tasks.json 163 | * @param {Object} context - Context containing projectRoot, tag, session 164 | * @param {string} direction - Direction of scope change (up/down) for logging 165 | * @param {string} strength - Strength level ('light', 'regular', 'heavy') 166 | * @param {number|null} originalComplexity - Original complexity score for smarter adjustments 167 | * @returns {Promise<Object>} Object with updated task and regeneration info 168 | */ 169 | async function regenerateSubtasksForComplexity( 170 | task, 171 | tasksPath, 172 | context, 173 | direction, 174 | strength = 'regular', 175 | originalComplexity = null 176 | ) { 177 | const { projectRoot, tag, session } = context; 178 | 179 | // Check if task has subtasks 180 | if ( 181 | !task.subtasks || 182 | !Array.isArray(task.subtasks) || 183 | task.subtasks.length === 0 184 | ) { 185 | return { 186 | updatedTask: task, 187 | regenerated: false, 188 | preserved: 0, 189 | generated: 0 190 | }; 191 | } 192 | 193 | // Identify subtasks to preserve vs regenerate 194 | const preservedSubtasks = task.subtasks.filter((subtask) => 195 | PRESERVE_STATUSES.includes(subtask.status) 196 | ); 197 | const pendingSubtasks = task.subtasks.filter((subtask) => 198 | REGENERATE_STATUSES.includes(subtask.status) 199 | ); 200 | 201 | // If no pending subtasks, nothing to regenerate 202 | if (pendingSubtasks.length === 0) { 203 | return { 204 | updatedTask: task, 205 | regenerated: false, 206 | preserved: preservedSubtasks.length, 207 | generated: 0 208 | }; 209 | } 210 | 211 | // Calculate appropriate number of total subtasks based on direction, complexity, strength, and original complexity 212 | let targetSubtaskCount; 213 | const preservedCount = preservedSubtasks.length; 214 | const currentPendingCount = pendingSubtasks.length; 215 | 216 | // Use original complexity to inform decisions (if available) 217 | const complexityFactor = originalComplexity 218 | ? Math.max(0.5, originalComplexity / 10) 219 | : 1.0; 220 | const complexityInfo = originalComplexity 221 | ? ` (original complexity: ${originalComplexity}/10)` 222 | : ''; 223 | 224 | if (direction === 'up') { 225 | // Scope up: More subtasks for increased complexity 226 | if (strength === 'light') { 227 | const base = Math.max( 228 | 5, 229 | preservedCount + Math.ceil(currentPendingCount * 1.1) 230 | ); 231 | targetSubtaskCount = Math.ceil(base * (0.8 + 0.4 * complexityFactor)); 232 | } else if (strength === 'regular') { 233 | const base = Math.max( 234 | 6, 235 | preservedCount + Math.ceil(currentPendingCount * 1.3) 236 | ); 237 | targetSubtaskCount = Math.ceil(base * (0.8 + 0.4 * complexityFactor)); 238 | } else { 239 | // heavy 240 | const base = Math.max( 241 | 8, 242 | preservedCount + Math.ceil(currentPendingCount * 1.6) 243 | ); 244 | targetSubtaskCount = Math.ceil(base * (0.8 + 0.6 * complexityFactor)); 245 | } 246 | } else { 247 | // Scope down: Fewer subtasks for decreased complexity 248 | // High complexity tasks get reduced more aggressively 249 | const aggressiveFactor = 250 | originalComplexity >= 8 ? 0.7 : originalComplexity >= 6 ? 0.85 : 1.0; 251 | 252 | if (strength === 'light') { 253 | const base = Math.max( 254 | 3, 255 | preservedCount + Math.ceil(currentPendingCount * 0.8) 256 | ); 257 | targetSubtaskCount = Math.ceil(base * aggressiveFactor); 258 | } else if (strength === 'regular') { 259 | const base = Math.max( 260 | 3, 261 | preservedCount + Math.ceil(currentPendingCount * 0.5) 262 | ); 263 | targetSubtaskCount = Math.ceil(base * aggressiveFactor); 264 | } else { 265 | // heavy 266 | // Heavy scope-down should be much more aggressive - aim for only core functionality 267 | // Very high complexity tasks (9-10) get reduced to almost nothing 268 | const ultraAggressiveFactor = 269 | originalComplexity >= 9 ? 0.3 : originalComplexity >= 7 ? 0.5 : 0.7; 270 | const base = Math.max( 271 | 2, 272 | preservedCount + Math.ceil(currentPendingCount * 0.25) 273 | ); 274 | targetSubtaskCount = Math.max(1, Math.ceil(base * ultraAggressiveFactor)); 275 | } 276 | } 277 | 278 | log( 279 | 'debug', 280 | `Complexity-aware subtask calculation${complexityInfo}: ${currentPendingCount} pending -> target ${targetSubtaskCount} total` 281 | ); 282 | log( 283 | 'debug', 284 | `Complexity-aware calculation${complexityInfo}: ${currentPendingCount} pending -> ${targetSubtaskCount} total subtasks (${strength} ${direction})` 285 | ); 286 | 287 | const newSubtasksNeeded = Math.max(1, targetSubtaskCount - preservedCount); 288 | 289 | try { 290 | // Generate new subtasks using AI to match the new complexity level 291 | const systemPrompt = `You are an expert project manager who creates task breakdowns that match complexity levels.`; 292 | 293 | const prompt = `Based on this updated task, generate ${newSubtasksNeeded} NEW subtasks that reflect the ${direction === 'up' ? 'increased' : 'decreased'} complexity level: 294 | 295 | **Task Title**: ${task.title} 296 | **Task Description**: ${task.description} 297 | **Implementation Details**: ${task.details} 298 | **Test Strategy**: ${task.testStrategy} 299 | 300 | **Complexity Direction**: This task was recently scoped ${direction} (${strength} strength) to ${direction === 'up' ? 'increase' : 'decrease'} complexity. 301 | ${originalComplexity ? `**Original Complexity**: ${originalComplexity}/10 - consider this when determining appropriate scope level.` : ''} 302 | 303 | ${preservedCount > 0 ? `**Preserved Subtasks**: ${preservedCount} existing subtasks with work already done will be kept.` : ''} 304 | 305 | Generate subtasks that: 306 | ${ 307 | direction === 'up' 308 | ? strength === 'heavy' 309 | ? `- Add comprehensive implementation steps with advanced features 310 | - Include extensive error handling, validation, and edge cases 311 | - Cover multiple integration scenarios and advanced testing 312 | - Provide thorough documentation and optimization approaches` 313 | : strength === 'regular' 314 | ? `- Add more detailed implementation steps 315 | - Include additional error handling and validation 316 | - Cover more edge cases and advanced features 317 | - Provide more comprehensive testing approaches` 318 | : `- Add some additional implementation details 319 | - Include basic error handling considerations 320 | - Cover a few common edge cases 321 | - Enhance testing approaches slightly` 322 | : strength === 'heavy' 323 | ? `- Focus ONLY on absolutely essential core functionality 324 | - Strip out ALL non-critical features (error handling, advanced testing, etc.) 325 | - Provide only the minimum viable implementation 326 | - Eliminate any complex integrations or advanced scenarios 327 | - Aim for the simplest possible working solution` 328 | : strength === 'regular' 329 | ? `- Focus on core functionality only 330 | - Simplify implementation steps 331 | - Remove non-essential features 332 | - Streamline to basic requirements` 333 | : `- Focus mainly on core functionality 334 | - Slightly simplify implementation steps 335 | - Remove some non-essential features 336 | - Streamline most requirements` 337 | } 338 | 339 | Return a JSON object with a "subtasks" array. Each subtask should have: 340 | - id: Sequential NUMBER starting from 1 (e.g., 1, 2, 3 - NOT "1", "2", "3") 341 | - title: Clear, specific title 342 | - description: Detailed description 343 | - dependencies: Array of dependency IDs as STRINGS (use format ["${task.id}.1", "${task.id}.2"] for siblings, or empty array [] for no dependencies) 344 | - details: Implementation guidance 345 | - status: "pending" 346 | - testStrategy: Testing approach 347 | 348 | IMPORTANT: 349 | - The 'id' field must be a NUMBER, not a string! 350 | - Dependencies must be strings, not numbers! 351 | 352 | Ensure the JSON is valid and properly formatted.`; 353 | 354 | // Define subtask schema 355 | const subtaskSchema = z.object({ 356 | subtasks: z.array( 357 | z.object({ 358 | id: z.number().int().positive(), 359 | title: z.string().min(5), 360 | description: z.string().min(10), 361 | dependencies: z.array(z.string()), 362 | details: z.string().min(20), 363 | status: z.string(), 364 | testStrategy: z.string() 365 | }) 366 | ) 367 | }); 368 | 369 | const aiResult = await generateObjectService({ 370 | role: context.research ? 'research' : 'main', 371 | session: context.session, 372 | systemPrompt, 373 | prompt, 374 | schema: subtaskSchema, 375 | objectName: 'subtask_regeneration', 376 | commandName: context.commandName || `subtask-regen-${direction}`, 377 | outputType: context.outputType || 'cli' 378 | }); 379 | 380 | const generatedSubtasks = aiResult.mainResult.subtasks || []; 381 | 382 | // Post-process generated subtasks to ensure defaults 383 | const processedGeneratedSubtasks = generatedSubtasks.map((subtask) => ({ 384 | ...subtask, 385 | status: subtask.status || 'pending', 386 | testStrategy: subtask.testStrategy || '' 387 | })); 388 | 389 | // Update task with preserved subtasks + newly generated ones 390 | task.subtasks = [...preservedSubtasks, ...processedGeneratedSubtasks]; 391 | 392 | return { 393 | updatedTask: task, 394 | regenerated: true, 395 | preserved: preservedSubtasks.length, 396 | generated: processedGeneratedSubtasks.length 397 | }; 398 | } catch (error) { 399 | log( 400 | 'warn', 401 | `Failed to regenerate subtasks for task ${task.id}: ${error.message}` 402 | ); 403 | // Don't fail the whole operation if subtask regeneration fails 404 | return { 405 | updatedTask: task, 406 | regenerated: false, 407 | preserved: preservedSubtasks.length, 408 | generated: 0, 409 | error: error.message 410 | }; 411 | } 412 | } 413 | 414 | /** 415 | * Generates AI prompt for scope adjustment 416 | * @param {Object} task - The task to adjust 417 | * @param {string} direction - 'up' or 'down' 418 | * @param {string} strength - 'light', 'regular', or 'heavy' 419 | * @param {string} customPrompt - Optional custom instructions 420 | * @returns {string} The generated prompt 421 | */ 422 | function generateScopePrompt(task, direction, strength, customPrompt) { 423 | const isUp = direction === 'up'; 424 | const strengthDescriptions = { 425 | light: isUp ? 'minor enhancements' : 'slight simplifications', 426 | regular: isUp 427 | ? 'moderate complexity increases' 428 | : 'moderate simplifications', 429 | heavy: isUp ? 'significant complexity additions' : 'major simplifications' 430 | }; 431 | 432 | let basePrompt = `You are tasked with adjusting the complexity of a task. 433 | 434 | CURRENT TASK: 435 | Title: ${task.title} 436 | Description: ${task.description} 437 | Details: ${task.details} 438 | Test Strategy: ${task.testStrategy || 'Not specified'} 439 | 440 | ADJUSTMENT REQUIREMENTS: 441 | - Direction: ${isUp ? 'INCREASE' : 'DECREASE'} complexity 442 | - Strength: ${strength} (${strengthDescriptions[strength]}) 443 | - Preserve the core purpose and functionality of the task 444 | - Maintain consistency with the existing task structure`; 445 | 446 | if (isUp) { 447 | basePrompt += ` 448 | - Add more detailed requirements, edge cases, or advanced features 449 | - Include additional implementation considerations 450 | - Enhance error handling and validation requirements 451 | - Expand testing strategies with more comprehensive scenarios`; 452 | } else { 453 | basePrompt += ` 454 | - Focus on core functionality and essential requirements 455 | - Remove or simplify non-essential features 456 | - Streamline implementation details 457 | - Simplify testing to focus on basic functionality`; 458 | } 459 | 460 | if (customPrompt) { 461 | basePrompt += `\n\nCUSTOM INSTRUCTIONS:\n${customPrompt}`; 462 | } 463 | 464 | basePrompt += `\n\nReturn a JSON object with the updated task containing these fields: 465 | - title: Updated task title 466 | - description: Updated task description 467 | - details: Updated implementation details 468 | - testStrategy: Updated test strategy 469 | - priority: Task priority ('low', 'medium', or 'high') 470 | 471 | Ensure the JSON is valid and properly formatted.`; 472 | 473 | return basePrompt; 474 | } 475 | 476 | /** 477 | * Adjusts task complexity using AI 478 | * @param {Object} task - The task to adjust 479 | * @param {string} direction - 'up' or 'down' 480 | * @param {string} strength - 'light', 'regular', or 'heavy' 481 | * @param {string} customPrompt - Optional custom instructions 482 | * @param {Object} context - Context object with projectRoot, tag, etc. 483 | * @returns {Promise<Object>} Updated task data and telemetry 484 | */ 485 | async function adjustTaskComplexity( 486 | task, 487 | direction, 488 | strength, 489 | customPrompt, 490 | context 491 | ) { 492 | const systemPrompt = `You are an expert software project manager who helps adjust task complexity while maintaining clarity and actionability.`; 493 | 494 | const prompt = generateScopePrompt(task, direction, strength, customPrompt); 495 | 496 | // Define the task schema for structured response using Zod 497 | const taskSchema = z.object({ 498 | title: z 499 | .string() 500 | .min(1) 501 | .describe('Updated task title reflecting scope adjustment'), 502 | description: z 503 | .string() 504 | .min(1) 505 | .describe('Updated task description with adjusted scope'), 506 | details: z 507 | .string() 508 | .min(1) 509 | .describe('Updated implementation details with adjusted complexity'), 510 | testStrategy: z 511 | .string() 512 | .min(1) 513 | .describe('Updated testing approach for the adjusted scope'), 514 | priority: z.enum(['low', 'medium', 'high']).describe('Task priority level') 515 | }); 516 | 517 | const aiResult = await generateObjectService({ 518 | role: context.research ? 'research' : 'main', 519 | session: context.session, 520 | systemPrompt, 521 | prompt, 522 | schema: taskSchema, 523 | objectName: 'updated_task', 524 | commandName: context.commandName || `scope-${direction}`, 525 | outputType: context.outputType || 'cli' 526 | }); 527 | 528 | const updatedTaskData = aiResult.mainResult; 529 | 530 | // Ensure priority has a value (in case AI didn't provide one) 531 | const processedTaskData = { 532 | ...updatedTaskData, 533 | priority: updatedTaskData.priority || task.priority || 'medium' 534 | }; 535 | 536 | return { 537 | updatedTask: { 538 | ...task, 539 | ...processedTaskData 540 | }, 541 | telemetryData: aiResult.telemetryData 542 | }; 543 | } 544 | 545 | /** 546 | * Increases task complexity (scope-up) 547 | * @param {string} tasksPath - Path to tasks.json file 548 | * @param {Array<number>} taskIds - Array of task IDs to scope up 549 | * @param {string} strength - Strength level ('light', 'regular', 'heavy') 550 | * @param {string} customPrompt - Optional custom instructions 551 | * @param {Object} context - Context object with projectRoot, tag, etc. 552 | * @param {string} outputFormat - Output format ('text' or 'json') 553 | * @returns {Promise<Object>} Results of the scope-up operation 554 | */ 555 | export async function scopeUpTask( 556 | tasksPath, 557 | taskIds, 558 | strength = 'regular', 559 | customPrompt = null, 560 | context = {}, 561 | outputFormat = 'text' 562 | ) { 563 | // Validate inputs 564 | if (!validateStrength(strength)) { 565 | throw new Error( 566 | `Invalid strength level: ${strength}. Must be one of: ${VALID_STRENGTHS.join(', ')}` 567 | ); 568 | } 569 | 570 | const { projectRoot = '.', tag = 'master' } = context; 571 | 572 | // Read tasks data 573 | const data = readJSON(tasksPath, projectRoot, tag); 574 | const tasks = data?.tasks || []; 575 | 576 | // Validate all task IDs exist 577 | for (const taskId of taskIds) { 578 | if (!taskExists(tasks, taskId)) { 579 | throw new Error(`Task with ID ${taskId} not found`); 580 | } 581 | } 582 | 583 | const updatedTasks = []; 584 | let combinedTelemetryData = null; 585 | 586 | // Process each task 587 | for (const taskId of taskIds) { 588 | const taskResult = findTaskById(tasks, taskId); 589 | const task = taskResult.task; 590 | if (!task) { 591 | throw new Error(`Task with ID ${taskId} not found`); 592 | } 593 | 594 | if (outputFormat === 'text') { 595 | log('info', `Scoping up task ${taskId}: ${task.title}`); 596 | } 597 | 598 | // Get original complexity score (if available) 599 | const originalComplexity = getCurrentComplexityScore(taskId, context); 600 | if (originalComplexity && outputFormat === 'text') { 601 | log('info', `Original complexity: ${originalComplexity}/10`); 602 | } 603 | 604 | const adjustResult = await adjustTaskComplexity( 605 | task, 606 | 'up', 607 | strength, 608 | customPrompt, 609 | context 610 | ); 611 | 612 | // Regenerate subtasks based on new complexity while preserving completed work 613 | const subtaskResult = await regenerateSubtasksForComplexity( 614 | adjustResult.updatedTask, 615 | tasksPath, 616 | context, 617 | 'up', 618 | strength, 619 | originalComplexity 620 | ); 621 | 622 | // Log subtask regeneration info if in text mode 623 | if (outputFormat === 'text' && subtaskResult.regenerated) { 624 | log( 625 | 'info', 626 | `Regenerated ${subtaskResult.generated} pending subtasks (preserved ${subtaskResult.preserved} completed)` 627 | ); 628 | } 629 | 630 | // Update task in data 631 | const taskIndex = data.tasks.findIndex((t) => t.id === taskId); 632 | if (taskIndex !== -1) { 633 | data.tasks[taskIndex] = subtaskResult.updatedTask; 634 | updatedTasks.push(subtaskResult.updatedTask); 635 | } 636 | 637 | // Re-analyze complexity after scoping (if we have a session for AI calls) 638 | if (context.session && originalComplexity) { 639 | try { 640 | // Write the updated task first so complexity analysis can read it 641 | writeJSON(tasksPath, data, projectRoot, tag); 642 | 643 | // Re-analyze complexity 644 | const newComplexity = await reanalyzeTaskComplexity( 645 | subtaskResult.updatedTask, 646 | tasksPath, 647 | context 648 | ); 649 | if (newComplexity && outputFormat === 'text') { 650 | const complexityChange = newComplexity - originalComplexity; 651 | const arrow = 652 | complexityChange > 0 ? '↗️' : complexityChange < 0 ? '↘️' : '➡️'; 653 | log( 654 | 'info', 655 | `New complexity: ${originalComplexity}/10 ${arrow} ${newComplexity}/10 (${complexityChange > 0 ? '+' : ''}${complexityChange})` 656 | ); 657 | } 658 | } catch (error) { 659 | if (outputFormat === 'text') { 660 | log('warn', `Could not re-analyze complexity: ${error.message}`); 661 | } 662 | } 663 | } 664 | 665 | // Combine telemetry data 666 | if (adjustResult.telemetryData) { 667 | if (!combinedTelemetryData) { 668 | combinedTelemetryData = { ...adjustResult.telemetryData }; 669 | } else { 670 | // Sum up costs and tokens 671 | combinedTelemetryData.inputTokens += 672 | adjustResult.telemetryData.inputTokens || 0; 673 | combinedTelemetryData.outputTokens += 674 | adjustResult.telemetryData.outputTokens || 0; 675 | combinedTelemetryData.totalTokens += 676 | adjustResult.telemetryData.totalTokens || 0; 677 | combinedTelemetryData.totalCost += 678 | adjustResult.telemetryData.totalCost || 0; 679 | } 680 | } 681 | } 682 | 683 | // Write updated data 684 | writeJSON(tasksPath, data, projectRoot, tag); 685 | 686 | if (outputFormat === 'text') { 687 | log('info', `Successfully scoped up ${updatedTasks.length} task(s)`); 688 | } 689 | 690 | return { 691 | updatedTasks, 692 | telemetryData: combinedTelemetryData 693 | }; 694 | } 695 | 696 | /** 697 | * Decreases task complexity (scope-down) 698 | * @param {string} tasksPath - Path to tasks.json file 699 | * @param {Array<number>} taskIds - Array of task IDs to scope down 700 | * @param {string} strength - Strength level ('light', 'regular', 'heavy') 701 | * @param {string} customPrompt - Optional custom instructions 702 | * @param {Object} context - Context object with projectRoot, tag, etc. 703 | * @param {string} outputFormat - Output format ('text' or 'json') 704 | * @returns {Promise<Object>} Results of the scope-down operation 705 | */ 706 | export async function scopeDownTask( 707 | tasksPath, 708 | taskIds, 709 | strength = 'regular', 710 | customPrompt = null, 711 | context = {}, 712 | outputFormat = 'text' 713 | ) { 714 | // Validate inputs 715 | if (!validateStrength(strength)) { 716 | throw new Error( 717 | `Invalid strength level: ${strength}. Must be one of: ${VALID_STRENGTHS.join(', ')}` 718 | ); 719 | } 720 | 721 | const { projectRoot = '.', tag = 'master' } = context; 722 | 723 | // Read tasks data 724 | const data = readJSON(tasksPath, projectRoot, tag); 725 | const tasks = data?.tasks || []; 726 | 727 | // Validate all task IDs exist 728 | for (const taskId of taskIds) { 729 | if (!taskExists(tasks, taskId)) { 730 | throw new Error(`Task with ID ${taskId} not found`); 731 | } 732 | } 733 | 734 | const updatedTasks = []; 735 | let combinedTelemetryData = null; 736 | 737 | // Process each task 738 | for (const taskId of taskIds) { 739 | const taskResult = findTaskById(tasks, taskId); 740 | const task = taskResult.task; 741 | if (!task) { 742 | throw new Error(`Task with ID ${taskId} not found`); 743 | } 744 | 745 | if (outputFormat === 'text') { 746 | log('info', `Scoping down task ${taskId}: ${task.title}`); 747 | } 748 | 749 | // Get original complexity score (if available) 750 | const originalComplexity = getCurrentComplexityScore(taskId, context); 751 | if (originalComplexity && outputFormat === 'text') { 752 | log('info', `Original complexity: ${originalComplexity}/10`); 753 | } 754 | 755 | const adjustResult = await adjustTaskComplexity( 756 | task, 757 | 'down', 758 | strength, 759 | customPrompt, 760 | context 761 | ); 762 | 763 | // Regenerate subtasks based on new complexity while preserving completed work 764 | const subtaskResult = await regenerateSubtasksForComplexity( 765 | adjustResult.updatedTask, 766 | tasksPath, 767 | context, 768 | 'down', 769 | strength, 770 | originalComplexity 771 | ); 772 | 773 | // Log subtask regeneration info if in text mode 774 | if (outputFormat === 'text' && subtaskResult.regenerated) { 775 | log( 776 | 'info', 777 | `Regenerated ${subtaskResult.generated} pending subtasks (preserved ${subtaskResult.preserved} completed)` 778 | ); 779 | } 780 | 781 | // Update task in data 782 | const taskIndex = data.tasks.findIndex((t) => t.id === taskId); 783 | if (taskIndex !== -1) { 784 | data.tasks[taskIndex] = subtaskResult.updatedTask; 785 | updatedTasks.push(subtaskResult.updatedTask); 786 | } 787 | 788 | // Re-analyze complexity after scoping (if we have a session for AI calls) 789 | if (context.session && originalComplexity) { 790 | try { 791 | // Write the updated task first so complexity analysis can read it 792 | writeJSON(tasksPath, data, projectRoot, tag); 793 | 794 | // Re-analyze complexity 795 | const newComplexity = await reanalyzeTaskComplexity( 796 | subtaskResult.updatedTask, 797 | tasksPath, 798 | context 799 | ); 800 | if (newComplexity && outputFormat === 'text') { 801 | const complexityChange = newComplexity - originalComplexity; 802 | const arrow = 803 | complexityChange > 0 ? '↗️' : complexityChange < 0 ? '↘️' : '➡️'; 804 | log( 805 | 'info', 806 | `New complexity: ${originalComplexity}/10 ${arrow} ${newComplexity}/10 (${complexityChange > 0 ? '+' : ''}${complexityChange})` 807 | ); 808 | } 809 | } catch (error) { 810 | if (outputFormat === 'text') { 811 | log('warn', `Could not re-analyze complexity: ${error.message}`); 812 | } 813 | } 814 | } 815 | 816 | // Combine telemetry data 817 | if (adjustResult.telemetryData) { 818 | if (!combinedTelemetryData) { 819 | combinedTelemetryData = { ...adjustResult.telemetryData }; 820 | } else { 821 | // Sum up costs and tokens 822 | combinedTelemetryData.inputTokens += 823 | adjustResult.telemetryData.inputTokens || 0; 824 | combinedTelemetryData.outputTokens += 825 | adjustResult.telemetryData.outputTokens || 0; 826 | combinedTelemetryData.totalTokens += 827 | adjustResult.telemetryData.totalTokens || 0; 828 | combinedTelemetryData.totalCost += 829 | adjustResult.telemetryData.totalCost || 0; 830 | } 831 | } 832 | } 833 | 834 | // Write updated data 835 | writeJSON(tasksPath, data, projectRoot, tag); 836 | 837 | if (outputFormat === 'text') { 838 | log('info', `Successfully scoped down ${updatedTasks.length} task(s)`); 839 | } 840 | 841 | return { 842 | updatedTasks, 843 | telemetryData: combinedTelemetryData 844 | }; 845 | } 846 | ``` -------------------------------------------------------------------------------- /scripts/modules/utils/contextGatherer.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * contextGatherer.js 3 | * Comprehensive context gathering utility for Task Master AI operations 4 | * Supports task context, file context, project tree, and custom context 5 | */ 6 | 7 | import fs from 'fs'; 8 | import path from 'path'; 9 | import pkg from 'gpt-tokens'; 10 | import Fuse from 'fuse.js'; 11 | import { 12 | readJSON, 13 | findTaskById, 14 | truncate, 15 | flattenTasksWithSubtasks 16 | } from '../utils.js'; 17 | 18 | const { encode } = pkg; 19 | 20 | /** 21 | * Context Gatherer class for collecting and formatting context from various sources 22 | */ 23 | export class ContextGatherer { 24 | constructor(projectRoot, tag) { 25 | this.projectRoot = projectRoot; 26 | this.tasksPath = path.join( 27 | projectRoot, 28 | '.taskmaster', 29 | 'tasks', 30 | 'tasks.json' 31 | ); 32 | this.tag = tag; 33 | this.allTasks = this._loadAllTasks(); 34 | } 35 | 36 | _loadAllTasks() { 37 | try { 38 | const data = readJSON(this.tasksPath, this.projectRoot, this.tag); 39 | const tasks = data?.tasks || []; 40 | return tasks; 41 | } catch (error) { 42 | console.warn( 43 | `Warning: Could not load tasks for ContextGatherer: ${error.message}` 44 | ); 45 | return []; 46 | } 47 | } 48 | 49 | /** 50 | * Count tokens in a text string using gpt-tokens 51 | * @param {string} text - Text to count tokens for 52 | * @returns {number} Token count 53 | */ 54 | countTokens(text) { 55 | if (!text || typeof text !== 'string') { 56 | return 0; 57 | } 58 | try { 59 | return encode(text).length; 60 | } catch (error) { 61 | // Fallback to rough character-based estimation if tokenizer fails 62 | // Rough estimate: ~4 characters per token for English text 63 | return Math.ceil(text.length / 4); 64 | } 65 | } 66 | 67 | /** 68 | * Main method to gather context from multiple sources 69 | * @param {Object} options - Context gathering options 70 | * @param {Array<string>} [options.tasks] - Task/subtask IDs to include 71 | * @param {Array<string>} [options.files] - File paths to include 72 | * @param {string} [options.customContext] - Additional custom context 73 | * @param {boolean} [options.includeProjectTree] - Include project file tree 74 | * @param {string} [options.format] - Output format: 'research', 'chat', 'system-prompt' 75 | * @param {boolean} [options.includeTokenCounts] - Whether to include token breakdown 76 | * @param {string} [options.semanticQuery] - A query string for semantic task searching. 77 | * @param {number} [options.maxSemanticResults] - Max number of semantic results. 78 | * @param {Array<number>} [options.dependencyTasks] - Array of task IDs to build dependency graphs from. 79 | * @returns {Promise<Object>} Object with context string and analysis data 80 | */ 81 | async gather(options = {}) { 82 | const { 83 | tasks = [], 84 | files = [], 85 | customContext = '', 86 | includeProjectTree = false, 87 | format = 'research', 88 | includeTokenCounts = false, 89 | semanticQuery, 90 | maxSemanticResults = 10, 91 | dependencyTasks = [] 92 | } = options; 93 | 94 | const contextSections = []; 95 | const finalTaskIds = new Set(tasks.map(String)); 96 | let analysisData = null; 97 | let tokenBreakdown = null; 98 | 99 | // Initialize token breakdown if requested 100 | if (includeTokenCounts) { 101 | tokenBreakdown = { 102 | total: 0, 103 | customContext: null, 104 | tasks: [], 105 | files: [], 106 | projectTree: null 107 | }; 108 | } 109 | 110 | // Semantic Search 111 | if (semanticQuery && this.allTasks.length > 0) { 112 | const semanticResults = this._performSemanticSearch( 113 | semanticQuery, 114 | maxSemanticResults 115 | ); 116 | 117 | // Store the analysis data for UI display 118 | analysisData = semanticResults.analysisData; 119 | 120 | semanticResults.tasks.forEach((task) => { 121 | finalTaskIds.add(String(task.id)); 122 | }); 123 | } 124 | 125 | // Dependency Graph Analysis 126 | if (dependencyTasks.length > 0) { 127 | const dependencyResults = this._buildDependencyGraphs(dependencyTasks); 128 | dependencyResults.allRelatedTaskIds.forEach((id) => 129 | finalTaskIds.add(String(id)) 130 | ); 131 | // We can format and add dependencyResults.graphVisualization later if needed 132 | } 133 | 134 | // Add custom context first 135 | if (customContext && customContext.trim()) { 136 | const formattedCustomContext = this._formatCustomContext( 137 | customContext, 138 | format 139 | ); 140 | contextSections.push(formattedCustomContext); 141 | 142 | // Calculate tokens for custom context if requested 143 | if (includeTokenCounts) { 144 | tokenBreakdown.customContext = { 145 | tokens: this.countTokens(formattedCustomContext), 146 | characters: formattedCustomContext.length 147 | }; 148 | tokenBreakdown.total += tokenBreakdown.customContext.tokens; 149 | } 150 | } 151 | 152 | // Gather context for the final list of tasks 153 | if (finalTaskIds.size > 0) { 154 | const taskContextResult = await this._gatherTaskContext( 155 | Array.from(finalTaskIds), 156 | format, 157 | includeTokenCounts 158 | ); 159 | if (taskContextResult.context) { 160 | contextSections.push(taskContextResult.context); 161 | 162 | // Add task breakdown if token counting is enabled 163 | if (includeTokenCounts && taskContextResult.breakdown) { 164 | tokenBreakdown.tasks = taskContextResult.breakdown; 165 | const taskTokens = taskContextResult.breakdown.reduce( 166 | (sum, task) => sum + task.tokens, 167 | 0 168 | ); 169 | tokenBreakdown.total += taskTokens; 170 | } 171 | } 172 | } 173 | 174 | // Add file context 175 | if (files.length > 0) { 176 | const fileContextResult = await this._gatherFileContext( 177 | files, 178 | format, 179 | includeTokenCounts 180 | ); 181 | if (fileContextResult.context) { 182 | contextSections.push(fileContextResult.context); 183 | 184 | // Add file breakdown if token counting is enabled 185 | if (includeTokenCounts && fileContextResult.breakdown) { 186 | tokenBreakdown.files = fileContextResult.breakdown; 187 | const fileTokens = fileContextResult.breakdown.reduce( 188 | (sum, file) => sum + file.tokens, 189 | 0 190 | ); 191 | tokenBreakdown.total += fileTokens; 192 | } 193 | } 194 | } 195 | 196 | // Add project tree context 197 | if (includeProjectTree) { 198 | const treeContextResult = await this._gatherProjectTreeContext( 199 | format, 200 | includeTokenCounts 201 | ); 202 | if (treeContextResult.context) { 203 | contextSections.push(treeContextResult.context); 204 | 205 | // Add tree breakdown if token counting is enabled 206 | if (includeTokenCounts && treeContextResult.breakdown) { 207 | tokenBreakdown.projectTree = treeContextResult.breakdown; 208 | tokenBreakdown.total += treeContextResult.breakdown.tokens; 209 | } 210 | } 211 | } 212 | 213 | const finalContext = this._joinContextSections(contextSections, format); 214 | 215 | const result = { 216 | context: finalContext, 217 | analysisData: analysisData, 218 | contextSections: contextSections.length, 219 | finalTaskIds: Array.from(finalTaskIds) 220 | }; 221 | 222 | // Only include tokenBreakdown if it was requested 223 | if (includeTokenCounts) { 224 | result.tokenBreakdown = tokenBreakdown; 225 | } 226 | 227 | return result; 228 | } 229 | 230 | _performSemanticSearch(query, maxResults) { 231 | const searchableTasks = this.allTasks.map((task) => { 232 | const dependencyTitles = 233 | task.dependencies?.length > 0 234 | ? task.dependencies 235 | .map((depId) => this.allTasks.find((t) => t.id === depId)?.title) 236 | .filter(Boolean) 237 | .join(' ') 238 | : ''; 239 | return { ...task, dependencyTitles }; 240 | }); 241 | 242 | // Use the exact same approach as add-task.js 243 | const searchOptions = { 244 | includeScore: true, // Return match scores 245 | threshold: 0.4, // Lower threshold = stricter matching (range 0-1) 246 | keys: [ 247 | { name: 'title', weight: 1.5 }, // Title is most important 248 | { name: 'description', weight: 2 }, // Description is very important 249 | { name: 'details', weight: 3 }, // Details is most important 250 | // Search dependencies to find tasks that depend on similar things 251 | { name: 'dependencyTitles', weight: 0.5 } 252 | ], 253 | // Sort matches by score (lower is better) 254 | shouldSort: true, 255 | // Allow searching in nested properties 256 | useExtendedSearch: true, 257 | // Return up to 50 matches 258 | limit: 50 259 | }; 260 | 261 | // Create search index using Fuse.js 262 | const fuse = new Fuse(searchableTasks, searchOptions); 263 | 264 | // Extract significant words and phrases from the prompt (like add-task.js does) 265 | const promptWords = query 266 | .toLowerCase() 267 | .replace(/[^\w\s-]/g, ' ') // Replace non-alphanumeric chars with spaces 268 | .split(/\s+/) 269 | .filter((word) => word.length > 3); // Words at least 4 chars 270 | 271 | // Use the user's prompt for fuzzy search 272 | const fuzzyResults = fuse.search(query); 273 | 274 | // Also search for each significant word to catch different aspects 275 | const wordResults = []; 276 | for (const word of promptWords) { 277 | if (word.length > 5) { 278 | // Only use significant words 279 | const results = fuse.search(word); 280 | if (results.length > 0) { 281 | wordResults.push(...results); 282 | } 283 | } 284 | } 285 | 286 | // Merge and deduplicate results 287 | const mergedResults = [...fuzzyResults]; 288 | 289 | // Add word results that aren't already in fuzzyResults 290 | for (const wordResult of wordResults) { 291 | if (!mergedResults.some((r) => r.item.id === wordResult.item.id)) { 292 | mergedResults.push(wordResult); 293 | } 294 | } 295 | 296 | // Group search results by relevance 297 | const highRelevance = mergedResults 298 | .filter((result) => result.score < 0.25) 299 | .map((result) => result.item); 300 | 301 | const mediumRelevance = mergedResults 302 | .filter((result) => result.score >= 0.25 && result.score < 0.4) 303 | .map((result) => result.item); 304 | 305 | // Get recent tasks (newest first) 306 | const recentTasks = [...this.allTasks] 307 | .sort((a, b) => b.id - a.id) 308 | .slice(0, 5); 309 | 310 | // Combine high relevance, medium relevance, and recent tasks 311 | // Prioritize high relevance first 312 | const allRelevantTasks = [...highRelevance]; 313 | 314 | // Add medium relevance if not already included 315 | for (const task of mediumRelevance) { 316 | if (!allRelevantTasks.some((t) => t.id === task.id)) { 317 | allRelevantTasks.push(task); 318 | } 319 | } 320 | 321 | // Add recent tasks if not already included 322 | for (const task of recentTasks) { 323 | if (!allRelevantTasks.some((t) => t.id === task.id)) { 324 | allRelevantTasks.push(task); 325 | } 326 | } 327 | 328 | // Get top N results for context 329 | const finalResults = allRelevantTasks.slice(0, maxResults); 330 | return { 331 | tasks: finalResults, 332 | analysisData: { 333 | highRelevance: highRelevance, 334 | mediumRelevance: mediumRelevance, 335 | recentTasks: recentTasks, 336 | allRelevantTasks: allRelevantTasks 337 | } 338 | }; 339 | } 340 | 341 | _buildDependencyContext(taskIds) { 342 | const { allRelatedTaskIds, graphs, depthMap } = 343 | this._buildDependencyGraphs(taskIds); 344 | if (allRelatedTaskIds.size === 0) return ''; 345 | 346 | const dependentTasks = Array.from(allRelatedTaskIds) 347 | .map((id) => this.allTasks.find((t) => t.id === id)) 348 | .filter(Boolean) 349 | .sort((a, b) => (depthMap.get(a.id) || 0) - (depthMap.get(b.id) || 0)); 350 | 351 | const uniqueDetailedTasks = dependentTasks.slice(0, 8); 352 | 353 | let context = `\nThis task relates to a dependency structure with ${dependentTasks.length} related tasks in the chain.`; 354 | 355 | const directDeps = this.allTasks.filter((t) => taskIds.includes(t.id)); 356 | if (directDeps.length > 0) { 357 | context += `\n\nDirect dependencies:\n${directDeps 358 | .map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`) 359 | .join('\n')}`; 360 | } 361 | 362 | const indirectDeps = dependentTasks.filter((t) => !taskIds.includes(t.id)); 363 | if (indirectDeps.length > 0) { 364 | context += `\n\nIndirect dependencies (dependencies of dependencies):\n${indirectDeps 365 | .slice(0, 5) 366 | .map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`) 367 | .join('\n')}`; 368 | if (indirectDeps.length > 5) 369 | context += `\n- ... and ${ 370 | indirectDeps.length - 5 371 | } more indirect dependencies`; 372 | } 373 | 374 | context += `\n\nDetailed information about dependencies:`; 375 | for (const depTask of uniqueDetailedTasks) { 376 | const isDirect = taskIds.includes(depTask.id) 377 | ? ' [DIRECT DEPENDENCY]' 378 | : ''; 379 | context += `\n\n------ Task ${depTask.id}${isDirect}: ${depTask.title} ------\n`; 380 | context += `Description: ${depTask.description}\n`; 381 | if (depTask.dependencies?.length) { 382 | context += `Dependencies: ${depTask.dependencies.join(', ')}\n`; 383 | } 384 | if (depTask.details) { 385 | context += `Implementation Details: ${truncate( 386 | depTask.details, 387 | 400 388 | )}\n`; 389 | } 390 | } 391 | 392 | if (graphs.length > 0) { 393 | context += '\n\nDependency Chain Visualization:'; 394 | context += graphs 395 | .map((graph) => this._formatDependencyChain(graph)) 396 | .join(''); 397 | } 398 | 399 | return context; 400 | } 401 | 402 | _buildDependencyGraphs(taskIds) { 403 | const visited = new Set(); 404 | const depthMap = new Map(); 405 | const graphs = []; 406 | 407 | for (const id of taskIds) { 408 | const graph = this._buildDependencyGraph(id, visited, depthMap); 409 | if (graph) graphs.push(graph); 410 | } 411 | 412 | return { allRelatedTaskIds: visited, graphs, depthMap }; 413 | } 414 | 415 | _buildDependencyGraph(taskId, visited, depthMap, depth = 0) { 416 | if (visited.has(taskId) || depth > 5) return null; // Limit recursion depth 417 | const task = this.allTasks.find((t) => t.id === taskId); 418 | if (!task) return null; 419 | 420 | visited.add(taskId); 421 | if (!depthMap.has(taskId) || depth < depthMap.get(taskId)) { 422 | depthMap.set(taskId, depth); 423 | } 424 | 425 | const dependencies = 426 | task.dependencies 427 | ?.map((depId) => 428 | this._buildDependencyGraph(depId, visited, depthMap, depth + 1) 429 | ) 430 | .filter(Boolean) || []; 431 | 432 | return { ...task, dependencies }; 433 | } 434 | 435 | _formatDependencyChain(node, prefix = '', isLast = true, depth = 0) { 436 | if (depth > 3) return ''; 437 | const connector = isLast ? '└── ' : '├── '; 438 | let result = `${prefix}${connector}Task ${node.id}: ${node.title}`; 439 | if (node.dependencies?.length) { 440 | const childPrefix = prefix + (isLast ? ' ' : '│ '); 441 | result += node.dependencies 442 | .map((dep, index) => 443 | this._formatDependencyChain( 444 | dep, 445 | childPrefix, 446 | index === node.dependencies.length - 1, 447 | depth + 1 448 | ) 449 | ) 450 | .join(''); 451 | } 452 | return '\n' + result; 453 | } 454 | 455 | /** 456 | * Parse task ID strings into structured format 457 | * Supports formats: "15", "15.2", "16,17.1" 458 | * @param {Array<string>} taskIds - Array of task ID strings 459 | * @returns {Array<Object>} Parsed task identifiers 460 | */ 461 | _parseTaskIds(taskIds) { 462 | const parsed = []; 463 | 464 | for (const idStr of taskIds) { 465 | if (idStr.includes('.')) { 466 | // Subtask format: "15.2" 467 | const [parentId, subtaskId] = idStr.split('.'); 468 | parsed.push({ 469 | type: 'subtask', 470 | parentId: parseInt(parentId, 10), 471 | subtaskId: parseInt(subtaskId, 10), 472 | fullId: idStr 473 | }); 474 | } else { 475 | // Task format: "15" 476 | parsed.push({ 477 | type: 'task', 478 | taskId: parseInt(idStr, 10), 479 | fullId: idStr 480 | }); 481 | } 482 | } 483 | 484 | return parsed; 485 | } 486 | 487 | /** 488 | * Gather context from tasks and subtasks 489 | * @param {Array<string>} taskIds - Task/subtask IDs 490 | * @param {string} format - Output format 491 | * @param {boolean} includeTokenCounts - Whether to include token breakdown 492 | * @returns {Promise<Object>} Task context result with breakdown 493 | */ 494 | async _gatherTaskContext(taskIds, format, includeTokenCounts = false) { 495 | try { 496 | if (!this.allTasks || this.allTasks.length === 0) { 497 | return { context: null, breakdown: [] }; 498 | } 499 | 500 | const parsedIds = this._parseTaskIds(taskIds); 501 | const contextItems = []; 502 | const breakdown = []; 503 | 504 | for (const parsed of parsedIds) { 505 | let formattedItem = null; 506 | let itemInfo = null; 507 | 508 | if (parsed.type === 'task') { 509 | const result = findTaskById(this.allTasks, parsed.taskId); 510 | if (result.task) { 511 | formattedItem = this._formatTaskForContext(result.task, format); 512 | itemInfo = { 513 | id: parsed.fullId, 514 | type: 'task', 515 | title: result.task.title, 516 | tokens: includeTokenCounts ? this.countTokens(formattedItem) : 0, 517 | characters: formattedItem.length 518 | }; 519 | } 520 | } else if (parsed.type === 'subtask') { 521 | const parentResult = findTaskById(this.allTasks, parsed.parentId); 522 | if (parentResult.task && parentResult.task.subtasks) { 523 | const subtask = parentResult.task.subtasks.find( 524 | (st) => st.id === parsed.subtaskId 525 | ); 526 | if (subtask) { 527 | formattedItem = this._formatSubtaskForContext( 528 | subtask, 529 | parentResult.task, 530 | format 531 | ); 532 | itemInfo = { 533 | id: parsed.fullId, 534 | type: 'subtask', 535 | title: subtask.title, 536 | parentTitle: parentResult.task.title, 537 | tokens: includeTokenCounts 538 | ? this.countTokens(formattedItem) 539 | : 0, 540 | characters: formattedItem.length 541 | }; 542 | } 543 | } 544 | } 545 | 546 | if (formattedItem && itemInfo) { 547 | contextItems.push(formattedItem); 548 | if (includeTokenCounts) { 549 | breakdown.push(itemInfo); 550 | } 551 | } 552 | } 553 | 554 | if (contextItems.length === 0) { 555 | return { context: null, breakdown: [] }; 556 | } 557 | 558 | const finalContext = this._formatTaskContextSection(contextItems, format); 559 | return { 560 | context: finalContext, 561 | breakdown: includeTokenCounts ? breakdown : [] 562 | }; 563 | } catch (error) { 564 | console.warn(`Warning: Could not gather task context: ${error.message}`); 565 | return { context: null, breakdown: [] }; 566 | } 567 | } 568 | 569 | /** 570 | * Format a task for context inclusion 571 | * @param {Object} task - Task object 572 | * @param {string} format - Output format 573 | * @returns {string} Formatted task context 574 | */ 575 | _formatTaskForContext(task, format) { 576 | const sections = []; 577 | 578 | sections.push(`**Task ${task.id}: ${task.title}**`); 579 | sections.push(`Description: ${task.description}`); 580 | sections.push(`Status: ${task.status || 'pending'}`); 581 | sections.push(`Priority: ${task.priority || 'medium'}`); 582 | 583 | if (task.dependencies && task.dependencies.length > 0) { 584 | sections.push(`Dependencies: ${task.dependencies.join(', ')}`); 585 | } 586 | 587 | if (task.details) { 588 | const details = truncate(task.details, 500); 589 | sections.push(`Implementation Details: ${details}`); 590 | } 591 | 592 | if (task.testStrategy) { 593 | const testStrategy = truncate(task.testStrategy, 300); 594 | sections.push(`Test Strategy: ${testStrategy}`); 595 | } 596 | 597 | if (task.subtasks && task.subtasks.length > 0) { 598 | sections.push(`Subtasks: ${task.subtasks.length} subtasks defined`); 599 | } 600 | 601 | return sections.join('\n'); 602 | } 603 | 604 | /** 605 | * Format a subtask for context inclusion 606 | * @param {Object} subtask - Subtask object 607 | * @param {Object} parentTask - Parent task object 608 | * @param {string} format - Output format 609 | * @returns {string} Formatted subtask context 610 | */ 611 | _formatSubtaskForContext(subtask, parentTask, format) { 612 | const sections = []; 613 | 614 | sections.push( 615 | `**Subtask ${parentTask.id}.${subtask.id}: ${subtask.title}**` 616 | ); 617 | sections.push(`Parent Task: ${parentTask.title}`); 618 | sections.push(`Description: ${subtask.description}`); 619 | sections.push(`Status: ${subtask.status || 'pending'}`); 620 | 621 | if (subtask.dependencies && subtask.dependencies.length > 0) { 622 | sections.push(`Dependencies: ${subtask.dependencies.join(', ')}`); 623 | } 624 | 625 | if (subtask.details) { 626 | const details = truncate(subtask.details, 500); 627 | sections.push(`Implementation Details: ${details}`); 628 | } 629 | 630 | return sections.join('\n'); 631 | } 632 | 633 | /** 634 | * Gather context from files 635 | * @param {Array<string>} filePaths - File paths to read 636 | * @param {string} format - Output format 637 | * @param {boolean} includeTokenCounts - Whether to include token breakdown 638 | * @returns {Promise<Object>} File context result with breakdown 639 | */ 640 | async _gatherFileContext(filePaths, format, includeTokenCounts = false) { 641 | const fileContents = []; 642 | const breakdown = []; 643 | 644 | for (const filePath of filePaths) { 645 | try { 646 | const fullPath = path.isAbsolute(filePath) 647 | ? filePath 648 | : path.join(this.projectRoot, filePath); 649 | 650 | if (!fs.existsSync(fullPath)) { 651 | continue; 652 | } 653 | 654 | const stats = fs.statSync(fullPath); 655 | if (!stats.isFile()) { 656 | continue; 657 | } 658 | 659 | // Check file size (limit to 50KB for context) 660 | if (stats.size > 50 * 1024) { 661 | continue; 662 | } 663 | 664 | const content = fs.readFileSync(fullPath, 'utf-8'); 665 | const relativePath = path.relative(this.projectRoot, fullPath); 666 | 667 | const fileData = { 668 | path: relativePath, 669 | size: stats.size, 670 | content: content, 671 | lastModified: stats.mtime 672 | }; 673 | 674 | fileContents.push(fileData); 675 | 676 | // Calculate tokens for this individual file if requested 677 | if (includeTokenCounts) { 678 | const formattedFile = this._formatSingleFileForContext( 679 | fileData, 680 | format 681 | ); 682 | breakdown.push({ 683 | path: relativePath, 684 | sizeKB: Math.round(stats.size / 1024), 685 | tokens: this.countTokens(formattedFile), 686 | characters: formattedFile.length 687 | }); 688 | } 689 | } catch (error) { 690 | console.warn( 691 | `Warning: Could not read file ${filePath}: ${error.message}` 692 | ); 693 | } 694 | } 695 | 696 | if (fileContents.length === 0) { 697 | return { context: null, breakdown: [] }; 698 | } 699 | 700 | const finalContext = this._formatFileContextSection(fileContents, format); 701 | return { 702 | context: finalContext, 703 | breakdown: includeTokenCounts ? breakdown : [] 704 | }; 705 | } 706 | 707 | /** 708 | * Generate project file tree context 709 | * @param {string} format - Output format 710 | * @param {boolean} includeTokenCounts - Whether to include token breakdown 711 | * @returns {Promise<Object>} Project tree context result with breakdown 712 | */ 713 | async _gatherProjectTreeContext(format, includeTokenCounts = false) { 714 | try { 715 | const tree = this._generateFileTree(this.projectRoot, 5); // Max depth 5 716 | const finalContext = this._formatProjectTreeSection(tree, format); 717 | 718 | const breakdown = includeTokenCounts 719 | ? { 720 | tokens: this.countTokens(finalContext), 721 | characters: finalContext.length, 722 | fileCount: tree.fileCount || 0, 723 | dirCount: tree.dirCount || 0 724 | } 725 | : null; 726 | 727 | return { 728 | context: finalContext, 729 | breakdown: breakdown 730 | }; 731 | } catch (error) { 732 | console.warn( 733 | `Warning: Could not generate project tree: ${error.message}` 734 | ); 735 | return { context: null, breakdown: null }; 736 | } 737 | } 738 | 739 | /** 740 | * Format a single file for context (used for token counting) 741 | * @param {Object} fileData - File data object 742 | * @param {string} format - Output format 743 | * @returns {string} Formatted file context 744 | */ 745 | _formatSingleFileForContext(fileData, format) { 746 | const header = `**File: ${fileData.path}** (${Math.round(fileData.size / 1024)}KB)`; 747 | const content = `\`\`\`\n${fileData.content}\n\`\`\``; 748 | return `${header}\n\n${content}`; 749 | } 750 | 751 | /** 752 | * Generate file tree structure 753 | * @param {string} dirPath - Directory path 754 | * @param {number} maxDepth - Maximum depth to traverse 755 | * @param {number} currentDepth - Current depth 756 | * @returns {Object} File tree structure 757 | */ 758 | _generateFileTree(dirPath, maxDepth, currentDepth = 0) { 759 | const ignoreDirs = [ 760 | '.git', 761 | 'node_modules', 762 | '.env', 763 | 'coverage', 764 | 'dist', 765 | 'build' 766 | ]; 767 | const ignoreFiles = ['.DS_Store', '.env', '.env.local', '.env.production']; 768 | 769 | if (currentDepth >= maxDepth) { 770 | return null; 771 | } 772 | 773 | try { 774 | const items = fs.readdirSync(dirPath); 775 | const tree = { 776 | name: path.basename(dirPath), 777 | type: 'directory', 778 | children: [], 779 | fileCount: 0, 780 | dirCount: 0 781 | }; 782 | 783 | for (const item of items) { 784 | if (ignoreDirs.includes(item) || ignoreFiles.includes(item)) { 785 | continue; 786 | } 787 | 788 | const itemPath = path.join(dirPath, item); 789 | const stats = fs.statSync(itemPath); 790 | 791 | if (stats.isDirectory()) { 792 | tree.dirCount++; 793 | if (currentDepth < maxDepth - 1) { 794 | const subtree = this._generateFileTree( 795 | itemPath, 796 | maxDepth, 797 | currentDepth + 1 798 | ); 799 | if (subtree) { 800 | tree.children.push(subtree); 801 | } 802 | } 803 | } else { 804 | tree.fileCount++; 805 | tree.children.push({ 806 | name: item, 807 | type: 'file', 808 | size: stats.size 809 | }); 810 | } 811 | } 812 | 813 | return tree; 814 | } catch (error) { 815 | return null; 816 | } 817 | } 818 | 819 | /** 820 | * Format custom context section 821 | * @param {string} customContext - Custom context string 822 | * @param {string} format - Output format 823 | * @returns {string} Formatted custom context 824 | */ 825 | _formatCustomContext(customContext, format) { 826 | switch (format) { 827 | case 'research': 828 | return `## Additional Context\n\n${customContext}`; 829 | case 'chat': 830 | return `**Additional Context:**\n${customContext}`; 831 | case 'system-prompt': 832 | return `Additional context: ${customContext}`; 833 | default: 834 | return customContext; 835 | } 836 | } 837 | 838 | /** 839 | * Format task context section 840 | * @param {Array<string>} taskItems - Formatted task items 841 | * @param {string} format - Output format 842 | * @returns {string} Formatted task context section 843 | */ 844 | _formatTaskContextSection(taskItems, format) { 845 | switch (format) { 846 | case 'research': 847 | return `## Task Context\n\n${taskItems.join('\n\n---\n\n')}`; 848 | case 'chat': 849 | return `**Task Context:**\n\n${taskItems.join('\n\n')}`; 850 | case 'system-prompt': 851 | return `Task context: ${taskItems.join(' | ')}`; 852 | default: 853 | return taskItems.join('\n\n'); 854 | } 855 | } 856 | 857 | /** 858 | * Format file context section 859 | * @param {Array<Object>} fileContents - File content objects 860 | * @param {string} format - Output format 861 | * @returns {string} Formatted file context section 862 | */ 863 | _formatFileContextSection(fileContents, format) { 864 | const fileItems = fileContents.map((file) => { 865 | const header = `**File: ${file.path}** (${Math.round(file.size / 1024)}KB)`; 866 | const content = `\`\`\`\n${file.content}\n\`\`\``; 867 | return `${header}\n\n${content}`; 868 | }); 869 | 870 | switch (format) { 871 | case 'research': 872 | return `## File Context\n\n${fileItems.join('\n\n---\n\n')}`; 873 | case 'chat': 874 | return `**File Context:**\n\n${fileItems.join('\n\n')}`; 875 | case 'system-prompt': 876 | return `File context: ${fileContents.map((f) => `${f.path} (${f.content.substring(0, 200)}...)`).join(' | ')}`; 877 | default: 878 | return fileItems.join('\n\n'); 879 | } 880 | } 881 | 882 | /** 883 | * Format project tree section 884 | * @param {Object} tree - File tree structure 885 | * @param {string} format - Output format 886 | * @returns {string} Formatted project tree section 887 | */ 888 | _formatProjectTreeSection(tree, format) { 889 | const treeString = this._renderFileTree(tree); 890 | 891 | switch (format) { 892 | case 'research': 893 | return `## Project Structure\n\n\`\`\`\n${treeString}\n\`\`\``; 894 | case 'chat': 895 | return `**Project Structure:**\n\`\`\`\n${treeString}\n\`\`\``; 896 | case 'system-prompt': 897 | return `Project structure: ${treeString.replace(/\n/g, ' | ')}`; 898 | default: 899 | return treeString; 900 | } 901 | } 902 | 903 | /** 904 | * Render file tree as string 905 | * @param {Object} tree - File tree structure 906 | * @param {string} prefix - Current prefix for indentation 907 | * @returns {string} Rendered tree string 908 | */ 909 | _renderFileTree(tree, prefix = '') { 910 | let result = `${prefix}${tree.name}/`; 911 | 912 | if (tree.fileCount > 0 || tree.dirCount > 0) { 913 | result += ` (${tree.fileCount} files, ${tree.dirCount} dirs)`; 914 | } 915 | 916 | result += '\n'; 917 | 918 | if (tree.children) { 919 | tree.children.forEach((child, index) => { 920 | const isLast = index === tree.children.length - 1; 921 | const childPrefix = prefix + (isLast ? '└── ' : '├── '); 922 | const nextPrefix = prefix + (isLast ? ' ' : '│ '); 923 | 924 | if (child.type === 'directory') { 925 | result += this._renderFileTree(child, childPrefix); 926 | } else { 927 | result += `${childPrefix}${child.name}\n`; 928 | } 929 | }); 930 | } 931 | 932 | return result; 933 | } 934 | 935 | /** 936 | * Join context sections based on format 937 | * @param {Array<string>} sections - Context sections 938 | * @param {string} format - Output format 939 | * @returns {string} Joined context string 940 | */ 941 | _joinContextSections(sections, format) { 942 | if (sections.length === 0) { 943 | return ''; 944 | } 945 | 946 | switch (format) { 947 | case 'research': 948 | return sections.join('\n\n---\n\n'); 949 | case 'chat': 950 | return sections.join('\n\n'); 951 | case 'system-prompt': 952 | return sections.join(' '); 953 | default: 954 | return sections.join('\n\n'); 955 | } 956 | } 957 | } 958 | 959 | /** 960 | * Factory function to create a context gatherer instance 961 | * @param {string} projectRoot - Project root directory 962 | * @param {string} tag - Tag for the task 963 | * @returns {ContextGatherer} Context gatherer instance 964 | * @throws {Error} If tag is not provided 965 | */ 966 | export function createContextGatherer(projectRoot, tag) { 967 | if (!tag) { 968 | throw new Error('Tag is required'); 969 | } 970 | return new ContextGatherer(projectRoot, tag); 971 | } 972 | 973 | export default ContextGatherer; 974 | ```