diff --git a/mcp-server/src/core/direct-functions/update-subtask-by-id.js b/mcp-server/src/core/direct-functions/update-subtask-by-id.js index fd9faa99..5de1e48b 100644 --- a/mcp-server/src/core/direct-functions/update-subtask-by-id.js +++ b/mcp-server/src/core/direct-functions/update-subtask-by-id.js @@ -103,7 +103,7 @@ export async function updateSubtaskByIdDirect(args, log, context = {}) { } try { - // Execute core updateSubtaskById function + // Call legacy script which handles both API and file storage via bridge const coreResult = await updateSubtaskById( tasksPath, subtaskIdStr, @@ -129,7 +129,7 @@ export async function updateSubtaskByIdDirect(args, log, context = {}) { }; } - // Subtask updated successfully + const parentId = subtaskIdStr.split('.')[0]; const successMessage = `Successfully updated subtask with ID ${subtaskIdStr}`; logWrapper.success(successMessage); return { @@ -137,7 +137,7 @@ export async function updateSubtaskByIdDirect(args, log, context = {}) { data: { message: `Successfully updated subtask with ID ${subtaskIdStr}`, subtaskId: subtaskIdStr, - parentId: subtaskIdStr.split('.')[0], + parentId: parentId, subtask: coreResult.updatedSubtask, tasksPath, useResearch, diff --git a/mcp-server/src/core/direct-functions/update-task-by-id.js b/mcp-server/src/core/direct-functions/update-task-by-id.js index b7b5570d..fd50717d 100644 --- a/mcp-server/src/core/direct-functions/update-task-by-id.js +++ b/mcp-server/src/core/direct-functions/update-task-by-id.js @@ -10,6 +10,7 @@ import { isSilentMode } from '../../../../scripts/modules/utils.js'; import { createLogWrapper } from '../../tools/utils.js'; +import { findTasksPath } from '../utils/path-utils.js'; /** * Direct function wrapper for updateTaskById with error handling. @@ -39,16 +40,6 @@ export async function updateTaskByIdDirect(args, log, context = {}) { `Updating task by ID via direct function. ID: ${id}, ProjectRoot: ${projectRoot}` ); - // Check if tasksJsonPath was provided - if (!tasksJsonPath) { - const errorMessage = 'tasksJsonPath is required but was not provided.'; - logWrapper.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_ARGUMENT', message: errorMessage } - }; - } - // Check required parameters (id and prompt) if (!id) { const errorMessage = @@ -56,7 +47,7 @@ export async function updateTaskByIdDirect(args, log, context = {}) { logWrapper.error(errorMessage); return { success: false, - error: { code: 'MISSING_TASK_ID', message: errorMessage } + error: { code: 'INPUT_VALIDATION_ERROR', message: errorMessage } }; } @@ -66,34 +57,39 @@ export async function updateTaskByIdDirect(args, log, context = {}) { logWrapper.error(errorMessage); return { success: false, - error: { code: 'MISSING_PROMPT', message: errorMessage } + error: { code: 'INPUT_VALIDATION_ERROR', message: errorMessage } }; } - // Parse taskId - handle both string and number values + // Parse taskId - handle numeric, alphanumeric, and subtask IDs let taskId; if (typeof id === 'string') { - // Handle subtask IDs (e.g., "5.2") - if (id.includes('.')) { - taskId = id; // Keep as string for subtask IDs - } else { - // Parse as integer for main task IDs - taskId = parseInt(id, 10); - if (Number.isNaN(taskId)) { - const errorMessage = `Invalid task ID: ${id}. Task ID must be a positive integer or subtask ID (e.g., "5.2").`; - logWrapper.error(errorMessage); - return { - success: false, - error: { code: 'INVALID_TASK_ID', message: errorMessage } - }; - } - } - } else { + // Keep ID as string - supports numeric (1, 2), alphanumeric (TAS-49, JIRA-123), and subtask IDs (1.2, TAS-49.1) taskId = id; + } else if (typeof id === 'number') { + // Convert number to string for consistency + taskId = String(id); + } else { + const errorMessage = `Invalid task ID type: ${typeof id}. Task ID must be a string or number.`; + logWrapper.error(errorMessage); + return { + success: false, + error: { code: 'INPUT_VALIDATION_ERROR', message: errorMessage } + }; } - // Use the provided path - const tasksPath = tasksJsonPath; + // Resolve tasks.json path - use provided or find it + const tasksPath = + tasksJsonPath || + findTasksPath({ projectRoot, file: args.file }, logWrapper); + if (!tasksPath) { + const errorMessage = 'tasks.json path could not be resolved.'; + logWrapper.error(errorMessage); + return { + success: false, + error: { code: 'INPUT_VALIDATION_ERROR', message: errorMessage } + }; + } // Get research flag const useResearch = research === true; @@ -108,7 +104,7 @@ export async function updateTaskByIdDirect(args, log, context = {}) { } try { - // Execute core updateTaskById function with proper parameters + // Call legacy script which handles both API and file storage via bridge const coreResult = await updateTaskById( tasksPath, taskId, @@ -128,7 +124,6 @@ export async function updateTaskByIdDirect(args, log, context = {}) { // Check if the core function returned null or an object without success if (!coreResult || coreResult.updatedTask === null) { - // Core function logs the reason, just return success with info const message = `Task ${taskId} was not updated (likely already completed).`; logWrapper.info(message); return { @@ -143,9 +138,8 @@ export async function updateTaskByIdDirect(args, log, context = {}) { }; } - // Task was updated successfully const successMessage = `Successfully updated task with ID ${taskId} based on the prompt`; - logWrapper.success(successMessage); + logWrapper.info(successMessage); return { success: true, data: { diff --git a/package-lock.json b/package-lock.json index 0c80b99e..428d46ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9360,6 +9360,10 @@ "resolved": "packages/ai-sdk-provider-grok-cli", "link": true }, + "node_modules/@tm/bridge": { + "resolved": "packages/tm-bridge", + "link": true + }, "node_modules/@tm/build-config": { "resolved": "packages/build-config", "link": true @@ -28214,6 +28218,283 @@ "version": "0.0.2", "license": "MIT WITH Commons-Clause" }, + "packages/tm-bridge": { + "name": "@tm/bridge", + "version": "0.0.0", + "dependencies": { + "@tm/core": "*", + "boxen": "^8.0.1", + "chalk": "5.6.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } + }, + "packages/tm-bridge/node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/tm-bridge/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/tm-bridge/node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/tm-bridge/node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/tm-bridge/node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/tm-bridge/node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/tm-bridge/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "packages/tm-bridge/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "packages/tm-bridge/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "packages/tm-bridge/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/tm-bridge/node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/tm-bridge/node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/tm-bridge/node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/tm-bridge/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "packages/tm-core": { "name": "@tm/core", "dependencies": { diff --git a/packages/tm-bridge/README.md b/packages/tm-bridge/README.md new file mode 100644 index 00000000..ae39a1d6 --- /dev/null +++ b/packages/tm-bridge/README.md @@ -0,0 +1,53 @@ +# @tm/bridge + +> ⚠️ **TEMPORARY PACKAGE - DELETE WHEN LEGACY CODE IS REMOVED** ⚠️ + +This package exists solely as a bridge between legacy scripts (`scripts/modules/task-manager/`) and the new tm-core architecture. It provides shared functionality that both CLI and MCP direct functions can use during the migration period. + +## Why does this exist? + +During the transition from legacy scripts to tm-core, we need a single source of truth for bridge logic that handles: +- API vs file storage detection +- Remote AI service delegation +- Consistent behavior across CLI and MCP interfaces + +## When to delete this + +Delete this entire package when: +1. ✅ Legacy scripts in `scripts/modules/task-manager/` are removed +2. ✅ MCP direct functions in `mcp-server/src/core/direct-functions/` are removed +3. ✅ All functionality has moved to tm-core +4. ✅ CLI and MCP use tm-core directly via TasksDomain + +## Migration path + +```text +Current: CLI → legacy scripts → @tm/bridge → @tm/core + MCP → direct functions → legacy scripts → @tm/bridge → @tm/core + +Future: CLI → @tm/core (TasksDomain) + MCP → @tm/core (TasksDomain) + +DELETE: legacy scripts, direct functions, @tm/bridge +``` + +## Usage + +```typescript +import { tryUpdateViaRemote } from '@tm/bridge'; + +const result = await tryUpdateViaRemote({ + taskId: '1.2', + prompt: 'Update task...', + projectRoot: '/path/to/project', + // ... other params +}); +``` + +## Contents + +- `update-bridge.ts` - Shared update bridge logic for task/subtask updates + +--- + +**Remember:** This package should NOT accumulate new features. It's a temporary migration aid only. diff --git a/packages/tm-bridge/package.json b/packages/tm-bridge/package.json new file mode 100644 index 00000000..b646a1b5 --- /dev/null +++ b/packages/tm-bridge/package.json @@ -0,0 +1,31 @@ +{ + "name": "@tm/bridge", + "private": true, + "description": "TEMPORARY: Bridge layer for legacy code migration. DELETE when legacy scripts are removed.", + "type": "module", + "types": "./src/index.ts", + "main": "./dist/index.js", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check --write", + "lint:check": "biome check", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tm/core": "*", + "chalk": "5.6.2", + "boxen": "^8.0.1" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + }, + "files": ["src", "README.md"], + "keywords": ["temporary", "bridge", "migration"], + "author": "Task Master AI" +} diff --git a/packages/tm-bridge/src/index.ts b/packages/tm-bridge/src/index.ts new file mode 100644 index 00000000..eab1c438 --- /dev/null +++ b/packages/tm-bridge/src/index.ts @@ -0,0 +1,16 @@ +/** + * @tm/bridge - Temporary bridge package for legacy code migration + * + * ⚠️ THIS PACKAGE IS TEMPORARY AND WILL BE DELETED ⚠️ + * + * This package exists solely to provide shared bridge logic between + * legacy scripts and the new tm-core architecture during migration. + * + * DELETE THIS PACKAGE when legacy scripts are removed. + */ + +export { + tryUpdateViaRemote, + type UpdateBridgeParams, + type RemoteUpdateResult +} from './update-bridge.js'; diff --git a/packages/tm-bridge/src/update-bridge.ts b/packages/tm-bridge/src/update-bridge.ts new file mode 100644 index 00000000..05662ba9 --- /dev/null +++ b/packages/tm-bridge/src/update-bridge.ts @@ -0,0 +1,183 @@ +import chalk from 'chalk'; +import boxen from 'boxen'; +import { createTmCore, type TmCore } from '@tm/core'; + +/** + * Parameters for the update bridge function + */ +export interface UpdateBridgeParams { + /** Task ID (can be numeric "1", alphanumeric "TAS-49", or dotted "1.2" or "TAS-49.1") */ + taskId: string | number; + /** Update prompt for AI */ + prompt: string; + /** Project root directory */ + projectRoot: string; + /** Optional tag for task organization */ + tag?: string; + /** Whether to append or full update (default: false) */ + appendMode?: boolean; + /** Whether called from MCP context (default: false) */ + isMCP?: boolean; + /** Output format (default: 'text') */ + outputFormat?: 'text' | 'json'; + /** Logging function */ + report: (level: string, ...args: unknown[]) => void; +} + +/** + * Result returned when API storage handles the update + */ +export interface RemoteUpdateResult { + success: boolean; + taskId: string | number; + message: string; + telemetryData: null; + tagInfo: null; +} + +/** + * Shared bridge function for update-task and update-subtask commands. + * Checks if using API storage and delegates to remote AI service if so. + * + * In API storage, tasks and subtasks are treated identically - there's no + * parent/child hierarchy, so update-task and update-subtask can be used + * interchangeably. + * + * @param params - Bridge parameters + * @returns Result object if API storage handled it, null if should fall through to file storage + */ +export async function tryUpdateViaRemote( + params: UpdateBridgeParams +): Promise { + const { + taskId, + prompt, + projectRoot, + tag, + appendMode = false, + isMCP = false, + outputFormat = 'text', + report + } = params; + + let tmCore: TmCore; + + try { + tmCore = await createTmCore({ + projectPath: projectRoot || process.cwd() + }); + } catch (tmCoreError) { + const errorMessage = + tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError); + report( + 'warn', + `TmCore check failed, falling back to file-based update: ${errorMessage}` + ); + // Return null to signal fall-through to file storage logic + return null; + } + + // Check if we're using API storage (use resolved storage type, not config) + const storageType = tmCore.tasks.getStorageType(); + + if (storageType !== 'api') { + // Not API storage - signal caller to fall through to file-based logic + report( + 'info', + `Using file storage - processing update locally for task ${taskId}` + ); + return null; + } + + // API STORAGE PATH: Delegate to remote AI service + report('info', `Delegating update to Hamster for task ${taskId}`); + + const mode = appendMode ? 'append' : 'update'; + + // Show CLI output if not MCP + if (!isMCP && outputFormat === 'text') { + const showDebug = process.env.TM_DEBUG === '1'; + const promptPreview = showDebug + ? `${prompt.substring(0, 60)}${prompt.length > 60 ? '...' : ''}` + : '[hidden]'; + + console.log( + boxen( + chalk.blue.bold(`Updating Task via Hamster`) + + '\n\n' + + chalk.white(`Task ID: ${taskId}`) + + '\n' + + chalk.white(`Mode: ${mode}`) + + '\n' + + chalk.white(`Prompt: ${promptPreview}`), + { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + } + ) + ); + } + + let loadingIndicator: NodeJS.Timeout | null = null; + if (!isMCP && outputFormat === 'text') { + // Simple loading indicator simulation (replace with actual startLoadingIndicator if available) + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let frameIndex = 0; + loadingIndicator = setInterval(() => { + process.stdout.write( + `\r${frames[frameIndex]} Updating task on Hamster...` + ); + frameIndex = (frameIndex + 1) % frames.length; + }, 80); + } + + try { + // Call the API storage method which handles the remote update + await tmCore.tasks.updateWithPrompt(String(taskId), prompt, tag, { + mode + }); + + if (loadingIndicator) { + clearInterval(loadingIndicator); + process.stdout.write('\r✓ Task updated successfully.\n'); + } + + if (outputFormat === 'text') { + console.log( + boxen( + chalk.green(`Successfully updated task ${taskId} via remote AI`) + + '\n\n' + + chalk.white('The task has been updated on the remote server.') + + '\n' + + chalk.white( + `Run ${chalk.yellow(`task-master show ${taskId}`)} to view the updated task.` + ), + { + padding: 1, + borderColor: 'green', + borderStyle: 'round' + } + ) + ); + } + + // Return success result - signals that we handled it + return { + success: true, + taskId: taskId, + message: 'Task updated via remote AI service', + telemetryData: null, + tagInfo: null + }; + } catch (updateError) { + if (loadingIndicator) { + clearInterval(loadingIndicator); + process.stdout.write('\r✗ Update failed.\n'); + } + + // tm-core already formatted the error properly, just re-throw + throw updateError; + } +} diff --git a/packages/tm-bridge/tsconfig.json b/packages/tm-bridge/tsconfig.json new file mode 100644 index 00000000..7cd96a0b --- /dev/null +++ b/packages/tm-bridge/tsconfig.json @@ -0,0 +1,37 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "baseUrl": ".", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "NodeNext", + "moduleDetection": "force", + "types": ["node"], + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/tm-core/src/common/interfaces/storage.interface.ts b/packages/tm-core/src/common/interfaces/storage.interface.ts index 7ddd2bec..e2d18d11 100644 --- a/packages/tm-core/src/common/interfaces/storage.interface.ts +++ b/packages/tm-core/src/common/interfaces/storage.interface.ts @@ -63,7 +63,7 @@ export interface IStorage { appendTasks(tasks: Task[], tag?: string): Promise; /** - * Update a specific task by ID + * Update a specific task by ID (direct structural update) * @param taskId - ID of the task to update * @param updates - Partial task object with fields to update * @param tag - Optional tag context for the task @@ -75,6 +75,23 @@ export interface IStorage { tag?: string ): Promise; + /** + * Update a task using AI-powered prompt (natural language update) + * @param taskId - ID of the task to update + * @param prompt - Natural language prompt describing the update + * @param tag - Optional tag context for the task + * @param options - Optional update options + * @param options.useResearch - Whether to use research capabilities (for file storage) + * @param options.mode - Update mode: 'append' adds info, 'update' makes targeted changes, 'rewrite' restructures (for API storage) + * @returns Promise that resolves when update is complete + */ + updateTaskWithPrompt( + taskId: string, + prompt: string, + tag?: string, + options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' } + ): Promise; + /** * Update task or subtask status by ID * @param taskId - ID of the task or subtask (e.g., "1" or "1.2") @@ -231,6 +248,12 @@ export abstract class BaseStorage implements IStorage { updates: Partial, tag?: string ): Promise; + abstract updateTaskWithPrompt( + taskId: string, + prompt: string, + tag?: string, + options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' } + ): Promise; abstract updateTaskStatus( taskId: string, newStatus: TaskStatus, diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.ts index 8aeb753f..883105df 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.ts @@ -28,7 +28,7 @@ export class AuthManager { private static readonly staticLogger = getLogger('AuthManager'); private credentialStore: CredentialStore; private oauthService: OAuthService; - private supabaseClient: SupabaseAuthClient; + public supabaseClient: SupabaseAuthClient; private organizationService?: OrganizationService; private readonly logger = getLogger('AuthManager'); diff --git a/packages/tm-core/src/modules/config/services/config-loader.service.spec.ts b/packages/tm-core/src/modules/config/services/config-loader.service.spec.ts index ae88bb98..bcc4d11c 100644 --- a/packages/tm-core/src/modules/config/services/config-loader.service.spec.ts +++ b/packages/tm-core/src/modules/config/services/config-loader.service.spec.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import { ConfigLoader } from './config-loader.service.js'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; diff --git a/packages/tm-core/src/modules/config/services/config-loader.service.ts b/packages/tm-core/src/modules/config/services/config-loader.service.ts index 2e35b521..e32c0f23 100644 --- a/packages/tm-core/src/modules/config/services/config-loader.service.ts +++ b/packages/tm-core/src/modules/config/services/config-loader.service.ts @@ -3,7 +3,7 @@ * Responsible for loading configuration from various file sources */ -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import path from 'node:path'; import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; diff --git a/packages/tm-core/src/modules/config/services/config-persistence.service.spec.ts b/packages/tm-core/src/modules/config/services/config-persistence.service.spec.ts index dc705038..1910e72c 100644 --- a/packages/tm-core/src/modules/config/services/config-persistence.service.spec.ts +++ b/packages/tm-core/src/modules/config/services/config-persistence.service.spec.ts @@ -3,8 +3,9 @@ */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import { ConfigPersistence } from './config-persistence.service.js'; +import type { PartialConfiguration } from '@tm/core/common/interfaces/configuration.interface.js'; vi.mock('node:fs', () => ({ promises: { @@ -32,9 +33,16 @@ describe('ConfigPersistence', () => { }); describe('saveConfig', () => { - const mockConfig = { - models: { main: 'test-model' }, - storage: { type: 'file' as const } + const mockConfig: PartialConfiguration = { + models: { main: 'test-model', fallback: 'test-fallback' }, + storage: { + type: 'file' as const, + enableBackup: true, + maxBackups: 5, + enableCompression: true, + encoding: 'utf-8', + atomicOperations: true + } }; it('should save configuration to file', async () => { diff --git a/packages/tm-core/src/modules/config/services/config-persistence.service.ts b/packages/tm-core/src/modules/config/services/config-persistence.service.ts index 06b9f1a2..5db86f58 100644 --- a/packages/tm-core/src/modules/config/services/config-persistence.service.ts +++ b/packages/tm-core/src/modules/config/services/config-persistence.service.ts @@ -3,7 +3,7 @@ * Handles saving and backup of configuration files */ -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import path from 'node:path'; import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js'; import { diff --git a/packages/tm-core/src/modules/config/services/runtime-state-manager.service.spec.ts b/packages/tm-core/src/modules/config/services/runtime-state-manager.service.spec.ts index c6341e31..9d3a17d7 100644 --- a/packages/tm-core/src/modules/config/services/runtime-state-manager.service.spec.ts +++ b/packages/tm-core/src/modules/config/services/runtime-state-manager.service.spec.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import { RuntimeStateManager } from './runtime-state-manager.service.js'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; @@ -48,7 +48,7 @@ describe('RuntimeStateManager', () => { '/test/project/.taskmaster/state.json', 'utf-8' ); - expect(state.activeTag).toBe('feature-branch'); + expect(state.currentTag).toBe('feature-branch'); expect(state.metadata).toEqual({ test: 'data' }); }); @@ -60,7 +60,7 @@ describe('RuntimeStateManager', () => { const state = await stateManager.loadState(); - expect(state.activeTag).toBe('env-tag'); + expect(state.currentTag).toBe('env-tag'); }); it('should use default state when file does not exist', async () => { @@ -70,7 +70,7 @@ describe('RuntimeStateManager', () => { const state = await stateManager.loadState(); - expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); + expect(state.currentTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); }); it('should use environment variable when file does not exist', async () => { @@ -82,7 +82,7 @@ describe('RuntimeStateManager', () => { const state = await stateManager.loadState(); - expect(state.activeTag).toBe('env-tag'); + expect(state.currentTag).toBe('env-tag'); }); it('should handle file read errors gracefully', async () => { @@ -90,7 +90,7 @@ describe('RuntimeStateManager', () => { const state = await stateManager.loadState(); - expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); + expect(state.currentTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); }); it('should handle invalid JSON gracefully', async () => { @@ -101,7 +101,7 @@ describe('RuntimeStateManager', () => { const state = await stateManager.loadState(); - expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); + expect(state.currentTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); @@ -114,7 +114,7 @@ describe('RuntimeStateManager', () => { vi.mocked(fs.writeFile).mockResolvedValue(undefined); // Set a specific state - await stateManager.setActiveTag('test-tag'); + await stateManager.setCurrentTag('test-tag'); // Verify mkdir was called expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.taskmaster', { @@ -160,7 +160,7 @@ describe('RuntimeStateManager', () => { describe('getActiveTag', () => { it('should return current active tag', () => { - const tag = stateManager.getActiveTag(); + const tag = stateManager.getCurrentTag(); expect(tag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); }); @@ -168,9 +168,9 @@ describe('RuntimeStateManager', () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); - await stateManager.setActiveTag('new-tag'); + await stateManager.setCurrentTag('new-tag'); - expect(stateManager.getActiveTag()).toBe('new-tag'); + expect(stateManager.getCurrentTag()).toBe('new-tag'); }); }); @@ -179,9 +179,9 @@ describe('RuntimeStateManager', () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); - await stateManager.setActiveTag('feature-xyz'); + await stateManager.setCurrentTag('feature-xyz'); - expect(stateManager.getActiveTag()).toBe('feature-xyz'); + expect(stateManager.getCurrentTag()).toBe('feature-xyz'); expect(fs.writeFile).toHaveBeenCalled(); }); }); @@ -193,7 +193,7 @@ describe('RuntimeStateManager', () => { expect(state1).not.toBe(state2); // Different instances expect(state1).toEqual(state2); // Same content - expect(state1.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); + expect(state1.currentTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); }); }); @@ -244,7 +244,7 @@ describe('RuntimeStateManager', () => { expect(fs.unlink).toHaveBeenCalledWith( '/test/project/.taskmaster/state.json' ); - expect(stateManager.getActiveTag()).toBe( + expect(stateManager.getCurrentTag()).toBe( DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG ); expect(stateManager.getState().metadata).toBeUndefined(); @@ -256,7 +256,7 @@ describe('RuntimeStateManager', () => { vi.mocked(fs.unlink).mockRejectedValue(error); await expect(stateManager.clearState()).resolves.not.toThrow(); - expect(stateManager.getActiveTag()).toBe( + expect(stateManager.getCurrentTag()).toBe( DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG ); }); diff --git a/packages/tm-core/src/modules/config/services/runtime-state-manager.service.ts b/packages/tm-core/src/modules/config/services/runtime-state-manager.service.ts index 3c633730..bd9a6a43 100644 --- a/packages/tm-core/src/modules/config/services/runtime-state-manager.service.ts +++ b/packages/tm-core/src/modules/config/services/runtime-state-manager.service.ts @@ -3,7 +3,7 @@ * Manages runtime state separate from configuration */ -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import path from 'node:path'; import { ERROR_CODES, diff --git a/packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts b/packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts index ca1fadd1..02c24ef4 100644 --- a/packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts +++ b/packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts @@ -3,7 +3,7 @@ * Follows the same pattern as ConfigManager and AuthManager */ -import { promises as fs } from 'fs'; +import fs from 'node:fs/promises'; import path from 'path'; import type { ComplexityReport, diff --git a/packages/tm-core/src/modules/storage/adapters/api-storage.ts b/packages/tm-core/src/modules/storage/adapters/api-storage.ts index 5165211d..e80ed67a 100644 --- a/packages/tm-core/src/modules/storage/adapters/api-storage.ts +++ b/packages/tm-core/src/modules/storage/adapters/api-storage.ts @@ -23,6 +23,8 @@ import { TaskRepository } from '../../tasks/repositories/task-repository.interfa import { SupabaseTaskRepository } from '../../tasks/repositories/supabase/index.js'; import { SupabaseClient } from '@supabase/supabase-js'; import { AuthManager } from '../../auth/managers/auth-manager.js'; +import { ApiClient } from '../utils/api-client.js'; +import { getLogger } from '../../../common/logger/factory.js'; /** * API storage configuration @@ -47,6 +49,22 @@ type ContextWithBrief = NonNullable< ReturnType > & { briefId: string }; +/** + * Response from the update task with prompt API endpoint + */ +interface UpdateTaskWithPromptResponse { + success: boolean; + task: { + id: string; + displayId: string | null; + title: string; + description: string | null; + status: string; + priority: string | null; + }; + message: string; +} + /** * ApiStorage implementation using repository pattern * Provides flexibility to swap between different backend implementations @@ -58,6 +76,8 @@ export class ApiStorage implements IStorage { private readonly maxRetries: number; private initialized = false; private tagsCache: Map = new Map(); + private apiClient?: ApiClient; + private readonly logger = getLogger('ApiStorage'); constructor(config: ApiStorageConfig) { this.validateConfig(config); @@ -501,6 +521,76 @@ export class ApiStorage implements IStorage { } } + /** + * Update task with AI-powered prompt + * Sends prompt to backend for server-side AI processing + */ + async updateTaskWithPrompt( + taskId: string, + prompt: string, + tag?: string, + options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' } + ): Promise { + await this.ensureInitialized(); + + const mode = options?.mode ?? 'append'; + + try { + // Use the API client - all auth, error handling, etc. is centralized + const apiClient = this.getApiClient(); + + const result = await apiClient.patch( + `/ai/api/v1/tasks/${taskId}/prompt`, + { prompt, mode } + ); + + if (!result.success) { + // API returned success: false + throw new Error( + result.message || + `Update failed for task ${taskId}. The server did not provide details.` + ); + } + + // Log success with task details + this.logger.info( + `Successfully updated task ${result.task.displayId || result.task.id} using AI prompt (mode: ${mode})` + ); + this.logger.info(` Title: ${result.task.title}`); + this.logger.info(` Status: ${result.task.status}`); + if (result.message) { + this.logger.info(` ${result.message}`); + } + } catch (error) { + // If it's already a TaskMasterError, just add context and re-throw + if (error instanceof TaskMasterError) { + throw error.withContext({ + operation: 'updateTaskWithPrompt', + taskId, + tag, + promptLength: prompt.length, + mode + }); + } + + // For other errors, wrap them + const errorMessage = + error instanceof Error ? error.message : String(error); + throw new TaskMasterError( + errorMessage, + ERROR_CODES.STORAGE_ERROR, + { + operation: 'updateTaskWithPrompt', + taskId, + tag, + promptLength: prompt.length, + mode + }, + error as Error + ); + } + } + /** * Update task or subtask status by ID - for API storage */ @@ -796,6 +886,35 @@ export class ApiStorage implements IStorage { return context as ContextWithBrief; } + /** + * Get or create API client instance with auth + */ + private getApiClient(): ApiClient { + if (!this.apiClient) { + const apiEndpoint = + process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN; + + if (!apiEndpoint) { + throw new TaskMasterError( + 'API endpoint not configured. Please set TM_PUBLIC_BASE_DOMAIN environment variable.', + ERROR_CODES.MISSING_CONFIGURATION, + { operation: 'getApiClient' } + ); + } + + const context = this.ensureBriefSelected('getApiClient'); + const authManager = AuthManager.getInstance(); + + this.apiClient = new ApiClient({ + baseUrl: apiEndpoint, + authManager, + accountId: context.orgId + }); + } + + return this.apiClient; + } + /** * Retry an operation with exponential backoff */ diff --git a/packages/tm-core/src/modules/storage/adapters/file-storage/file-operations.ts b/packages/tm-core/src/modules/storage/adapters/file-storage/file-operations.ts index 19dc59df..6d5ce03a 100644 --- a/packages/tm-core/src/modules/storage/adapters/file-storage/file-operations.ts +++ b/packages/tm-core/src/modules/storage/adapters/file-storage/file-operations.ts @@ -2,7 +2,8 @@ * @fileoverview File operations with atomic writes and locking */ -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; +import { constants } from 'node:fs'; import type { FileStorageData } from './format-handler.js'; /** @@ -84,7 +85,7 @@ export class FileOperations { */ async exists(filePath: string): Promise { try { - await fs.access(filePath, fs.constants.F_OK); + await fs.access(filePath, constants.F_OK); return true; } catch { return false; diff --git a/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts b/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts index b01bc124..ca0717db 100644 --- a/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts +++ b/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts @@ -372,6 +372,22 @@ export class FileStorage implements IStorage { await this.saveTasks(tasks, tag); } + /** + * Update task with AI-powered prompt + * For file storage, this should NOT be called - client must handle AI processing first + */ + async updateTaskWithPrompt( + _taskId: string, + _prompt: string, + _tag?: string, + _options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' } + ): Promise { + throw new Error( + 'File storage does not support updateTaskWithPrompt. ' + + 'Client-side AI logic must process the prompt before calling updateTask().' + ); + } + /** * Update task or subtask status by ID - handles file storage logic with parent/subtask relationships */ diff --git a/packages/tm-core/src/modules/storage/utils/api-client.ts b/packages/tm-core/src/modules/storage/utils/api-client.ts new file mode 100644 index 00000000..19f8acfe --- /dev/null +++ b/packages/tm-core/src/modules/storage/utils/api-client.ts @@ -0,0 +1,146 @@ +/** + * Lightweight API client utility for Hamster backend + * Centralizes error handling, auth, and request/response logic + */ + +import { + TaskMasterError, + ERROR_CODES +} from '../../../common/errors/task-master-error.js'; +import type { AuthManager } from '../../auth/managers/auth-manager.js'; + +export interface ApiClientOptions { + baseUrl: string; + authManager: AuthManager; + accountId?: string; +} + +export interface ApiErrorResponse { + message: string; + error?: string; + statusCode?: number; +} + +export class ApiClient { + constructor(private options: ApiClientOptions) {} + + /** + * Make a typed API request with automatic error handling + */ + async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const { baseUrl, authManager, accountId } = this.options; + + // Get auth session + const session = await authManager.supabaseClient.getSession(); + if (!session) { + throw new TaskMasterError( + 'Not authenticated', + ERROR_CODES.AUTHENTICATION_ERROR, + { operation: 'api-request', endpoint } + ); + } + + // Build full URL + const url = `${baseUrl}${endpoint}`; + + // Build headers + const headers: RequestInit['headers'] = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.access_token}`, + ...(accountId ? { 'x-account-id': accountId } : {}), + ...options.headers + }; + + try { + // Make request + const response = await fetch(url, { + ...options, + headers + }); + + // Handle non-2xx responses + if (!response.ok) { + await this.handleErrorResponse(response, endpoint); + } + + // Parse successful response + return (await response.json()) as T; + } catch (error) { + // If it's already a TaskMasterError, re-throw + if (error instanceof TaskMasterError) { + throw error; + } + + // Wrap other errors + const errorMessage = + error instanceof Error ? error.message : String(error); + throw new TaskMasterError( + errorMessage, + ERROR_CODES.API_ERROR, + { operation: 'api-request', endpoint }, + error as Error + ); + } + } + + /** + * Extract and throw a clean error from API response + */ + private async handleErrorResponse( + response: Response, + endpoint: string + ): Promise { + let errorMessage: string; + + try { + // API returns: { message: "...", error: "...", statusCode: 404 } + const errorBody = (await response.json()) as ApiErrorResponse; + errorMessage = + errorBody.message || errorBody.error || 'Unknown API error'; + } catch { + // Fallback if response isn't JSON + errorMessage = (await response.text()) || response.statusText; + } + + throw new TaskMasterError(errorMessage, ERROR_CODES.API_ERROR, { + operation: 'api-request', + endpoint, + statusCode: response.status + }); + } + + /** + * Convenience methods for common HTTP verbs + */ + async get(endpoint: string): Promise { + return this.request(endpoint, { method: 'GET' }); + } + + async post(endpoint: string, body?: any): Promise { + return this.request(endpoint, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined + }); + } + + async patch(endpoint: string, body?: any): Promise { + return this.request(endpoint, { + method: 'PATCH', + body: body ? JSON.stringify(body) : undefined + }); + } + + async put(endpoint: string, body?: any): Promise { + return this.request(endpoint, { + method: 'PUT', + body: body ? JSON.stringify(body) : undefined + }); + } + + async delete(endpoint: string): Promise { + return this.request(endpoint, { method: 'DELETE' }); + } +} diff --git a/packages/tm-core/src/modules/tasks/services/preflight-checker.service.ts b/packages/tm-core/src/modules/tasks/services/preflight-checker.service.ts index 863c2d08..3632a741 100644 --- a/packages/tm-core/src/modules/tasks/services/preflight-checker.service.ts +++ b/packages/tm-core/src/modules/tasks/services/preflight-checker.service.ts @@ -3,7 +3,7 @@ * Validates environment and prerequisites for autopilot execution */ -import { readFileSync, existsSync, readdirSync } from 'fs'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'path'; import { execSync } from 'child_process'; import { getLogger } from '../../../common/logger/factory.js'; diff --git a/packages/tm-core/src/modules/tasks/services/task-service.ts b/packages/tm-core/src/modules/tasks/services/task-service.ts index b29f0783..b2397a57 100644 --- a/packages/tm-core/src/modules/tasks/services/task-service.ts +++ b/packages/tm-core/src/modules/tasks/services/task-service.ts @@ -505,6 +505,125 @@ export class TaskService { await this.configManager.setActiveTag(tag); } + /** + * Update a task with new data (direct structural update) + * @param taskId - Task ID (supports numeric, alphanumeric, and subtask IDs) + * @param updates - Partial task object with fields to update + * @param tag - Optional tag context + */ + async updateTask( + taskId: string | number, + updates: Partial, + tag?: string + ): Promise { + // Ensure we have storage + if (!this.storage) { + throw new TaskMasterError( + 'Storage not initialized', + ERROR_CODES.STORAGE_ERROR + ); + } + + // Auto-initialize if needed + if (!this.initialized) { + await this.initialize(); + } + + // Use provided tag or get active tag + const activeTag = tag || this.getActiveTag(); + const taskIdStr = String(taskId); + + try { + // Direct update - no AI processing + await this.storage.updateTask(taskIdStr, updates, activeTag); + } catch (error) { + // If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it + if ( + error instanceof TaskMasterError && + error.is(ERROR_CODES.NO_BRIEF_SELECTED) + ) { + throw error; + } + + throw new TaskMasterError( + `Failed to update task ${taskId}`, + ERROR_CODES.STORAGE_ERROR, + { + operation: 'updateTask', + resource: 'task', + taskId: taskIdStr, + tag: activeTag + }, + error as Error + ); + } + } + + /** + * Update a task using AI-powered prompt (natural language update) + * @param taskId - Task ID (supports numeric, alphanumeric, and subtask IDs) + * @param prompt - Natural language prompt describing the update + * @param tag - Optional tag context + * @param options - Optional update options + * @param options.useResearch - Use research AI for file storage updates + * @param options.mode - Update mode for API storage: 'append', 'update', or 'rewrite' + */ + async updateTaskWithPrompt( + taskId: string | number, + prompt: string, + tag?: string, + options?: { mode?: 'append' | 'update' | 'rewrite'; useResearch?: boolean } + ): Promise { + // Ensure we have storage + if (!this.storage) { + throw new TaskMasterError( + 'Storage not initialized', + ERROR_CODES.STORAGE_ERROR + ); + } + + // Auto-initialize if needed + if (!this.initialized) { + await this.initialize(); + } + + // Use provided tag or get active tag + const activeTag = tag || this.getActiveTag(); + const taskIdStr = String(taskId); + + try { + // AI-powered update - send prompt to storage layer + // API storage: sends prompt to backend for server-side AI processing + // File storage: must use client-side AI logic before calling this + await this.storage.updateTaskWithPrompt( + taskIdStr, + prompt, + activeTag, + options + ); + } catch (error) { + // If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it + if ( + error instanceof TaskMasterError + ) { + throw error; + } + + throw new TaskMasterError( + `Failed to update task ${taskId} with prompt`, + ERROR_CODES.STORAGE_ERROR, + { + operation: 'updateTaskWithPrompt', + resource: 'task', + taskId: taskIdStr, + tag: activeTag, + promptLength: prompt.length + }, + error as Error + ); + } + } + /** * Update task status - delegates to storage layer which handles storage-specific logic */ diff --git a/packages/tm-core/src/modules/tasks/tasks-domain.ts b/packages/tm-core/src/modules/tasks/tasks-domain.ts index 78b03b90..2e5c39fa 100644 --- a/packages/tm-core/src/modules/tasks/tasks-domain.ts +++ b/packages/tm-core/src/modules/tasks/tasks-domain.ts @@ -111,6 +111,38 @@ export class TasksDomain { // ========== Task Status Management ========== + /** + * Update task with new data (direct structural update) + * @param taskId - Task ID (supports numeric, alphanumeric like TAS-49, and subtask IDs like 1.2) + * @param updates - Partial task object with fields to update + * @param tag - Optional tag context + */ + async update( + taskId: string | number, + updates: Partial, + tag?: string + ): Promise { + return this.taskService.updateTask(taskId, updates, tag); + } + + /** + * Update task using AI-powered prompt (natural language update) + * @param taskId - Task ID (supports numeric, alphanumeric like TAS-49, and subtask IDs like 1.2) + * @param prompt - Natural language prompt describing the update + * @param tag - Optional tag context + * @param options - Optional update options + * @param options.useResearch - Use research AI for file storage updates + * @param options.mode - Update mode for API storage: 'append', 'update', or 'rewrite' + */ + async updateWithPrompt( + taskId: string | number, + prompt: string, + tag?: string, + options?: { mode?: 'append' | 'update' | 'rewrite'; useResearch?: boolean } + ): Promise { + return this.taskService.updateTaskWithPrompt(taskId, prompt, tag, options); + } + /** * Update task status */ diff --git a/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts b/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts index 2fd9a329..012d5b59 100644 --- a/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts +++ b/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts @@ -6,7 +6,7 @@ * Each project gets its own directory for organizing workflow-related data. */ -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import type { WorkflowState } from '../types.js'; diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 13361b7b..2d237388 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -1080,12 +1080,19 @@ function registerCommands(programInstance) { process.exit(1); } - // Parse the task ID and validate it's a number - const taskId = parseInt(options.id, 10); - if (Number.isNaN(taskId) || taskId <= 0) { + // Parse the task ID and validate it's a number or a string like ham-123 or tas-456 + // Accept valid task IDs: + // - positive integers (e.g. 1,2,3) + // - strings like ham-123, ham-1, tas-456, etc + // Disallow decimals and invalid formats + const validId = + /^\d+$/.test(options.id) || // plain positive integer + /^[a-z]+-\d+$/i.test(options.id); // label-number format (e.g., ham-123) + + if (!validId) { console.error( chalk.red( - `Error: Invalid task ID: ${options.id}. Task ID must be a positive integer.` + `Error: Invalid task ID: ${options.id}. Task ID must be a positive integer or in the form "ham-123".` ) ); console.log( @@ -1096,6 +1103,8 @@ function registerCommands(programInstance) { process.exit(1); } + const taskId = options.id; + if (!options.prompt) { console.error( chalk.red( diff --git a/scripts/modules/task-manager/list-tasks.js b/scripts/modules/task-manager/list-tasks.js index 77a0b3aa..c51768f1 100644 --- a/scripts/modules/task-manager/list-tasks.js +++ b/scripts/modules/task-manager/list-tasks.js @@ -18,6 +18,7 @@ import { getComplexityWithColor, createProgressBar } from '../ui.js'; +import { createTmCore } from '@tm/core'; /** * List all tasks @@ -31,7 +32,7 @@ import { * @param {string} context.tag - Tag for the task * @returns {Object} - Task list result for json format */ -function listTasks( +async function listTasks( tasksPath, statusFilter, reportPath = null, @@ -41,8 +42,30 @@ function listTasks( ) { const { projectRoot, tag } = context; try { - // Extract projectRoot from context if provided - const data = readJSON(tasksPath, projectRoot, tag); // Pass projectRoot to readJSON + // BRIDGE: Initialize tm-core for unified task access + let data; + try { + const tmCore = await createTmCore({ + projectPath: projectRoot || process.cwd() + }); + + // Load tasks via tm-core tasks domain (supports both file and API storage) + const result = await tmCore.tasks.list({ tag }); + data = { tasks: result.tasks }; + + log( + 'debug', + `Loaded ${result.tasks.length} tasks via tm-core (${result.storageType} storage)` + ); + } catch (storageError) { + log( + 'warn', + `TmCore failed, falling back to legacy readJSON: ${storageError.message}` + ); + // Fallback to old readJSON if tm-core fails + data = readJSON(tasksPath, projectRoot, tag); + } + if (!data || !data.tasks) { throw new Error(`No valid tasks found in ${tasksPath}`); } diff --git a/scripts/modules/task-manager/update-subtask-by-id.js b/scripts/modules/task-manager/update-subtask-by-id.js index 68666d35..94f9c0ea 100644 --- a/scripts/modules/task-manager/update-subtask-by-id.js +++ b/scripts/modules/task-manager/update-subtask-by-id.js @@ -25,6 +25,7 @@ import { getPromptManager } from '../prompt-manager.js'; import generateTaskFiles from './generate-task-files.js'; import { ContextGatherer } from '../utils/contextGatherer.js'; import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; +import { tryUpdateViaRemote } from '@tm/bridge'; /** * Update a subtask by appending additional timestamped information using the unified AI service. @@ -92,6 +93,32 @@ async function updateSubtaskById( throw new Error('Could not determine project root directory'); } + // --- BRIDGE: Try remote update first (API storage) --- + // In API storage, subtask IDs like "1.2" or "TAS-49.1" are just regular task IDs + // So update-subtask and update-task work identically + const remoteResult = await tryUpdateViaRemote({ + taskId: subtaskId, + prompt, + projectRoot, + tag, + appendMode: true, // Subtask updates are always append mode + useResearch, + isMCP, + outputFormat, + report + }); + + // If remote handled it, return the result + if (remoteResult) { + return { + updatedSubtask: { id: subtaskId }, + telemetryData: remoteResult.telemetryData, + tagInfo: remoteResult.tagInfo + }; + } + // Otherwise fall through to file-based logic below + // --- End BRIDGE --- + const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) { throw new Error( diff --git a/scripts/modules/task-manager/update-task-by-id.js b/scripts/modules/task-manager/update-task-by-id.js index e7f537f4..58e2224a 100644 --- a/scripts/modules/task-manager/update-task-by-id.js +++ b/scripts/modules/task-manager/update-task-by-id.js @@ -1,5 +1,4 @@ import fs from 'fs'; -import path from 'path'; import chalk from 'chalk'; import boxen from 'boxen'; import Table from 'cli-table3'; @@ -34,11 +33,12 @@ import { import { getPromptManager } from '../prompt-manager.js'; import { ContextGatherer } from '../utils/contextGatherer.js'; import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; +import { tryUpdateViaRemote } from '@tm/bridge'; /** * Update a task by ID with new information using the unified AI service. * @param {string} tasksPath - Path to the tasks.json file - * @param {number} taskId - ID of the task to update + * @param {string|number} taskId - ID of the task to update (supports numeric, alphanumeric like HAM-123, and subtask IDs like 1.2) * @param {string} prompt - Prompt for generating updated task information * @param {boolean} [useResearch=false] - Whether to use the research AI role. * @param {Object} context - Context object containing session and mcpLog. @@ -76,13 +76,21 @@ async function updateTaskById( try { report('info', `Updating single task ${taskId} with prompt: "${prompt}"`); - // --- Input Validations (Keep existing) --- - if (!Number.isInteger(taskId) || taskId <= 0) - throw new Error( - `Invalid task ID: ${taskId}. Task ID must be a positive integer.` - ); + // --- Input Validations --- + // Note: taskId can be a number (1), string with dot (1.2), or display ID (HAM-123) + // So we don't validate it as strictly anymore + if (taskId === null || taskId === undefined || String(taskId).trim() === '') + throw new Error('Task ID cannot be empty.'); + if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') throw new Error('Prompt cannot be empty.'); + + // Determine project root first (needed for API key checks) + const projectRoot = providedProjectRoot || findProjectRoot(); + if (!projectRoot) { + throw new Error('Could not determine project root directory'); + } + if (useResearch && !isApiKeySet('perplexity', session)) { report( 'warn', @@ -94,22 +102,48 @@ async function updateTaskById( ); useResearch = false; } + + // --- BRIDGE: Try remote update first (API storage) --- + const remoteResult = await tryUpdateViaRemote({ + taskId, + prompt, + projectRoot, + tag, + appendMode, + useResearch, + isMCP, + outputFormat, + report + }); + + // If remote handled it, return the result + if (remoteResult) { + return remoteResult; + } + // Otherwise fall through to file-based logic below + // --- End BRIDGE --- + + // For file storage, ensure the tasks file exists if (!fs.existsSync(tasksPath)) throw new Error(`Tasks file not found: ${tasksPath}`); // --- End Input Validations --- - // Determine project root - const projectRoot = providedProjectRoot || findProjectRoot(); - if (!projectRoot) { - throw new Error('Could not determine project root directory'); - } - // --- Task Loading and Status Check (Keep existing) --- const data = readJSON(tasksPath, projectRoot, tag); if (!data || !data.tasks) throw new Error(`No valid tasks found in ${tasksPath}.`); - const taskIndex = data.tasks.findIndex((task) => task.id === taskId); - if (taskIndex === -1) throw new Error(`Task with ID ${taskId} not found.`); + // File storage requires a strict numeric task ID + const idStr = String(taskId).trim(); + if (!/^\d+$/.test(idStr)) { + throw new Error( + 'For file storage, taskId must be a positive integer. ' + + 'Use update-subtask-by-id for IDs like "1.2", or run in API storage for display IDs (e.g., "HAM-123").' + ); + } + const numericTaskId = Number(idStr); + const taskIndex = data.tasks.findIndex((task) => task.id === numericTaskId); + if (taskIndex === -1) + throw new Error(`Task with ID ${numericTaskId} not found.`); const taskToUpdate = data.tasks[taskIndex]; if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') { report( diff --git a/tests/unit/scripts/modules/task-manager/list-tasks.test.js b/tests/unit/scripts/modules/task-manager/list-tasks.test.js index 13827c52..06f173b9 100644 --- a/tests/unit/scripts/modules/task-manager/list-tasks.test.js +++ b/tests/unit/scripts/modules/task-manager/list-tasks.test.js @@ -47,6 +47,16 @@ jest.unstable_mockModule( }) ); +// Mock @tm/core to control task data in tests +const mockTasksList = jest.fn(); +jest.unstable_mockModule('@tm/core', () => ({ + createTmCore: jest.fn(async () => ({ + tasks: { + list: mockTasksList + } + })) +})); + // Import the mocked modules const { readJSON, @@ -144,7 +154,12 @@ describe('listTasks', () => { }); // Set up default mock return values - readJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks))); + const defaultSampleTasks = JSON.parse(JSON.stringify(sampleTasks)); + readJSON.mockReturnValue(defaultSampleTasks); + mockTasksList.mockResolvedValue({ + tasks: defaultSampleTasks.tasks, + storageType: 'file' + }); readComplexityReport.mockReturnValue(null); validateAndFixDependencies.mockImplementation(() => {}); displayTaskList.mockImplementation(() => {}); @@ -161,12 +176,11 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json', { + const result = await listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); expect(result).toEqual( expect.objectContaining({ tasks: expect.arrayContaining([ @@ -186,13 +200,18 @@ describe('listTasks', () => { const statusFilter = 'pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); - // Verify only pending tasks are returned expect(result.tasks).toHaveLength(1); expect(result.tasks[0].status).toBe('pending'); @@ -205,9 +224,16 @@ describe('listTasks', () => { const statusFilter = 'done'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Verify only done tasks are returned @@ -221,9 +247,16 @@ describe('listTasks', () => { const statusFilter = 'review'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Verify only review tasks are returned @@ -237,7 +270,7 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, true, 'json', { + const result = await listTasks(tasksPath, null, null, true, 'json', { tag: 'master' }); @@ -254,7 +287,7 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json', { + const result = await listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); @@ -274,9 +307,16 @@ describe('listTasks', () => { const statusFilter = 'blocked'; // Status that doesn't exist in sample data // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Verify empty array is returned @@ -286,14 +326,26 @@ describe('listTasks', () => { test('should handle file read errors', async () => { // Arrange const tasksPath = 'tasks/tasks.json'; + // Mock tm-core to throw an error, and readJSON to also throw + mockTasksList.mockReset(); + mockTasksList.mockImplementation(() => { + return Promise.reject(new Error('File not found')); + }); + readJSON.mockReset(); readJSON.mockImplementation(() => { throw new Error('File not found'); }); // Act & Assert - expect(() => { - listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); - }).toThrow('File not found'); + // When outputFormat is 'json', listTasks throws a structured error object + await expect( + listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }) + ).rejects.toEqual( + expect.objectContaining({ + code: 'TASK_LIST_ERROR', + message: 'File not found' + }) + ); }); test('should validate and fix dependencies before listing', async () => { @@ -301,10 +353,9 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); + await listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); // Note: validateAndFixDependencies is not called by listTasks function // This test just verifies the function runs without error }); @@ -314,7 +365,7 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, 'pending', null, true, 'json', { + const result = await listTasks(tasksPath, 'pending', null, true, 'json', { tag: 'master' }); @@ -335,9 +386,16 @@ describe('listTasks', () => { const statusFilter = 'in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert expect(result.tasks).toHaveLength(1); @@ -351,9 +409,16 @@ describe('listTasks', () => { const statusFilter = 'cancelled'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert expect(result.tasks).toHaveLength(1); @@ -366,7 +431,7 @@ describe('listTasks', () => { const tasksPath = 'tasks/tasks.json'; // Act - const result = listTasks(tasksPath, null, null, false, 'json', { + const result = await listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); @@ -394,13 +459,18 @@ describe('listTasks', () => { const statusFilter = 'done,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert - expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master'); - // Should return tasks with 'done' or 'pending' status expect(result.tasks).toHaveLength(2); expect(result.tasks.map((t) => t.status)).toEqual( @@ -414,9 +484,16 @@ describe('listTasks', () => { const statusFilter = 'done,pending,in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should return tasks with 'done', 'pending', or 'in-progress' status @@ -440,9 +517,16 @@ describe('listTasks', () => { const statusFilter = 'done, pending , in-progress'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should trim spaces and work correctly @@ -459,9 +543,16 @@ describe('listTasks', () => { const statusFilter = 'done,,pending,'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should ignore empty values and work with valid ones @@ -476,9 +567,16 @@ describe('listTasks', () => { const statusFilter = 'DONE,Pending,IN-PROGRESS'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should match case-insensitively @@ -495,9 +593,16 @@ describe('listTasks', () => { const statusFilter = 'blocked,deferred'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should return empty array as no tasks have these statuses @@ -510,9 +615,16 @@ describe('listTasks', () => { const statusFilter = 'pending,'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should work the same as single status filter @@ -526,9 +638,16 @@ describe('listTasks', () => { const statusFilter = 'done,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should return the original filter string @@ -541,9 +660,16 @@ describe('listTasks', () => { const statusFilter = 'all'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should return all tasks when filter is 'all' @@ -557,9 +683,16 @@ describe('listTasks', () => { const statusFilter = 'done,nonexistent,pending'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should return only tasks with existing statuses @@ -574,9 +707,16 @@ describe('listTasks', () => { const statusFilter = 'review,cancelled'; // Act - const result = listTasks(tasksPath, statusFilter, null, false, 'json', { - tag: 'master' - }); + const result = await listTasks( + tasksPath, + statusFilter, + null, + false, + 'json', + { + tag: 'master' + } + ); // Assert // Should return tasks with 'review' or 'cancelled' status @@ -660,6 +800,11 @@ describe('listTasks', () => { }); test('should handle empty task list in compact format', async () => { + // Mock tm-core to return empty task list + mockTasksList.mockResolvedValue({ + tasks: [], + storageType: 'file' + }); readJSON.mockReturnValue({ tasks: [] }); const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const tasksPath = 'tasks/tasks.json'; @@ -701,6 +846,11 @@ describe('listTasks', () => { ] }; + // Mock tm-core to return test data + mockTasksList.mockResolvedValue({ + tasks: tasksWithDeps.tasks, + storageType: 'file' + }); readJSON.mockReturnValue(tasksWithDeps); const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const tasksPath = 'tasks/tasks.json'; diff --git a/tsconfig.json b/tsconfig.json index 0883c824..1fbd0cc7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,9 @@ ], "@tm/ai-sdk-provider-grok-cli/*": [ "./packages/ai-sdk-provider-grok-cli/src/*" - ] + ], + "@tm/bridge": ["./packages/tm-bridge/src/index.ts"], + "@tm/bridge/*": ["./packages/tm-bridge/src/*"] } }, "tsx": {