diff --git a/CHANGELOG.md b/CHANGELOG.md index 102b64d3..71c32093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # task-master-ai +## 0.27.2 + +### Patch Changes + +- [#1248](https://github.com/eyaltoledano/claude-task-master/pull/1248) [`044a7bf`](https://github.com/eyaltoledano/claude-task-master/commit/044a7bfc98049298177bc655cf341d7a8b6a0011) Thanks [@Crunchyman-ralph](https://github.com/Crunchyman-ralph)! - Fix set-status for subtasks: + - Parent tasks are now set as `done` when subtasks are all `done` + - Parent tasks are now set as `in-progress` when at least one subtask is `in-progress` or `done` + ## 0.27.1 ### Patch Changes diff --git a/apps/extension/CHANGELOG.md b/apps/extension/CHANGELOG.md index 2a36dc5d..6c98d5dc 100644 --- a/apps/extension/CHANGELOG.md +++ b/apps/extension/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## 0.25.3 + +### Patch Changes + +- Updated dependencies [[`044a7bf`](https://github.com/eyaltoledano/claude-task-master/commit/044a7bfc98049298177bc655cf341d7a8b6a0011)]: + - task-master-ai@0.27.2 + ## 0.25.2 ### Patch Changes diff --git a/apps/extension/package.json b/apps/extension/package.json index 28a0e17f..88f33ae0 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -3,7 +3,7 @@ "private": true, "displayName": "TaskMaster", "description": "A visual Kanban board interface for TaskMaster projects in VS Code", - "version": "0.25.2", + "version": "0.25.3", "publisher": "Hamster", "icon": "assets/icon.png", "engines": { @@ -240,7 +240,7 @@ "check-types": "tsc --noEmit" }, "dependencies": { - "task-master-ai": "0.27.1" + "task-master-ai": "0.27.2" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package-lock.json b/package-lock.json index e5619e96..b2a6b523 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "task-master-ai", - "version": "0.27.1", + "version": "0.27.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "task-master-ai", - "version": "0.27.1", + "version": "0.27.2", "license": "MIT WITH Commons-Clause", "workspaces": [ "apps/*", @@ -357,9 +357,9 @@ } }, "apps/extension": { - "version": "0.25.2", + "version": "0.25.3", "dependencies": { - "task-master-ai": "0.27.1" + "task-master-ai": "0.27.2" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 0c193e8b..3fb6eb0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "task-master-ai", - "version": "0.27.1", + "version": "0.27.2", "description": "A task management system for ambitious AI-driven development that doesn't overwhelm and confuse Cursor.", "main": "index.js", "type": "module", diff --git a/packages/tm-core/src/interfaces/storage.interface.ts b/packages/tm-core/src/interfaces/storage.interface.ts index 4fee9893..ee7297f5 100644 --- a/packages/tm-core/src/interfaces/storage.interface.ts +++ b/packages/tm-core/src/interfaces/storage.interface.ts @@ -3,7 +3,17 @@ * This file defines the contract for all storage implementations */ -import type { Task, TaskMetadata } from '../types/index.js'; +import type { Task, TaskMetadata, TaskStatus } from '../types/index.js'; + +/** + * Result type for updateTaskStatus operations + */ +export interface UpdateStatusResult { + success: boolean; + oldStatus: TaskStatus; + newStatus: TaskStatus; + taskId: string; +} /** * Interface for storage operations on tasks @@ -54,6 +64,19 @@ export interface IStorage { tag?: string ): Promise; + /** + * Update task or subtask status by ID + * @param taskId - ID of the task or subtask (e.g., "1" or "1.2") + * @param newStatus - New status to set + * @param tag - Optional tag context for the task + * @returns Promise that resolves to update result with old and new status + */ + updateTaskStatus( + taskId: string, + newStatus: TaskStatus, + tag?: string + ): Promise; + /** * Delete a task by ID * @param taskId - ID of the task to delete @@ -191,6 +214,11 @@ export abstract class BaseStorage implements IStorage { updates: Partial, tag?: string ): Promise; + abstract updateTaskStatus( + taskId: string, + newStatus: TaskStatus, + tag?: string + ): Promise; abstract deleteTask(taskId: string, tag?: string): Promise; abstract exists(tag?: string): Promise; abstract loadMetadata(tag?: string): Promise; diff --git a/packages/tm-core/src/services/task-service.ts b/packages/tm-core/src/services/task-service.ts index 9fa1810b..0670dfa1 100644 --- a/packages/tm-core/src/services/task-service.ts +++ b/packages/tm-core/src/services/task-service.ts @@ -446,7 +446,7 @@ export class TaskService { } /** - * Update task status + * Update task status - delegates to storage layer which handles storage-specific logic */ async updateTaskStatus( taskId: string | number, @@ -468,49 +468,28 @@ export class TaskService { // Use provided tag or get active tag const activeTag = tag || this.getActiveTag(); - const taskIdStr = String(taskId); - // TODO: For now, assume it's a regular task and just try to update directly - // In the future, we can add subtask support if needed - if (taskIdStr.includes('.')) { - throw new TaskMasterError( - 'Subtask status updates not yet supported in API storage', - ERROR_CODES.NOT_IMPLEMENTED - ); - } - - // Get the current task to get old status (simple, direct approach) - let currentTask: Task | null; try { - // Try to get the task directly - currentTask = await this.storage.loadTask(taskIdStr, activeTag); + // Delegate to storage layer which handles the specific logic for tasks vs subtasks + return await this.storage.updateTaskStatus( + taskIdStr, + newStatus, + activeTag + ); } catch (error) { throw new TaskMasterError( - `Failed to load task ${taskIdStr}`, - ERROR_CODES.TASK_NOT_FOUND, - { taskId: taskIdStr }, + `Failed to update task status for ${taskIdStr}`, + ERROR_CODES.STORAGE_ERROR, + { + operation: 'updateTaskStatus', + resource: 'task', + taskId: taskIdStr, + newStatus, + tag: activeTag + }, error as Error ); } - - if (!currentTask) { - throw new TaskMasterError( - `Task ${taskIdStr} not found`, - ERROR_CODES.TASK_NOT_FOUND - ); - } - - const oldStatus = currentTask.status; - - // Simple, direct update - just change the status - await this.storage.updateTask(taskIdStr, { status: newStatus }, activeTag); - - return { - success: true, - oldStatus, - newStatus, - taskId: taskIdStr - }; } } diff --git a/packages/tm-core/src/storage/api-storage.ts b/packages/tm-core/src/storage/api-storage.ts index c4a475d8..b62c1e92 100644 --- a/packages/tm-core/src/storage/api-storage.ts +++ b/packages/tm-core/src/storage/api-storage.ts @@ -5,9 +5,15 @@ import type { IStorage, - StorageStats + StorageStats, + UpdateStatusResult } from '../interfaces/storage.interface.js'; -import type { Task, TaskMetadata, TaskTag } from '../types/index.js'; +import type { + Task, + TaskMetadata, + TaskTag, + TaskStatus +} from '../types/index.js'; import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js'; import { TaskRepository } from '../repositories/task-repository.interface.js'; import { SupabaseTaskRepository } from '../repositories/supabase-task-repository.js'; @@ -485,6 +491,62 @@ export class ApiStorage implements IStorage { } } + /** + * Update task or subtask status by ID - for API storage + */ + async updateTaskStatus( + taskId: string, + newStatus: TaskStatus, + tag?: string + ): Promise { + await this.ensureInitialized(); + + try { + const existingTask = await this.retryOperation(() => + this.repository.getTask(this.projectId, taskId) + ); + + if (!existingTask) { + throw new Error(`Task ${taskId} not found`); + } + + const oldStatus = existingTask.status; + if (oldStatus === newStatus) { + return { + success: true, + oldStatus, + newStatus, + taskId + }; + } + + // Update the task/subtask status + await this.retryOperation(() => + this.repository.updateTask(this.projectId, taskId, { + status: newStatus, + updatedAt: new Date().toISOString() + }) + ); + + // Note: Parent status auto-adjustment is handled by the backend API service + // which has its own business logic for managing task relationships + + return { + success: true, + oldStatus, + newStatus, + taskId + }; + } catch (error) { + throw new TaskMasterError( + 'Failed to update task status via API', + ERROR_CODES.STORAGE_ERROR, + { operation: 'updateTaskStatus', taskId, newStatus, tag }, + error as Error + ); + } + } + /** * Get all available tags */ diff --git a/packages/tm-core/src/storage/file-storage/file-storage.ts b/packages/tm-core/src/storage/file-storage/file-storage.ts index 13fb27dd..a0486e41 100644 --- a/packages/tm-core/src/storage/file-storage/file-storage.ts +++ b/packages/tm-core/src/storage/file-storage/file-storage.ts @@ -2,10 +2,11 @@ * @fileoverview Refactored file-based storage implementation for Task Master */ -import type { Task, TaskMetadata } from '../../types/index.js'; +import type { Task, TaskMetadata, TaskStatus } from '../../types/index.js'; import type { IStorage, - StorageStats + StorageStats, + UpdateStatusResult } from '../../interfaces/storage.interface.js'; import { FormatHandler } from './format-handler.js'; import { FileOperations } from './file-operations.js'; @@ -281,6 +282,156 @@ export class FileStorage implements IStorage { await this.saveTasks(tasks, tag); } + /** + * Update task or subtask status by ID - handles file storage logic with parent/subtask relationships + */ + async updateTaskStatus( + taskId: string, + newStatus: TaskStatus, + tag?: string + ): Promise { + const tasks = await this.loadTasks(tag); + + // Check if this is a subtask (contains a dot) + if (taskId.includes('.')) { + return this.updateSubtaskStatusInFile(tasks, taskId, newStatus, tag); + } + + // Handle regular task update + const taskIndex = tasks.findIndex((t) => String(t.id) === String(taskId)); + + if (taskIndex === -1) { + throw new Error(`Task ${taskId} not found`); + } + + const oldStatus = tasks[taskIndex].status; + if (oldStatus === newStatus) { + return { + success: true, + oldStatus, + newStatus, + taskId: String(taskId) + }; + } + + tasks[taskIndex] = { + ...tasks[taskIndex], + status: newStatus, + updatedAt: new Date().toISOString() + }; + + await this.saveTasks(tasks, tag); + + return { + success: true, + oldStatus, + newStatus, + taskId: String(taskId) + }; + } + + /** + * Update subtask status within file storage - handles parent status auto-adjustment + */ + private async updateSubtaskStatusInFile( + tasks: Task[], + subtaskId: string, + newStatus: TaskStatus, + tag?: string + ): Promise { + // Parse the subtask ID to get parent ID and subtask ID + const parts = subtaskId.split('.'); + if (parts.length !== 2) { + throw new Error( + `Invalid subtask ID format: ${subtaskId}. Expected format: parentId.subtaskId` + ); + } + + const [parentId, subIdRaw] = parts; + const subId = subIdRaw.trim(); + if (!/^\d+$/.test(subId)) { + throw new Error( + `Invalid subtask ID: ${subId}. Subtask ID must be a positive integer.` + ); + } + const subtaskNumericId = Number(subId); + + // Find the parent task + const parentTaskIndex = tasks.findIndex( + (t) => String(t.id) === String(parentId) + ); + + if (parentTaskIndex === -1) { + throw new Error(`Parent task ${parentId} not found`); + } + + const parentTask = tasks[parentTaskIndex]; + + // Find the subtask within the parent task + const subtaskIndex = parentTask.subtasks.findIndex( + (st) => st.id === subtaskNumericId || String(st.id) === subId + ); + + if (subtaskIndex === -1) { + throw new Error( + `Subtask ${subtaskId} not found in parent task ${parentId}` + ); + } + + const oldStatus = parentTask.subtasks[subtaskIndex].status || 'pending'; + if (oldStatus === newStatus) { + return { + success: true, + oldStatus, + newStatus, + taskId: subtaskId + }; + } + + const now = new Date().toISOString(); + + // Update the subtask status + parentTask.subtasks[subtaskIndex] = { + ...parentTask.subtasks[subtaskIndex], + status: newStatus, + updatedAt: now + }; + + // Auto-adjust parent status based on subtask statuses + const subs = parentTask.subtasks; + let parentNewStatus = parentTask.status; + if (subs.length > 0) { + const norm = (s: any) => s.status || 'pending'; + const isDoneLike = (s: any) => { + const st = norm(s); + return st === 'done' || st === 'completed'; + }; + const allDone = subs.every(isDoneLike); + const anyInProgress = subs.some((s) => norm(s) === 'in-progress'); + const anyDone = subs.some(isDoneLike); + if (allDone) parentNewStatus = 'done'; + else if (anyInProgress || anyDone) parentNewStatus = 'in-progress'; + } + + // Always bump updatedAt; update status only if changed + tasks[parentTaskIndex] = { + ...parentTask, + ...(parentNewStatus !== parentTask.status + ? { status: parentNewStatus } + : {}), + updatedAt: now + }; + + await this.saveTasks(tasks, tag); + + return { + success: true, + oldStatus, + newStatus, + taskId: subtaskId + }; + } + /** * Delete a task */