feat: update task remote (#1345)

This commit is contained in:
Ralph Khreish
2025-10-25 19:25:17 +02:00
committed by GitHub
parent 03b7ef9a0e
commit 486ed40215
31 changed files with 1463 additions and 159 deletions

View File

@@ -103,7 +103,7 @@ export async function updateSubtaskByIdDirect(args, log, context = {}) {
} }
try { try {
// Execute core updateSubtaskById function // Call legacy script which handles both API and file storage via bridge
const coreResult = await updateSubtaskById( const coreResult = await updateSubtaskById(
tasksPath, tasksPath,
subtaskIdStr, 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}`; const successMessage = `Successfully updated subtask with ID ${subtaskIdStr}`;
logWrapper.success(successMessage); logWrapper.success(successMessage);
return { return {
@@ -137,7 +137,7 @@ export async function updateSubtaskByIdDirect(args, log, context = {}) {
data: { data: {
message: `Successfully updated subtask with ID ${subtaskIdStr}`, message: `Successfully updated subtask with ID ${subtaskIdStr}`,
subtaskId: subtaskIdStr, subtaskId: subtaskIdStr,
parentId: subtaskIdStr.split('.')[0], parentId: parentId,
subtask: coreResult.updatedSubtask, subtask: coreResult.updatedSubtask,
tasksPath, tasksPath,
useResearch, useResearch,

View File

@@ -10,6 +10,7 @@ import {
isSilentMode isSilentMode
} from '../../../../scripts/modules/utils.js'; } from '../../../../scripts/modules/utils.js';
import { createLogWrapper } from '../../tools/utils.js'; import { createLogWrapper } from '../../tools/utils.js';
import { findTasksPath } from '../utils/path-utils.js';
/** /**
* Direct function wrapper for updateTaskById with error handling. * 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}` `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) // Check required parameters (id and prompt)
if (!id) { if (!id) {
const errorMessage = const errorMessage =
@@ -56,7 +47,7 @@ export async function updateTaskByIdDirect(args, log, context = {}) {
logWrapper.error(errorMessage); logWrapper.error(errorMessage);
return { return {
success: false, 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); logWrapper.error(errorMessage);
return { return {
success: false, 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; let taskId;
if (typeof id === 'string') { if (typeof id === 'string') {
// Handle subtask IDs (e.g., "5.2") // Keep ID as string - supports numeric (1, 2), alphanumeric (TAS-49, JIRA-123), and subtask IDs (1.2, TAS-49.1)
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 {
taskId = id; 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 // Resolve tasks.json path - use provided or find it
const tasksPath = tasksJsonPath; 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 // Get research flag
const useResearch = research === true; const useResearch = research === true;
@@ -108,7 +104,7 @@ export async function updateTaskByIdDirect(args, log, context = {}) {
} }
try { try {
// Execute core updateTaskById function with proper parameters // Call legacy script which handles both API and file storage via bridge
const coreResult = await updateTaskById( const coreResult = await updateTaskById(
tasksPath, tasksPath,
taskId, taskId,
@@ -128,7 +124,6 @@ export async function updateTaskByIdDirect(args, log, context = {}) {
// Check if the core function returned null or an object without success // Check if the core function returned null or an object without success
if (!coreResult || coreResult.updatedTask === null) { 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).`; const message = `Task ${taskId} was not updated (likely already completed).`;
logWrapper.info(message); logWrapper.info(message);
return { 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`; const successMessage = `Successfully updated task with ID ${taskId} based on the prompt`;
logWrapper.success(successMessage); logWrapper.info(successMessage);
return { return {
success: true, success: true,
data: { data: {

281
package-lock.json generated
View File

@@ -9360,6 +9360,10 @@
"resolved": "packages/ai-sdk-provider-grok-cli", "resolved": "packages/ai-sdk-provider-grok-cli",
"link": true "link": true
}, },
"node_modules/@tm/bridge": {
"resolved": "packages/tm-bridge",
"link": true
},
"node_modules/@tm/build-config": { "node_modules/@tm/build-config": {
"resolved": "packages/build-config", "resolved": "packages/build-config",
"link": true "link": true
@@ -28214,6 +28218,283 @@
"version": "0.0.2", "version": "0.0.2",
"license": "MIT WITH Commons-Clause" "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": { "packages/tm-core": {
"name": "@tm/core", "name": "@tm/core",
"dependencies": { "dependencies": {

View File

@@ -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.

View File

@@ -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"
}

View File

@@ -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';

View File

@@ -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<RemoteUpdateResult | null> {
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;
}
}

View File

@@ -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"]
}

View File

@@ -63,7 +63,7 @@ export interface IStorage {
appendTasks(tasks: Task[], tag?: string): Promise<void>; appendTasks(tasks: Task[], tag?: string): Promise<void>;
/** /**
* Update a specific task by ID * Update a specific task by ID (direct structural update)
* @param taskId - ID of the task to update * @param taskId - ID of the task to update
* @param updates - Partial task object with fields to update * @param updates - Partial task object with fields to update
* @param tag - Optional tag context for the task * @param tag - Optional tag context for the task
@@ -75,6 +75,23 @@ export interface IStorage {
tag?: string tag?: string
): Promise<void>; ): Promise<void>;
/**
* 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<void>;
/** /**
* Update task or subtask status by ID * Update task or subtask status by ID
* @param taskId - ID of the task or subtask (e.g., "1" or "1.2") * @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<Task>, updates: Partial<Task>,
tag?: string tag?: string
): Promise<void>; ): Promise<void>;
abstract updateTaskWithPrompt(
taskId: string,
prompt: string,
tag?: string,
options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' }
): Promise<void>;
abstract updateTaskStatus( abstract updateTaskStatus(
taskId: string, taskId: string,
newStatus: TaskStatus, newStatus: TaskStatus,

View File

@@ -28,7 +28,7 @@ export class AuthManager {
private static readonly staticLogger = getLogger('AuthManager'); private static readonly staticLogger = getLogger('AuthManager');
private credentialStore: CredentialStore; private credentialStore: CredentialStore;
private oauthService: OAuthService; private oauthService: OAuthService;
private supabaseClient: SupabaseAuthClient; public supabaseClient: SupabaseAuthClient;
private organizationService?: OrganizationService; private organizationService?: OrganizationService;
private readonly logger = getLogger('AuthManager'); private readonly logger = getLogger('AuthManager');

View File

@@ -3,7 +3,7 @@
*/ */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 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 { ConfigLoader } from './config-loader.service.js';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';

View File

@@ -3,7 +3,7 @@
* Responsible for loading configuration from various file sources * 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 path from 'node:path';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js'; import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';

View File

@@ -3,8 +3,9 @@
*/ */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 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 { ConfigPersistence } from './config-persistence.service.js';
import type { PartialConfiguration } from '@tm/core/common/interfaces/configuration.interface.js';
vi.mock('node:fs', () => ({ vi.mock('node:fs', () => ({
promises: { promises: {
@@ -32,9 +33,16 @@ describe('ConfigPersistence', () => {
}); });
describe('saveConfig', () => { describe('saveConfig', () => {
const mockConfig = { const mockConfig: PartialConfiguration = {
models: { main: 'test-model' }, models: { main: 'test-model', fallback: 'test-fallback' },
storage: { type: 'file' as const } storage: {
type: 'file' as const,
enableBackup: true,
maxBackups: 5,
enableCompression: true,
encoding: 'utf-8',
atomicOperations: true
}
}; };
it('should save configuration to file', async () => { it('should save configuration to file', async () => {

View File

@@ -3,7 +3,7 @@
* Handles saving and backup of configuration files * 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 path from 'node:path';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js'; import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import { import {

View File

@@ -3,7 +3,7 @@
*/ */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 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 { RuntimeStateManager } from './runtime-state-manager.service.js';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
@@ -48,7 +48,7 @@ describe('RuntimeStateManager', () => {
'/test/project/.taskmaster/state.json', '/test/project/.taskmaster/state.json',
'utf-8' 'utf-8'
); );
expect(state.activeTag).toBe('feature-branch'); expect(state.currentTag).toBe('feature-branch');
expect(state.metadata).toEqual({ test: 'data' }); expect(state.metadata).toEqual({ test: 'data' });
}); });
@@ -60,7 +60,7 @@ describe('RuntimeStateManager', () => {
const state = await stateManager.loadState(); 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 () => { it('should use default state when file does not exist', async () => {
@@ -70,7 +70,7 @@ describe('RuntimeStateManager', () => {
const state = await stateManager.loadState(); 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 () => { it('should use environment variable when file does not exist', async () => {
@@ -82,7 +82,7 @@ describe('RuntimeStateManager', () => {
const state = await stateManager.loadState(); 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 () => { it('should handle file read errors gracefully', async () => {
@@ -90,7 +90,7 @@ describe('RuntimeStateManager', () => {
const state = await stateManager.loadState(); 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 () => { it('should handle invalid JSON gracefully', async () => {
@@ -101,7 +101,7 @@ describe('RuntimeStateManager', () => {
const state = await stateManager.loadState(); 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(); expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore(); warnSpy.mockRestore();
@@ -114,7 +114,7 @@ describe('RuntimeStateManager', () => {
vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined);
// Set a specific state // Set a specific state
await stateManager.setActiveTag('test-tag'); await stateManager.setCurrentTag('test-tag');
// Verify mkdir was called // Verify mkdir was called
expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.taskmaster', { expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.taskmaster', {
@@ -160,7 +160,7 @@ describe('RuntimeStateManager', () => {
describe('getActiveTag', () => { describe('getActiveTag', () => {
it('should return current active tag', () => { it('should return current active tag', () => {
const tag = stateManager.getActiveTag(); const tag = stateManager.getCurrentTag();
expect(tag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); expect(tag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
}); });
@@ -168,9 +168,9 @@ describe('RuntimeStateManager', () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).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.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).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(); expect(fs.writeFile).toHaveBeenCalled();
}); });
}); });
@@ -193,7 +193,7 @@ describe('RuntimeStateManager', () => {
expect(state1).not.toBe(state2); // Different instances expect(state1).not.toBe(state2); // Different instances
expect(state1).toEqual(state2); // Same content 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( expect(fs.unlink).toHaveBeenCalledWith(
'/test/project/.taskmaster/state.json' '/test/project/.taskmaster/state.json'
); );
expect(stateManager.getActiveTag()).toBe( expect(stateManager.getCurrentTag()).toBe(
DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG
); );
expect(stateManager.getState().metadata).toBeUndefined(); expect(stateManager.getState().metadata).toBeUndefined();
@@ -256,7 +256,7 @@ describe('RuntimeStateManager', () => {
vi.mocked(fs.unlink).mockRejectedValue(error); vi.mocked(fs.unlink).mockRejectedValue(error);
await expect(stateManager.clearState()).resolves.not.toThrow(); await expect(stateManager.clearState()).resolves.not.toThrow();
expect(stateManager.getActiveTag()).toBe( expect(stateManager.getCurrentTag()).toBe(
DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG
); );
}); });

View File

@@ -3,7 +3,7 @@
* Manages runtime state separate from configuration * 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 path from 'node:path';
import { import {
ERROR_CODES, ERROR_CODES,

View File

@@ -3,7 +3,7 @@
* Follows the same pattern as ConfigManager and AuthManager * 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 path from 'path';
import type { import type {
ComplexityReport, ComplexityReport,

View File

@@ -23,6 +23,8 @@ import { TaskRepository } from '../../tasks/repositories/task-repository.interfa
import { SupabaseTaskRepository } from '../../tasks/repositories/supabase/index.js'; import { SupabaseTaskRepository } from '../../tasks/repositories/supabase/index.js';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { AuthManager } from '../../auth/managers/auth-manager.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 * API storage configuration
@@ -47,6 +49,22 @@ type ContextWithBrief = NonNullable<
ReturnType<typeof AuthManager.prototype.getContext> ReturnType<typeof AuthManager.prototype.getContext>
> & { briefId: string }; > & { 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 * ApiStorage implementation using repository pattern
* Provides flexibility to swap between different backend implementations * Provides flexibility to swap between different backend implementations
@@ -58,6 +76,8 @@ export class ApiStorage implements IStorage {
private readonly maxRetries: number; private readonly maxRetries: number;
private initialized = false; private initialized = false;
private tagsCache: Map<string, TaskTag> = new Map(); private tagsCache: Map<string, TaskTag> = new Map();
private apiClient?: ApiClient;
private readonly logger = getLogger('ApiStorage');
constructor(config: ApiStorageConfig) { constructor(config: ApiStorageConfig) {
this.validateConfig(config); 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<void> {
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<UpdateTaskWithPromptResponse>(
`/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 * Update task or subtask status by ID - for API storage
*/ */
@@ -796,6 +886,35 @@ export class ApiStorage implements IStorage {
return context as ContextWithBrief; 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 * Retry an operation with exponential backoff
*/ */

View File

@@ -2,7 +2,8 @@
* @fileoverview File operations with atomic writes and locking * @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'; import type { FileStorageData } from './format-handler.js';
/** /**
@@ -84,7 +85,7 @@ export class FileOperations {
*/ */
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
try { try {
await fs.access(filePath, fs.constants.F_OK); await fs.access(filePath, constants.F_OK);
return true; return true;
} catch { } catch {
return false; return false;

View File

@@ -372,6 +372,22 @@ export class FileStorage implements IStorage {
await this.saveTasks(tasks, tag); 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<void> {
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 * Update task or subtask status by ID - handles file storage logic with parent/subtask relationships
*/ */

View File

@@ -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<T = any>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
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<never> {
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<T = any>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T = any>(endpoint: string, body?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined
});
}
async patch<T = any>(endpoint: string, body?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined
});
}
async put<T = any>(endpoint: string, body?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined
});
}
async delete<T = any>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
}

View File

@@ -3,7 +3,7 @@
* Validates environment and prerequisites for autopilot execution * 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 { join } from 'path';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { getLogger } from '../../../common/logger/factory.js'; import { getLogger } from '../../../common/logger/factory.js';

View File

@@ -505,6 +505,125 @@ export class TaskService {
await this.configManager.setActiveTag(tag); 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<Task>,
tag?: string
): Promise<void> {
// 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<void> {
// 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 * Update task status - delegates to storage layer which handles storage-specific logic
*/ */

View File

@@ -111,6 +111,38 @@ export class TasksDomain {
// ========== Task Status Management ========== // ========== 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<Task>,
tag?: string
): Promise<void> {
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<void> {
return this.taskService.updateTaskWithPrompt(taskId, prompt, tag, options);
}
/** /**
* Update task status * Update task status
*/ */

View File

@@ -6,7 +6,7 @@
* Each project gets its own directory for organizing workflow-related data. * 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 path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import type { WorkflowState } from '../types.js'; import type { WorkflowState } from '../types.js';

View File

@@ -1080,12 +1080,19 @@ function registerCommands(programInstance) {
process.exit(1); process.exit(1);
} }
// Parse the task ID and validate it's a number // Parse the task ID and validate it's a number or a string like ham-123 or tas-456
const taskId = parseInt(options.id, 10); // Accept valid task IDs:
if (Number.isNaN(taskId) || taskId <= 0) { // - 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( console.error(
chalk.red( 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( console.log(
@@ -1096,6 +1103,8 @@ function registerCommands(programInstance) {
process.exit(1); process.exit(1);
} }
const taskId = options.id;
if (!options.prompt) { if (!options.prompt) {
console.error( console.error(
chalk.red( chalk.red(

View File

@@ -18,6 +18,7 @@ import {
getComplexityWithColor, getComplexityWithColor,
createProgressBar createProgressBar
} from '../ui.js'; } from '../ui.js';
import { createTmCore } from '@tm/core';
/** /**
* List all tasks * List all tasks
@@ -31,7 +32,7 @@ import {
* @param {string} context.tag - Tag for the task * @param {string} context.tag - Tag for the task
* @returns {Object} - Task list result for json format * @returns {Object} - Task list result for json format
*/ */
function listTasks( async function listTasks(
tasksPath, tasksPath,
statusFilter, statusFilter,
reportPath = null, reportPath = null,
@@ -41,8 +42,30 @@ function listTasks(
) { ) {
const { projectRoot, tag } = context; const { projectRoot, tag } = context;
try { try {
// Extract projectRoot from context if provided // BRIDGE: Initialize tm-core for unified task access
const data = readJSON(tasksPath, projectRoot, tag); // Pass projectRoot to readJSON 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) { if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`); throw new Error(`No valid tasks found in ${tasksPath}`);
} }

View File

@@ -25,6 +25,7 @@ import { getPromptManager } from '../prompt-manager.js';
import generateTaskFiles from './generate-task-files.js'; import generateTaskFiles from './generate-task-files.js';
import { ContextGatherer } from '../utils/contextGatherer.js'; import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.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. * 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'); 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); const data = readJSON(tasksPath, projectRoot, tag);
if (!data || !data.tasks) { if (!data || !data.tasks) {
throw new Error( throw new Error(

View File

@@ -1,5 +1,4 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path';
import chalk from 'chalk'; import chalk from 'chalk';
import boxen from 'boxen'; import boxen from 'boxen';
import Table from 'cli-table3'; import Table from 'cli-table3';
@@ -34,11 +33,12 @@ import {
import { getPromptManager } from '../prompt-manager.js'; import { getPromptManager } from '../prompt-manager.js';
import { ContextGatherer } from '../utils/contextGatherer.js'; import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.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. * Update a task by ID with new information using the unified AI service.
* @param {string} tasksPath - Path to the tasks.json file * @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 {string} prompt - Prompt for generating updated task information
* @param {boolean} [useResearch=false] - Whether to use the research AI role. * @param {boolean} [useResearch=false] - Whether to use the research AI role.
* @param {Object} context - Context object containing session and mcpLog. * @param {Object} context - Context object containing session and mcpLog.
@@ -76,13 +76,21 @@ async function updateTaskById(
try { try {
report('info', `Updating single task ${taskId} with prompt: "${prompt}"`); report('info', `Updating single task ${taskId} with prompt: "${prompt}"`);
// --- Input Validations (Keep existing) --- // --- Input Validations ---
if (!Number.isInteger(taskId) || taskId <= 0) // Note: taskId can be a number (1), string with dot (1.2), or display ID (HAM-123)
throw new Error( // So we don't validate it as strictly anymore
`Invalid task ID: ${taskId}. Task ID must be a positive integer.` if (taskId === null || taskId === undefined || String(taskId).trim() === '')
); throw new Error('Task ID cannot be empty.');
if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') if (!prompt || typeof prompt !== 'string' || prompt.trim() === '')
throw new Error('Prompt cannot be empty.'); 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)) { if (useResearch && !isApiKeySet('perplexity', session)) {
report( report(
'warn', 'warn',
@@ -94,22 +102,48 @@ async function updateTaskById(
); );
useResearch = false; 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)) if (!fs.existsSync(tasksPath))
throw new Error(`Tasks file not found: ${tasksPath}`); throw new Error(`Tasks file not found: ${tasksPath}`);
// --- End Input Validations --- // --- 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) --- // --- Task Loading and Status Check (Keep existing) ---
const data = readJSON(tasksPath, projectRoot, tag); const data = readJSON(tasksPath, projectRoot, tag);
if (!data || !data.tasks) if (!data || !data.tasks)
throw new Error(`No valid tasks found in ${tasksPath}.`); throw new Error(`No valid tasks found in ${tasksPath}.`);
const taskIndex = data.tasks.findIndex((task) => task.id === taskId); // File storage requires a strict numeric task ID
if (taskIndex === -1) throw new Error(`Task with ID ${taskId} not found.`); 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]; const taskToUpdate = data.tasks[taskIndex];
if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') { if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') {
report( report(

View File

@@ -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 // Import the mocked modules
const { const {
readJSON, readJSON,
@@ -144,7 +154,12 @@ describe('listTasks', () => {
}); });
// Set up default mock return values // 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); readComplexityReport.mockReturnValue(null);
validateAndFixDependencies.mockImplementation(() => {}); validateAndFixDependencies.mockImplementation(() => {});
displayTaskList.mockImplementation(() => {}); displayTaskList.mockImplementation(() => {});
@@ -161,12 +176,11 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json'; const tasksPath = 'tasks/tasks.json';
// Act // Act
const result = listTasks(tasksPath, null, null, false, 'json', { const result = await listTasks(tasksPath, null, null, false, 'json', {
tag: 'master' tag: 'master'
}); });
// Assert // Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
expect(result).toEqual( expect(result).toEqual(
expect.objectContaining({ expect.objectContaining({
tasks: expect.arrayContaining([ tasks: expect.arrayContaining([
@@ -186,13 +200,18 @@ describe('listTasks', () => {
const statusFilter = 'pending'; const statusFilter = 'pending';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Verify only pending tasks are returned // Verify only pending tasks are returned
expect(result.tasks).toHaveLength(1); expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('pending'); expect(result.tasks[0].status).toBe('pending');
@@ -205,9 +224,16 @@ describe('listTasks', () => {
const statusFilter = 'done'; const statusFilter = 'done';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Verify only done tasks are returned // Verify only done tasks are returned
@@ -221,9 +247,16 @@ describe('listTasks', () => {
const statusFilter = 'review'; const statusFilter = 'review';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Verify only review tasks are returned // Verify only review tasks are returned
@@ -237,7 +270,7 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json'; const tasksPath = 'tasks/tasks.json';
// Act // Act
const result = listTasks(tasksPath, null, null, true, 'json', { const result = await listTasks(tasksPath, null, null, true, 'json', {
tag: 'master' tag: 'master'
}); });
@@ -254,7 +287,7 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json'; const tasksPath = 'tasks/tasks.json';
// Act // Act
const result = listTasks(tasksPath, null, null, false, 'json', { const result = await listTasks(tasksPath, null, null, false, 'json', {
tag: 'master' tag: 'master'
}); });
@@ -274,9 +307,16 @@ describe('listTasks', () => {
const statusFilter = 'blocked'; // Status that doesn't exist in sample data const statusFilter = 'blocked'; // Status that doesn't exist in sample data
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Verify empty array is returned // Verify empty array is returned
@@ -286,14 +326,26 @@ describe('listTasks', () => {
test('should handle file read errors', async () => { test('should handle file read errors', async () => {
// Arrange // Arrange
const tasksPath = 'tasks/tasks.json'; 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(() => { readJSON.mockImplementation(() => {
throw new Error('File not found'); throw new Error('File not found');
}); });
// Act & Assert // Act & Assert
expect(() => { // When outputFormat is 'json', listTasks throws a structured error object
listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); await expect(
}).toThrow('File not found'); 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 () => { test('should validate and fix dependencies before listing', async () => {
@@ -301,10 +353,9 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json'; const tasksPath = 'tasks/tasks.json';
// Act // Act
listTasks(tasksPath, null, null, false, 'json', { tag: 'master' }); await listTasks(tasksPath, null, null, false, 'json', { tag: 'master' });
// Assert // Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Note: validateAndFixDependencies is not called by listTasks function // Note: validateAndFixDependencies is not called by listTasks function
// This test just verifies the function runs without error // This test just verifies the function runs without error
}); });
@@ -314,7 +365,7 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json'; const tasksPath = 'tasks/tasks.json';
// Act // Act
const result = listTasks(tasksPath, 'pending', null, true, 'json', { const result = await listTasks(tasksPath, 'pending', null, true, 'json', {
tag: 'master' tag: 'master'
}); });
@@ -335,9 +386,16 @@ describe('listTasks', () => {
const statusFilter = 'in-progress'; const statusFilter = 'in-progress';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
expect(result.tasks).toHaveLength(1); expect(result.tasks).toHaveLength(1);
@@ -351,9 +409,16 @@ describe('listTasks', () => {
const statusFilter = 'cancelled'; const statusFilter = 'cancelled';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
expect(result.tasks).toHaveLength(1); expect(result.tasks).toHaveLength(1);
@@ -366,7 +431,7 @@ describe('listTasks', () => {
const tasksPath = 'tasks/tasks.json'; const tasksPath = 'tasks/tasks.json';
// Act // Act
const result = listTasks(tasksPath, null, null, false, 'json', { const result = await listTasks(tasksPath, null, null, false, 'json', {
tag: 'master' tag: 'master'
}); });
@@ -394,13 +459,18 @@ describe('listTasks', () => {
const statusFilter = 'done,pending'; const statusFilter = 'done,pending';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
expect(readJSON).toHaveBeenCalledWith(tasksPath, undefined, 'master');
// Should return tasks with 'done' or 'pending' status // Should return tasks with 'done' or 'pending' status
expect(result.tasks).toHaveLength(2); expect(result.tasks).toHaveLength(2);
expect(result.tasks.map((t) => t.status)).toEqual( expect(result.tasks.map((t) => t.status)).toEqual(
@@ -414,9 +484,16 @@ describe('listTasks', () => {
const statusFilter = 'done,pending,in-progress'; const statusFilter = 'done,pending,in-progress';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should return tasks with 'done', 'pending', or 'in-progress' status // Should return tasks with 'done', 'pending', or 'in-progress' status
@@ -440,9 +517,16 @@ describe('listTasks', () => {
const statusFilter = 'done, pending , in-progress'; const statusFilter = 'done, pending , in-progress';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should trim spaces and work correctly // Should trim spaces and work correctly
@@ -459,9 +543,16 @@ describe('listTasks', () => {
const statusFilter = 'done,,pending,'; const statusFilter = 'done,,pending,';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should ignore empty values and work with valid ones // Should ignore empty values and work with valid ones
@@ -476,9 +567,16 @@ describe('listTasks', () => {
const statusFilter = 'DONE,Pending,IN-PROGRESS'; const statusFilter = 'DONE,Pending,IN-PROGRESS';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should match case-insensitively // Should match case-insensitively
@@ -495,9 +593,16 @@ describe('listTasks', () => {
const statusFilter = 'blocked,deferred'; const statusFilter = 'blocked,deferred';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should return empty array as no tasks have these statuses // Should return empty array as no tasks have these statuses
@@ -510,9 +615,16 @@ describe('listTasks', () => {
const statusFilter = 'pending,'; const statusFilter = 'pending,';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should work the same as single status filter // Should work the same as single status filter
@@ -526,9 +638,16 @@ describe('listTasks', () => {
const statusFilter = 'done,pending'; const statusFilter = 'done,pending';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should return the original filter string // Should return the original filter string
@@ -541,9 +660,16 @@ describe('listTasks', () => {
const statusFilter = 'all'; const statusFilter = 'all';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should return all tasks when filter is 'all' // Should return all tasks when filter is 'all'
@@ -557,9 +683,16 @@ describe('listTasks', () => {
const statusFilter = 'done,nonexistent,pending'; const statusFilter = 'done,nonexistent,pending';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should return only tasks with existing statuses // Should return only tasks with existing statuses
@@ -574,9 +707,16 @@ describe('listTasks', () => {
const statusFilter = 'review,cancelled'; const statusFilter = 'review,cancelled';
// Act // Act
const result = listTasks(tasksPath, statusFilter, null, false, 'json', { const result = await listTasks(
tag: 'master' tasksPath,
}); statusFilter,
null,
false,
'json',
{
tag: 'master'
}
);
// Assert // Assert
// Should return tasks with 'review' or 'cancelled' status // Should return tasks with 'review' or 'cancelled' status
@@ -660,6 +800,11 @@ describe('listTasks', () => {
}); });
test('should handle empty task list in compact format', async () => { 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: [] }); readJSON.mockReturnValue({ tasks: [] });
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const tasksPath = 'tasks/tasks.json'; 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); readJSON.mockReturnValue(tasksWithDeps);
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const tasksPath = 'tasks/tasks.json'; const tasksPath = 'tasks/tasks.json';

View File

@@ -27,7 +27,9 @@
], ],
"@tm/ai-sdk-provider-grok-cli/*": [ "@tm/ai-sdk-provider-grok-cli/*": [
"./packages/ai-sdk-provider-grok-cli/src/*" "./packages/ai-sdk-provider-grok-cli/src/*"
] ],
"@tm/bridge": ["./packages/tm-bridge/src/index.ts"],
"@tm/bridge/*": ["./packages/tm-bridge/src/*"]
} }
}, },
"tsx": { "tsx": {