diff --git a/.changeset/list-watch-compact.md b/.changeset/list-watch-compact.md new file mode 100644 index 00000000..3d273c7f --- /dev/null +++ b/.changeset/list-watch-compact.md @@ -0,0 +1,12 @@ +--- +"task-master-ai": minor +--- + +Add watch mode and compact output to list command + +- Add `-w/--watch` flag to continuously monitor task changes with real-time updates +- Add `-c/--compact` flag for minimal task output format +- Add `--no-header` flag to hide the command header +- Support file-based watching via fs.watch for local tasks.json +- Support API-based watching via Supabase Realtime for authenticated users +- Display last sync timestamp and source in watch mode diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index 823b8449..3f073908 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -11,6 +11,7 @@ import { type Task, type TaskStatus, type TmCore, + type WatchSubscription, createTmCore } from '@tm/core'; import type { StorageType } from '@tm/core'; @@ -24,6 +25,8 @@ import { displayDashboards, displayRecommendedNextTask, displaySuggestedNextSteps, + displaySyncMessage, + displayWatchFooter, getPriorityBreakdown, getTaskDescription } from '../ui/index.js'; @@ -42,8 +45,11 @@ export interface ListCommandOptions { withSubtasks?: boolean; format?: OutputFormat; json?: boolean; + compact?: boolean; + noHeader?: boolean; silent?: boolean; project?: string; + watch?: boolean; } /** @@ -87,11 +93,17 @@ export class ListTasksCommand extends Command { 'text' ) .option('--json', 'Output in JSON format (shorthand for --format json)') + .option( + '-c, --compact', + 'Output in compact format (shorthand for --format compact)' + ) + .option('--no-header', 'Hide the command header') .option('--silent', 'Suppress output (useful for programmatic usage)') .option( '-p, --project ', 'Project root directory (auto-detected if not provided)' ) + .option('-w, --watch', 'Watch for changes and update list automatically') .action(async (statusArg?: string, options?: ListCommandOptions) => { // Handle special "all" keyword to show with subtasks let status = statusArg || options?.status; @@ -127,20 +139,88 @@ export class ListTasksCommand extends Command { await this.initializeCore(getProjectRoot(options.project)); // Get tasks from core - const result = await this.getTasks(options); + if (options.watch) { + await this.watchTasks(options); + } else { + const result = await this.getTasks(options); - // Store result for programmatic access - this.setLastResult(result); + // Store result for programmatic access + this.setLastResult(result); - // Display results - if (!options.silent) { - this.displayResults(result, options); + // Display results + if (!options.silent) { + this.displayResults(result, options); + } } } catch (error: any) { displayError(error); } } + /** + * Watch for changes and update list + * Uses tm-core's unified watch API which handles both file and API storage + */ + private async watchTasks(options: ListCommandOptions): Promise { + if (!this.tmCore) { + throw new Error('TmCore not initialized'); + } + + // Initial render + let result = await this.getTasks(options); + let lastSync = new Date(); + + console.clear(); + this.displayResults(result, options); + + const storageType = result.storageType; + displayWatchFooter(storageType, lastSync); + + let subscription: WatchSubscription | undefined; + + try { + // Subscribe to task changes via tm-core + subscription = await this.tmCore.tasks.watch( + async (event) => { + if (event.type === 'change') { + try { + // Re-fetch tasks + result = await this.getTasks(options); + lastSync = new Date(); + + // Clear and display + console.clear(); + this.displayResults(result, options); + + // Show sync message with timestamp + displaySyncMessage(storageType, lastSync); + displayWatchFooter(storageType, lastSync); + } catch { + // Ignore errors during watch (e.g. partial writes) + } + } else if (event.type === 'error' && event.error) { + console.error(chalk.red(`\n⚠ Watch error: ${event.error.message}`)); + } + }, + { tag: options.tag } + ); + + // Cleanup on process termination + const cleanup = () => { + subscription?.unsubscribe(); + process.exit(0); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Keep process alive + await new Promise(() => {}); + } catch (error: any) { + console.error(chalk.red(`Watch mode error: ${error.message}`)); + throw error; + } + } + /** * Validate command options */ @@ -219,9 +299,13 @@ export class ListTasksCommand extends Command { result: ListTasksResult, options: ListCommandOptions ): void { - // If --json flag is set, override format to 'json' + // Resolve format: --json and --compact flags override --format option const format = ( - options.json ? 'json' : options.format || 'text' + options.json + ? 'json' + : options.compact + ? 'compact' + : options.format || 'text' ) as OutputFormat; switch (format) { @@ -230,12 +314,12 @@ export class ListTasksCommand extends Command { break; case 'compact': - this.displayCompact(result.tasks, options.withSubtasks); + this.displayCompact(result, options); break; case 'text': default: - this.displayText(result, options.withSubtasks, options.status); + this.displayText(result, options); break; } } @@ -264,12 +348,25 @@ export class ListTasksCommand extends Command { /** * Display in compact format */ - private displayCompact(tasks: Task[], withSubtasks?: boolean): void { + private displayCompact( + data: ListTasksResult, + options: ListCommandOptions + ): void { + const { tasks, tag, storageType } = data; + + // Display header unless --no-header is set + if (options.noHeader !== true) { + displayCommandHeader(this.tmCore, { + tag: tag || 'master', + storageType + }); + } + tasks.forEach((task) => { const icon = STATUS_ICONS[task.status]; console.log(`${chalk.cyan(task.id)} ${icon} ${task.title}`); - if (withSubtasks && task.subtasks?.length) { + if (options.withSubtasks && task.subtasks?.length) { task.subtasks.forEach((subtask) => { const subIcon = STATUS_ICONS[subtask.status]; console.log( @@ -285,16 +382,17 @@ export class ListTasksCommand extends Command { */ private displayText( data: ListTasksResult, - withSubtasks?: boolean, - _statusFilter?: string + options: ListCommandOptions ): void { const { tasks, tag, storageType } = data; - // Display header using utility function - displayCommandHeader(this.tmCore, { - tag: tag || 'master', - storageType - }); + // Display header unless --no-header is set + if (options.noHeader !== true) { + displayCommandHeader(this.tmCore, { + tag: tag || 'master', + storageType + }); + } // No tasks message if (tasks.length === 0) { @@ -328,7 +426,7 @@ export class ListTasksCommand extends Command { // Task table console.log( ui.createTaskTable(tasks, { - showSubtasks: withSubtasks, + showSubtasks: options.withSubtasks, showDependencies: true, showComplexity: true // Enable complexity column }) diff --git a/apps/cli/src/ui/components/index.ts b/apps/cli/src/ui/components/index.ts index ef2e58f9..efcaacff 100644 --- a/apps/cli/src/ui/components/index.ts +++ b/apps/cli/src/ui/components/index.ts @@ -9,3 +9,4 @@ export * from './header.component.js'; export * from './next-task.component.js'; export * from './suggested-steps.component.js'; export * from './task-detail.component.js'; +export * from './watch-footer.component.js'; diff --git a/apps/cli/src/ui/components/watch-footer.component.ts b/apps/cli/src/ui/components/watch-footer.component.ts new file mode 100644 index 00000000..4d2a3166 --- /dev/null +++ b/apps/cli/src/ui/components/watch-footer.component.ts @@ -0,0 +1,41 @@ +/** + * @fileoverview Watch mode footer component for real-time task updates + */ + +import chalk from 'chalk'; +import { formatTime } from '@tm/core'; + +/** + * Get display label for storage source + */ +function getSourceLabel(storageType: 'api' | 'file'): string { + return storageType === 'api' ? 'Hamster Studio' : 'tasks.json'; +} + +/** + * Display watch status footer + */ +export function displayWatchFooter( + storageType: 'api' | 'file', + lastSync: Date +): void { + const syncTime = formatTime(lastSync); + const source = getSourceLabel(storageType); + + console.log(chalk.dim(`\nWatching ${source} for changes...`)); + console.log(chalk.gray(`Last synced: ${syncTime}`)); + console.log(chalk.dim('Press Ctrl+C to exit')); +} + +/** + * Display sync notification message + */ +export function displaySyncMessage( + storageType: 'api' | 'file', + syncTime: Date +): void { + const formattedTime = formatTime(syncTime); + const source = getSourceLabel(storageType); + + console.log(chalk.blue(`\nℹ ${source} updated at ${formattedTime}`)); +} diff --git a/apps/docs/capabilities/cli-root-commands.mdx b/apps/docs/capabilities/cli-root-commands.mdx index 3a7cbf49..73505ff7 100644 --- a/apps/docs/capabilities/cli-root-commands.mdx +++ b/apps/docs/capabilities/cli-root-commands.mdx @@ -28,6 +28,15 @@ sidebarTitle: "CLI Commands" # List tasks with a specific status and include subtasks task-master list --status= --with-subtasks + + # Watch for changes and auto-refresh the list + task-master list --watch + task-master list -w + + # Combine watch with other options + task-master list all -w # Watch all tasks with subtasks + task-master list -w --compact # Watch in compact format + task-master list --status=pending -w # Watch pending tasks only ``` diff --git a/apps/docs/command-reference.mdx b/apps/docs/command-reference.mdx index 5c8b1ff9..2fe23f81 100644 --- a/apps/docs/command-reference.mdx +++ b/apps/docs/command-reference.mdx @@ -27,6 +27,20 @@ description: "A comprehensive reference of all available Task Master commands" # List tasks with a specific status and include subtasks task-master list --status= --with-subtasks + + # Watch for changes and auto-refresh + task-master list --watch + task-master list -w + + # Compact output format + task-master list --compact + task-master list -c + + # Watch mode with compact output + task-master list -w -c + + # Hide the header + task-master list --no-header ``` diff --git a/assets/rules/taskmaster.mdc b/assets/rules/taskmaster.mdc index 93ff3616..da265164 100644 --- a/assets/rules/taskmaster.mdc +++ b/assets/rules/taskmaster.mdc @@ -101,7 +101,8 @@ This document provides a detailed reference for interacting with Taskmaster, cov * `withSubtasks`: `Include subtasks indented under their parent tasks in the list.` (CLI: `--with-subtasks`) * `tag`: `Specify which tag context to list tasks from. Defaults to the current active tag.` (CLI: `--tag `) * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) -* **Usage:** Get an overview of the project status, often used at the start of a work session. + * `watch`: `Watch for changes and auto-refresh the list in real-time. Works with file storage (fs.watch) and API storage (Supabase Realtime).` (CLI: `-w, --watch`) +* **Usage:** Get an overview of the project status, often used at the start of a work session. Use `--watch` to keep the list live-updating as tasks change. ### 4. Get Next Task (`next_task`) diff --git a/packages/tm-core/src/common/interfaces/storage.interface.ts b/packages/tm-core/src/common/interfaces/storage.interface.ts index 80f543f9..7cbe4323 100644 --- a/packages/tm-core/src/common/interfaces/storage.interface.ts +++ b/packages/tm-core/src/common/interfaces/storage.interface.ts @@ -235,6 +235,17 @@ export interface IStorage { * @returns Promise that resolves to tags with statistics */ getTagsWithStats(): Promise; + + /** + * Watch for changes to tasks + * @param callback - Function called when tasks change + * @param options - Watch options (debounce, tag) + * @returns Subscription handle with unsubscribe method + */ + watch( + callback: (event: WatchEvent) => void, + options?: WatchOptions + ): Promise; } /** @@ -282,6 +293,41 @@ export interface TagsWithStatsResult { totalTags: number; } +/** + * Watch event types + */ +export type WatchEventType = 'change' | 'error'; + +/** + * Watch event payload + */ +export interface WatchEvent { + /** Type of event */ + type: WatchEventType; + /** Timestamp of the event */ + timestamp: Date; + /** Error if type is 'error' */ + error?: Error; +} + +/** + * Watch options + */ +export interface WatchOptions { + /** Debounce time in milliseconds (default: 100) */ + debounceMs?: number; + /** Tag context for file storage */ + tag?: string; +} + +/** + * Watch subscription handle + */ +export interface WatchSubscription { + /** Unsubscribe from watch events */ + unsubscribe: () => void; +} + /** * Storage statistics interface */ @@ -380,6 +426,10 @@ export abstract class BaseStorage implements IStorage { abstract getStorageType(): 'file' | 'api'; abstract getCurrentBriefName(): string | null; abstract getTagsWithStats(): Promise; + abstract watch( + callback: (event: WatchEvent) => void, + options?: WatchOptions + ): Promise; /** * Utility method to generate backup filename * @param originalPath - Original file path diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 6be86067..4c45e600 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -36,10 +36,14 @@ export type * from './common/types/index.js'; // Common interfaces export type * from './common/interfaces/index.js'; -// Storage interfaces - TagInfo and TagsWithStatsResult +// Storage interfaces - TagInfo, TagsWithStatsResult, and Watch types export type { TagInfo, - TagsWithStatsResult + TagsWithStatsResult, + WatchEvent, + WatchEventType, + WatchOptions, + WatchSubscription } from './common/interfaces/storage.interface.js'; // Storage adapters - FileStorage for direct local file access 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 99500061..afb4ca56 100644 --- a/packages/tm-core/src/modules/storage/adapters/api-storage.ts +++ b/packages/tm-core/src/modules/storage/adapters/api-storage.ts @@ -12,7 +12,10 @@ import type { IStorage, LoadTasksOptions, StorageStats, - UpdateStatusResult + UpdateStatusResult, + WatchEvent, + WatchOptions, + WatchSubscription } from '../../../common/interfaces/storage.interface.js'; import { getLogger } from '../../../common/logger/factory.js'; import type { @@ -941,6 +944,92 @@ export class ApiStorage implements IStorage { this.tagsCache.clear(); } + /** + * Watch for changes to tasks via Supabase Realtime + * Subscribes to postgres_changes for the tasks table filtered by brief_id + * + * Optional debouncing is supported for collaborative scenarios where multiple + * rapid changes (e.g., bulk operations, multiple users) could cause UI flicker. + * Default: 300ms (higher than FileStorage's 100ms since Realtime events are cleaner) + */ + async watch( + callback: (event: WatchEvent) => void, + options?: WatchOptions + ): Promise { + await this.ensureInitialized(); + + const authManager = AuthManager.getInstance(); + const context = authManager.getContext(); + + if (!context?.briefId) { + throw new TaskMasterError( + 'No brief context found for watching. Please connect to a brief first.', + ERROR_CODES.NO_BRIEF_SELECTED, + { operation: 'watch' } + ); + } + + const supabase = authManager.supabaseClient.getClient(); + const channelName = `tasks-watch-${context.briefId}-${Date.now()}`; + const debounceMs = options?.debounceMs ?? 300; + + let debounceTimer: NodeJS.Timeout | undefined; + let closed = false; + + const debouncedCallback = () => { + if (closed) return; + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + if (!closed) { + callback({ + type: 'change', + timestamp: new Date() + }); + } + }, debounceMs); + }; + + const channel = supabase + .channel(channelName) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'tasks', + filter: `brief_id=eq.${context.briefId}` + }, + debouncedCallback + ) + .subscribe((status, error) => { + if ( + status === 'CHANNEL_ERROR' || + status === 'TIMED_OUT' || + status === 'CLOSED' || + error + ) { + callback({ + type: 'error', + timestamp: new Date(), + error: + error || new Error(`Channel subscription ${status.toLowerCase()}`) + }); + } + }); + + return { + unsubscribe: () => { + closed = true; + if (debounceTimer) { + clearTimeout(debounceTimer); + } + channel.unsubscribe(); + } + }; + } + /** * Ensure storage is initialized */ 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 f4c1e997..ffa39c85 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 @@ -2,6 +2,7 @@ * @fileoverview Refactored file-based storage implementation for Task Master */ +import fs from 'node:fs'; import path from 'node:path'; import { ERROR_CODES, @@ -11,7 +12,10 @@ import type { IStorage, LoadTasksOptions, StorageStats, - UpdateStatusResult + UpdateStatusResult, + WatchEvent, + WatchOptions, + WatchSubscription } from '../../../../common/interfaces/storage.interface.js'; import type { Task, @@ -882,6 +886,68 @@ export class FileStorage implements IStorage { } } + /** + * Watch for changes to tasks file + * Uses fs.watch with debouncing to detect file changes + */ + async watch( + callback: (event: WatchEvent) => void, + options?: WatchOptions + ): Promise { + const tasksPath = this.pathResolver.getTasksPath(); + const debounceMs = options?.debounceMs ?? 100; + + // Ensure file exists before watching + const fileExists = await this.fileOps.exists(tasksPath); + if (!fileExists) { + throw new TaskMasterError( + 'Tasks file not found. Initialize the project first.', + ERROR_CODES.NOT_FOUND, + { path: tasksPath } + ); + } + + let debounceTimer: NodeJS.Timeout | undefined; + let closed = false; + + const watcher = fs.watch(tasksPath, (eventType, filename) => { + if (closed) return; + if (filename && eventType === 'change') { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + if (!closed) { + callback({ + type: 'change', + timestamp: new Date() + }); + } + }, debounceMs); + } + }); + + watcher.on('error', (error) => { + if (!closed) { + callback({ + type: 'error', + timestamp: new Date(), + error + }); + } + }); + + return { + unsubscribe: () => { + closed = true; + if (debounceTimer) { + clearTimeout(debounceTimer); + } + watcher.close(); + } + }; + } + /** * Enrich tasks with complexity data from the complexity report * Private helper method called by loadTasks() diff --git a/packages/tm-core/src/modules/tasks/services/task-file-generator.service.spec.ts b/packages/tm-core/src/modules/tasks/services/task-file-generator.service.spec.ts index 3d489006..112e7c9e 100644 --- a/packages/tm-core/src/modules/tasks/services/task-file-generator.service.spec.ts +++ b/packages/tm-core/src/modules/tasks/services/task-file-generator.service.spec.ts @@ -37,7 +37,8 @@ function createMockStorage(tasks: Task[] = []): IStorage { getStats: vi.fn(), getStorageType: vi.fn().mockReturnValue('file'), getCurrentBriefName: vi.fn().mockReturnValue(null), - getTagsWithStats: vi.fn() + getTagsWithStats: vi.fn(), + watch: vi.fn().mockResolvedValue({ unsubscribe: vi.fn() }) }; } diff --git a/packages/tm-core/src/modules/tasks/tasks-domain.ts b/packages/tm-core/src/modules/tasks/tasks-domain.ts index 6ccb5645..534487df 100644 --- a/packages/tm-core/src/modules/tasks/tasks-domain.ts +++ b/packages/tm-core/src/modules/tasks/tasks-domain.ts @@ -36,6 +36,11 @@ import type { } from './services/preflight-checker.service.js'; import type { TaskValidationResult } from './services/task-loader.service.js'; import type { ExpandTaskResult } from '../integration/services/task-expansion.service.js'; +import type { + WatchEvent, + WatchOptions, + WatchSubscription +} from '../../common/interfaces/storage.interface.js'; /** * Tasks Domain - Unified API for all task operations @@ -389,6 +394,25 @@ export class TasksDomain { return this.taskService.getStorageType(); } + // ========== Watch ========== + + /** + * Watch for changes to tasks + * For file storage: uses fs.watch on tasks.json with debouncing + * For API storage: uses Supabase Realtime subscriptions + * + * @param callback - Function called when tasks change + * @param options - Watch options (debounce, tag) + * @returns Subscription handle with unsubscribe method + */ + async watch( + callback: (event: WatchEvent) => void, + options?: WatchOptions + ): Promise { + const storage = this.taskService.getStorage(); + return storage.watch(callback, options); + } + // ========== Task File Generation ========== /** diff --git a/packages/tm-core/src/utils/time.utils.ts b/packages/tm-core/src/utils/time.utils.ts index 146b9c51..2ed05737 100644 --- a/packages/tm-core/src/utils/time.utils.ts +++ b/packages/tm-core/src/utils/time.utils.ts @@ -1,9 +1,9 @@ /** - * @fileoverview Time utilities for formatting relative timestamps + * @fileoverview Time utilities for formatting timestamps * Shared across CLI, MCP, extension, and other interfaces */ -import { formatDistanceToNow } from 'date-fns'; +import { format, formatDistanceToNow } from 'date-fns'; /** * Format a date as relative time from now (e.g., "2 hours ago", "3 days ago") @@ -16,3 +16,12 @@ export function formatRelativeTime(date: string | Date): string { // Use date-fns for robust formatting with proper edge case handling return formatDistanceToNow(dateObj, { addSuffix: true }); } + +/** + * Format a date as a time string (e.g., "02:30:45 PM") + * @param date - Date object to format + * @returns Formatted time string in 12-hour format with seconds + */ +export function formatTime(date: Date): string { + return format(date, 'hh:mm:ss a'); +}