From 6c3a92c439d4573ff5046e3d251a4a26d85d0deb Mon Sep 17 00:00:00 2001 From: Ben Coombs Date: Wed, 14 Jan 2026 21:59:19 +0000 Subject: [PATCH] feat(list): Add --ready and --blocking filters to identify parallelizable tasks (#1533) Co-authored-by: Ben Coombs Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> - fixes #1532 --- .changeset/list-blocks-ready-filter.md | 16 + apps/cli/src/commands/list.command.spec.ts | 959 ++++++++++++++++++ apps/cli/src/commands/list.command.ts | 345 +++++-- apps/cli/src/commands/loop.command.spec.ts | 4 +- apps/cli/src/commands/tags.command.ts | 18 +- apps/cli/src/ui/display/tables.ts | 140 ++- .../integration/commands/loop.command.test.ts | 69 +- .../tests/unit/commands/list.command.spec.ts | 195 ---- .../tests/unit/commands/show.command.spec.ts | 12 +- package-lock.json | 4 +- packages/tm-core/src/index.ts | 11 + .../tm-core/src/modules/tasks/utils/index.ts | 6 + .../src/modules/tasks/utils/task-filters.ts | 168 +++ .../modules/task-manager/tag-management.js | 64 +- .../unit/task-manager/tag-management.test.js | 177 ++++ 15 files changed, 1805 insertions(+), 383 deletions(-) create mode 100644 .changeset/list-blocks-ready-filter.md create mode 100644 apps/cli/src/commands/list.command.spec.ts delete mode 100644 apps/cli/tests/unit/commands/list.command.spec.ts create mode 100644 packages/tm-core/src/modules/tasks/utils/index.ts create mode 100644 packages/tm-core/src/modules/tasks/utils/task-filters.ts diff --git a/.changeset/list-blocks-ready-filter.md b/.changeset/list-blocks-ready-filter.md new file mode 100644 index 00000000..03f192d5 --- /dev/null +++ b/.changeset/list-blocks-ready-filter.md @@ -0,0 +1,16 @@ +--- +"task-master-ai": minor +--- + +Add --ready and --blocking filters to list command for identifying parallelizable tasks + +- Add `--ready` filter to show only tasks with satisfied dependencies (ready to work on) +- Add `--blocking` filter to show only tasks that block other tasks +- Combine `--ready --blocking` to find high-impact tasks (ready AND blocking others) +- Add "Blocks" column to task table showing which tasks depend on each task +- Blocks field included in JSON output for programmatic access +- Add "Ready" column to `tags` command showing count of ready tasks per tag +- Add `--ready` filter to `tags` command to show only tags with available work +- Excludes deferred/blocked tasks from ready count (only actionable statuses) +- Add `--all-tags` option to list ready tasks across all tags (use with `--ready`) +- Tag column shown as first column when using `--all-tags` for easy scanning diff --git a/apps/cli/src/commands/list.command.spec.ts b/apps/cli/src/commands/list.command.spec.ts new file mode 100644 index 00000000..7fc851ec --- /dev/null +++ b/apps/cli/src/commands/list.command.spec.ts @@ -0,0 +1,959 @@ +/** + * @fileoverview Unit tests for ListTasksCommand + */ + +import type { TmCore } from '@tm/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies - use importOriginal to preserve real implementations +// Only mock createTmCore since we inject a mock tmCore directly in tests +vi.mock('@tm/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createTmCore: vi.fn() + }; +}); + +vi.mock('../utils/project-root.js', () => ({ + getProjectRoot: vi.fn((path?: string) => path || '/test/project') +})); + +vi.mock('../utils/error-handler.js', () => ({ + displayError: vi.fn() +})); + +vi.mock('../utils/display-helpers.js', () => ({ + displayCommandHeader: vi.fn() +})); + +vi.mock('../ui/index.js', () => ({ + calculateDependencyStatistics: vi.fn(() => ({ total: 0, blocked: 0 })), + calculateSubtaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })), + calculateTaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })), + displayDashboards: vi.fn(), + displayRecommendedNextTask: vi.fn(), + displaySuggestedNextSteps: vi.fn(), + getPriorityBreakdown: vi.fn(() => ({})), + getTaskDescription: vi.fn(() => 'Test description') +})); + +vi.mock('../utils/ui.js', () => ({ + createTaskTable: vi.fn(() => 'Table output'), + displayWarning: vi.fn() +})); + +import { ListTasksCommand } from './list.command.js'; + +describe('ListTasksCommand', () => { + let consoleLogSpy: any; + let mockTmCore: Partial; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + mockTmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: [{ id: '1', title: 'Test Task', status: 'pending' }], + total: 1, + filtered: 1, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + } as any, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } as any + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleLogSpy.mockRestore(); + }); + + describe('JSON output format', () => { + it('should use JSON format when --json flag is set', async () => { + const command = new ListTasksCommand(); + + // Mock the tmCore initialization + (command as any).tmCore = mockTmCore; + + // Execute with --json flag + await (command as any).executeCommand({ + json: true, + format: 'text' // Should be overridden by --json + }); + + // Verify JSON output was called + expect(consoleLogSpy).toHaveBeenCalled(); + const output = consoleLogSpy.mock.calls[0][0]; + + // Should be valid JSON + expect(() => JSON.parse(output)).not.toThrow(); + + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty('tasks'); + expect(parsed).toHaveProperty('metadata'); + }); + + it('should override --format when --json is set', async () => { + const command = new ListTasksCommand(); + (command as any).tmCore = mockTmCore; + + await (command as any).executeCommand({ + json: true, + format: 'compact' // Should be overridden + }); + + // Should output JSON, not compact format + const output = consoleLogSpy.mock.calls[0][0]; + expect(() => JSON.parse(output)).not.toThrow(); + }); + + it('should use specified format when --json is not set', async () => { + const command = new ListTasksCommand(); + (command as any).tmCore = mockTmCore; + + await (command as any).executeCommand({ + format: 'compact' + }); + + // Should use compact format (not JSON) + const output = consoleLogSpy.mock.calls; + // In compact mode, output is not JSON + expect(output.length).toBeGreaterThan(0); + }); + + it('should default to text format when neither flag is set', async () => { + const command = new ListTasksCommand(); + (command as any).tmCore = mockTmCore; + + await (command as any).executeCommand({}); + + // Should use text format (not JSON) + // If any console.log was called, verify it's not JSON + if (consoleLogSpy.mock.calls.length > 0) { + const output = consoleLogSpy.mock.calls[0][0]; + // Text format output should not be parseable JSON + // or should be the table string we mocked + expect( + output === 'Table output' || + (() => { + try { + JSON.parse(output); + return false; + } catch { + return true; + } + })() + ).toBe(true); + } + }); + }); + + describe('format validation', () => { + it('should accept valid formats', () => { + const command = new ListTasksCommand(); + + expect((command as any).validateOptions({ format: 'text' })).toBe(true); + expect((command as any).validateOptions({ format: 'json' })).toBe(true); + expect((command as any).validateOptions({ format: 'compact' })).toBe( + true + ); + }); + + it('should reject invalid formats', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const command = new ListTasksCommand(); + + expect((command as any).validateOptions({ format: 'invalid' })).toBe( + false + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid format: invalid') + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('--ready filter', () => { + it('should filter to only tasks with all dependencies satisfied', async () => { + const command = new ListTasksCommand(); + + // Mock tasks where some have satisfied deps and some don't + const mockTasks = [ + { id: '1', title: 'Task 1', status: 'done', dependencies: [] }, + { id: '2', title: 'Task 2', status: 'pending', dependencies: ['1'] }, // deps satisfied (1 is done) + { id: '3', title: 'Task 3', status: 'pending', dependencies: ['2'] }, // deps NOT satisfied (2 is pending) + { id: '4', title: 'Task 4', status: 'pending', dependencies: [] } // no deps, ready + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 4, + filtered: 4, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + ready: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Should only include tasks 2 and 4 (ready to work on) + expect(parsed.tasks).toHaveLength(2); + expect(parsed.tasks.map((t: any) => t.id)).toEqual( + expect.arrayContaining(['2', '4']) + ); + expect(parsed.tasks.map((t: any) => t.id)).not.toContain('3'); + }); + + it('should exclude done/cancelled tasks from ready filter', async () => { + const command = new ListTasksCommand(); + + const mockTasks = [ + { id: '1', title: 'Task 1', status: 'done', dependencies: [] }, + { id: '2', title: 'Task 2', status: 'cancelled', dependencies: [] }, + { id: '3', title: 'Task 3', status: 'pending', dependencies: [] } + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 3, + filtered: 3, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + ready: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Should only include task 3 (pending with no deps) + expect(parsed.tasks).toHaveLength(1); + expect(parsed.tasks[0].id).toBe('3'); + }); + + it('should exclude deferred and blocked tasks from ready filter', async () => { + const command = new ListTasksCommand(); + + const mockTasks = [ + { id: '1', title: 'Task 1', status: 'pending', dependencies: [] }, + { id: '2', title: 'Task 2', status: 'deferred', dependencies: [] }, + { id: '3', title: 'Task 3', status: 'blocked', dependencies: [] }, + { id: '4', title: 'Task 4', status: 'in-progress', dependencies: [] }, + { id: '5', title: 'Task 5', status: 'review', dependencies: [] } + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 5, + filtered: 5, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + ready: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Should only include pending, in-progress, and review tasks + expect(parsed.tasks).toHaveLength(3); + const ids = parsed.tasks.map((t: any) => t.id); + expect(ids).toContain('1'); // pending + expect(ids).toContain('4'); // in-progress + expect(ids).toContain('5'); // review + expect(ids).not.toContain('2'); // deferred - excluded + expect(ids).not.toContain('3'); // blocked - excluded + }); + }); + + describe('--blocking filter', () => { + it('should filter to only tasks that block other tasks', async () => { + const command = new ListTasksCommand(); + + const mockTasks = [ + { id: '1', title: 'Task 1', status: 'pending', dependencies: [] }, // blocks 2, 3 + { id: '2', title: 'Task 2', status: 'pending', dependencies: ['1'] }, // blocks 4 + { id: '3', title: 'Task 3', status: 'pending', dependencies: ['1'] }, // blocks nothing + { id: '4', title: 'Task 4', status: 'pending', dependencies: ['2'] } // blocks nothing + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 4, + filtered: 4, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + blocking: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Should only include tasks 1 and 2 (they block other tasks) + expect(parsed.tasks).toHaveLength(2); + expect(parsed.tasks.map((t: any) => t.id)).toEqual( + expect.arrayContaining(['1', '2']) + ); + }); + }); + + describe('--ready --blocking combined filter', () => { + it('should show high-impact tasks (ready AND blocking)', async () => { + const command = new ListTasksCommand(); + + const mockTasks = [ + { id: '1', title: 'Task 1', status: 'done', dependencies: [] }, + { id: '2', title: 'Task 2', status: 'pending', dependencies: ['1'] }, // ready (1 done), blocks 3,4 + { id: '3', title: 'Task 3', status: 'pending', dependencies: ['2'] }, // not ready, blocks 5 + { id: '4', title: 'Task 4', status: 'pending', dependencies: ['2'] }, // not ready, blocks nothing + { id: '5', title: 'Task 5', status: 'pending', dependencies: ['3'] }, // not ready, blocks nothing + { id: '6', title: 'Task 6', status: 'pending', dependencies: [] } // ready, blocks nothing + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 6, + filtered: 6, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + ready: true, + blocking: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Should only include task 2 (ready AND blocks other tasks) + expect(parsed.tasks).toHaveLength(1); + expect(parsed.tasks[0].id).toBe('2'); + }); + }); + + describe('blocks field in output', () => { + it('should include blocks field showing which tasks depend on each task', async () => { + const command = new ListTasksCommand(); + + const mockTasks = [ + { id: '1', title: 'Task 1', status: 'pending', dependencies: [] }, + { id: '2', title: 'Task 2', status: 'pending', dependencies: ['1'] }, + { id: '3', title: 'Task 3', status: 'pending', dependencies: ['1'] }, + { + id: '4', + title: 'Task 4', + status: 'pending', + dependencies: ['2', '3'] + } + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 4, + filtered: 4, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Task 1 blocks tasks 2 and 3 + const task1 = parsed.tasks.find((t: any) => t.id === '1'); + expect(task1.blocks).toEqual(expect.arrayContaining(['2', '3'])); + + // Task 2 blocks task 4 + const task2 = parsed.tasks.find((t: any) => t.id === '2'); + expect(task2.blocks).toEqual(['4']); + + // Task 3 blocks task 4 + const task3 = parsed.tasks.find((t: any) => t.id === '3'); + expect(task3.blocks).toEqual(['4']); + + // Task 4 blocks nothing + const task4 = parsed.tasks.find((t: any) => t.id === '4'); + expect(task4.blocks).toEqual([]); + }); + }); + + describe('--ready filter edge cases', () => { + it('should treat cancelled dependencies as satisfied for --ready filter', async () => { + const command = new ListTasksCommand(); + + // Task 1 is cancelled, Task 2 depends on Task 1 + // Task 2 should be considered "ready" because cancelled = complete + const mockTasks = [ + { + id: '1', + title: 'Cancelled Task', + status: 'cancelled', + dependencies: [] + }, + { + id: '2', + title: 'Dependent Task', + status: 'pending', + dependencies: ['1'] + } + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 2, + filtered: 2, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + ready: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Task 2 should be ready because task 1 (cancelled) counts as complete + expect(parsed.tasks).toHaveLength(1); + expect(parsed.tasks[0].id).toBe('2'); + expect(parsed.tasks[0].status).toBe('pending'); + }); + + it('should apply status filter after ready filter', async () => { + const command = new ListTasksCommand(); + + // Multiple ready tasks with different statuses + const mockTasks = [ + { id: '1', title: 'Done', status: 'done', dependencies: [] }, + { + id: '2', + title: 'Pending ready', + status: 'pending', + dependencies: ['1'] + }, + { + id: '3', + title: 'In-progress ready', + status: 'in-progress', + dependencies: ['1'] + } + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 3, + filtered: 3, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + ready: true, + status: 'pending', + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // With --ready --status=pending, should only show task 2 + // Task 2 is ready (dep 1 is done) and pending + // Task 3 is ready but in-progress, not pending + expect(parsed.tasks).toHaveLength(1); + expect(parsed.tasks[0].id).toBe('2'); + expect(parsed.tasks[0].status).toBe('pending'); + }); + }); + + describe('buildBlocksMap validation', () => { + it('should warn about dependencies to non-existent tasks', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const command = new ListTasksCommand(); + + const mockTasks = [ + { + id: '1', + title: 'Task with bad dep', + status: 'pending', + dependencies: ['999'] + } + ]; + + (command as any).tmCore = { + tasks: { + list: vi.fn().mockResolvedValue({ + tasks: mockTasks, + total: 1, + filtered: 1, + storageType: 'json' + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + json: true + }); + + // Verify console.warn was called with warning about invalid dependency + expect(consoleWarnSpy).toHaveBeenCalled(); + + // Find the call that mentions invalid dependency references + const warnCalls = consoleWarnSpy.mock.calls.map((call) => call[0]); + const hasInvalidDepWarning = warnCalls.some( + (msg) => + typeof msg === 'string' && + msg.includes('invalid dependency reference') + ); + const hasSpecificTaskWarning = warnCalls.some( + (msg) => + typeof msg === 'string' && + msg.includes('Task 1') && + msg.includes('999') + ); + + expect(hasInvalidDepWarning).toBe(true); + expect(hasSpecificTaskWarning).toBe(true); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('--all-tags option', () => { + it('should fetch tasks from multiple tags and include tagName field', async () => { + const command = new ListTasksCommand(); + + // Mock tasks for different tags + const featureATasksResponse = { + tasks: [ + { + id: '1', + title: 'Feature A Task 1', + status: 'pending', + dependencies: [] + }, + { + id: '2', + title: 'Feature A Task 2', + status: 'done', + dependencies: [] + } + ], + total: 2, + filtered: 2, + storageType: 'json' + }; + + const featureBTasksResponse = { + tasks: [ + { + id: '1', + title: 'Feature B Task 1', + status: 'in-progress', + dependencies: [] + }, + { + id: '2', + title: 'Feature B Task 2', + status: 'pending', + dependencies: ['1'] + } + ], + total: 2, + filtered: 2, + storageType: 'json' + }; + + const listMock = vi + .fn() + .mockResolvedValueOnce(featureATasksResponse) + .mockResolvedValueOnce(featureBTasksResponse); + + (command as any).tmCore = { + tasks: { + list: listMock, + getTagsWithStats: vi.fn().mockResolvedValue({ + tags: [ + { name: 'feature-a', taskCount: 2 }, + { name: 'feature-b', taskCount: 2 } + ] + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + allTags: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Should include tasks from both tags + expect(parsed.tasks).toHaveLength(4); + + // Each task should have tagName field + const featureATasks = parsed.tasks.filter( + (t: any) => t.tagName === 'feature-a' + ); + const featureBTasks = parsed.tasks.filter( + (t: any) => t.tagName === 'feature-b' + ); + + expect(featureATasks).toHaveLength(2); + expect(featureBTasks).toHaveLength(2); + + // Verify metadata indicates all tags + expect(parsed.metadata.allTags).toBe(true); + expect(parsed.metadata.tag).toBe('all'); + expect(parsed.metadata.total).toBe(4); + }); + + it('should apply --ready filter per-tag when combined with --all-tags', async () => { + const command = new ListTasksCommand(); + + // Tag A: Task 1 is done, Task 2 depends on Task 1 (ready) + const tagATasksResponse = { + tasks: [ + { id: '1', title: 'Tag A Task 1', status: 'done', dependencies: [] }, + { + id: '2', + title: 'Tag A Task 2', + status: 'pending', + dependencies: ['1'] + } + ], + total: 2, + filtered: 2, + storageType: 'json' + }; + + // Tag B: Task 1 is pending (ready), Task 2 depends on Task 1 (not ready) + // Note: Task IDs can overlap between tags, but dependencies are tag-scoped + const tagBTasksResponse = { + tasks: [ + { + id: '1', + title: 'Tag B Task 1', + status: 'pending', + dependencies: [] + }, + { + id: '2', + title: 'Tag B Task 2', + status: 'pending', + dependencies: ['1'] + } + ], + total: 2, + filtered: 2, + storageType: 'json' + }; + + const listMock = vi + .fn() + .mockResolvedValueOnce(tagATasksResponse) + .mockResolvedValueOnce(tagBTasksResponse); + + (command as any).tmCore = { + tasks: { + list: listMock, + getTagsWithStats: vi.fn().mockResolvedValue({ + tags: [ + { name: 'tag-a', taskCount: 2 }, + { name: 'tag-b', taskCount: 2 } + ] + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + allTags: true, + ready: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Tag A: Task 2 is ready (Task 1 is done) + // Tag B: Task 1 is ready (no deps), Task 2 is NOT ready (Task 1 is pending) + expect(parsed.tasks).toHaveLength(2); + + const taskIds = parsed.tasks.map((t: any) => `${t.tagName}:${t.id}`); + expect(taskIds).toContain('tag-a:2'); // Ready: deps satisfied + expect(taskIds).toContain('tag-b:1'); // Ready: no deps + expect(taskIds).not.toContain('tag-b:2'); // Not ready: depends on pending task + }); + + it('should reject --all-tags combined with --watch', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const command = new ListTasksCommand(); + + const isValid = (command as any).validateOptions({ + allTags: true, + watch: true + }); + + expect(isValid).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('--all-tags cannot be used with --watch mode') + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should apply --blocking filter with --all-tags', async () => { + const command = new ListTasksCommand(); + + // Tag A: Task 1 blocks Task 2 + const tagATasksResponse = { + tasks: [ + { + id: '1', + title: 'Tag A Task 1', + status: 'pending', + dependencies: [] + }, + { + id: '2', + title: 'Tag A Task 2', + status: 'pending', + dependencies: ['1'] + } + ], + total: 2, + filtered: 2, + storageType: 'json' + }; + + // Tag B: Task 1 blocks nothing (no other tasks depend on it) + const tagBTasksResponse = { + tasks: [ + { + id: '1', + title: 'Tag B Task 1', + status: 'pending', + dependencies: [] + } + ], + total: 1, + filtered: 1, + storageType: 'json' + }; + + const listMock = vi + .fn() + .mockResolvedValueOnce(tagATasksResponse) + .mockResolvedValueOnce(tagBTasksResponse); + + (command as any).tmCore = { + tasks: { + list: listMock, + getTagsWithStats: vi.fn().mockResolvedValue({ + tags: [ + { name: 'tag-a', taskCount: 2 }, + { name: 'tag-b', taskCount: 1 } + ] + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + allTags: true, + blocking: true, + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Only Tag A Task 1 blocks other tasks + expect(parsed.tasks).toHaveLength(1); + expect(parsed.tasks[0].tagName).toBe('tag-a'); + expect(parsed.tasks[0].id).toBe('1'); + expect(parsed.tasks[0].blocks).toContain('2'); + }); + + it('should apply status filter with --all-tags', async () => { + const command = new ListTasksCommand(); + + const tagATasksResponse = { + tasks: [ + { + id: '1', + title: 'Tag A Task 1', + status: 'pending', + dependencies: [] + }, + { id: '2', title: 'Tag A Task 2', status: 'done', dependencies: [] } + ], + total: 2, + filtered: 2, + storageType: 'json' + }; + + const tagBTasksResponse = { + tasks: [ + { + id: '1', + title: 'Tag B Task 1', + status: 'in-progress', + dependencies: [] + }, + { + id: '2', + title: 'Tag B Task 2', + status: 'pending', + dependencies: [] + } + ], + total: 2, + filtered: 2, + storageType: 'json' + }; + + const listMock = vi + .fn() + .mockResolvedValueOnce(tagATasksResponse) + .mockResolvedValueOnce(tagBTasksResponse); + + (command as any).tmCore = { + tasks: { + list: listMock, + getTagsWithStats: vi.fn().mockResolvedValue({ + tags: [ + { name: 'tag-a', taskCount: 2 }, + { name: 'tag-b', taskCount: 2 } + ] + }), + getStorageType: vi.fn().mockReturnValue('json') + }, + config: { + getActiveTag: vi.fn().mockReturnValue('master') + } + }; + + await (command as any).executeCommand({ + allTags: true, + status: 'pending', + json: true + }); + + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + // Only pending tasks should be included + expect(parsed.tasks).toHaveLength(2); + expect(parsed.tasks.every((t: any) => t.status === 'pending')).toBe(true); + + const taskIds = parsed.tasks.map((t: any) => `${t.tagName}:${t.id}`); + expect(taskIds).toContain('tag-a:1'); + expect(taskIds).toContain('tag-b:2'); + }); + }); +}); diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index 3f073908..8d2ada00 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -5,14 +5,20 @@ import { OUTPUT_FORMATS, + buildBlocksMap, + createTmCore, + filterBlockingTasks, + filterReadyTasks, + isTaskComplete, + type InvalidDependency, type OutputFormat, STATUS_ICONS, TASK_STATUSES, type Task, type TaskStatus, + type TaskWithBlocks, type TmCore, - type WatchSubscription, - createTmCore + type WatchSubscription } from '@tm/core'; import type { StorageType } from '@tm/core'; import chalk from 'chalk'; @@ -33,7 +39,6 @@ import { import { displayCommandHeader } from '../utils/display-helpers.js'; import { displayError } from '../utils/error-handler.js'; import { getProjectRoot } from '../utils/project-root.js'; -import { isTaskComplete } from '../utils/task-status.js'; import * as ui from '../utils/ui.js'; /** @@ -50,17 +55,26 @@ export interface ListCommandOptions { silent?: boolean; project?: string; watch?: boolean; + ready?: boolean; + blocking?: boolean; + allTags?: boolean; } +/** + * Task with tag info for cross-tag listing + */ +export type TaskWithTag = TaskWithBlocks & { tagName: string }; + /** * Result type from list command */ export interface ListTasksResult { - tasks: Task[]; + tasks: TaskWithBlocks[] | TaskWithTag[]; total: number; filtered: number; tag?: string; storageType: Exclude; + allTags?: boolean; } /** @@ -104,6 +118,15 @@ export class ListTasksCommand extends Command { 'Project root directory (auto-detected if not provided)' ) .option('-w, --watch', 'Watch for changes and update list automatically') + .option( + '--ready', + 'Show only tasks ready to work on (dependencies satisfied)' + ) + .option('--blocking', 'Show only tasks that block other tasks') + .option( + '--all-tags', + 'Show tasks from all tags (combine with --ready for actionable tasks)' + ) .action(async (statusArg?: string, options?: ListCommandOptions) => { // Handle special "all" keyword to show with subtasks let status = statusArg || options?.status; @@ -142,7 +165,10 @@ export class ListTasksCommand extends Command { if (options.watch) { await this.watchTasks(options); } else { - const result = await this.getTasks(options); + // Use cross-tag listing when --all-tags is specified + const result = options.allTags + ? await this.getTasksFromAllTags(options) + : await this.getTasks(options); // Store result for programmatic access this.setLastResult(result); @@ -195,8 +221,15 @@ export class ListTasksCommand extends Command { // Show sync message with timestamp displaySyncMessage(storageType, lastSync); displayWatchFooter(storageType, lastSync); - } catch { - // Ignore errors during watch (e.g. partial writes) + } catch (refreshError: unknown) { + // Log warning but continue watching - don't crash on transient errors + const message = + refreshError instanceof Error + ? refreshError.message + : String(refreshError); + console.error( + chalk.yellow(`\nWarning: Failed to refresh tasks: ${message}`) + ); } } else if (event.type === 'error' && event.error) { console.error(chalk.red(`\n⚠ Watch error: ${event.error.message}`)); @@ -250,6 +283,17 @@ export class ListTasksCommand extends Command { } } + // Validate --all-tags cannot be used with --watch + if (options.allTags && options.watch) { + console.error(chalk.red('--all-tags cannot be used with --watch mode')); + console.error( + chalk.gray( + 'Use --all-tags without --watch, or --watch without --all-tags' + ) + ); + return false; + } + return true; } @@ -272,14 +316,20 @@ export class ListTasksCommand extends Command { throw new Error('TmCore not initialized'); } - // Build filter - const filter = + // Parse status filter values + const statusFilterValues = options.status && options.status !== 'all' - ? { - status: options.status - .split(',') - .map((s: string) => s.trim() as TaskStatus) - } + ? options.status.split(',').map((s: string) => s.trim() as TaskStatus) + : undefined; + + // When --ready is used, we need ALL tasks to correctly compute which dependencies are satisfied + // So we fetch without status filter first, then apply status filter after ready/blocking + const needsAllTasks = options.ready || options.blocking; + + // Build filter - skip status filter if we need all tasks for ready/blocking computation + const filter = + statusFilterValues && !needsAllTasks + ? { status: statusFilterValues } : undefined; // Call tm-core @@ -289,7 +339,174 @@ export class ListTasksCommand extends Command { includeSubtasks: options.withSubtasks }); - return result as ListTasksResult; + // Build blocks map and enrich tasks with blocks field + const { blocksMap, invalidDependencies } = buildBlocksMap(result.tasks); + this.displayInvalidDependencyWarnings(invalidDependencies); + const enrichedTasks = result.tasks.map((task) => ({ + ...task, + blocks: blocksMap.get(String(task.id)) || [] + })); + + // Apply ready/blocking filters (with full task context) + let filteredTasks = enrichedTasks; + + if (options.ready) { + filteredTasks = filterReadyTasks(filteredTasks); + } + + if (options.blocking) { + filteredTasks = filterBlockingTasks(filteredTasks); + } + + // Apply status filter AFTER ready/blocking if we deferred it earlier + if (statusFilterValues && needsAllTasks) { + filteredTasks = filteredTasks.filter((task) => + statusFilterValues.includes(task.status) + ); + } + + return { + ...result, + tasks: filteredTasks, + filtered: filteredTasks.length + } as ListTasksResult; + } + + /** + * Get ready tasks from all tags + * Fetches tasks from each tag and combines them with tag info + */ + private async getTasksFromAllTags( + options: ListCommandOptions + ): Promise { + if (!this.tmCore) { + throw new Error('TmCore not initialized'); + } + + // Get all tags + const tagsResult = await this.tmCore.tasks.getTagsWithStats(); + const allTaggedTasks: TaskWithTag[] = []; + let totalTaskCount = 0; + const failedTags: Array<{ name: string; error: string }> = []; + + // Fetch tasks from each tag + for (const tagInfo of tagsResult.tags) { + const tagName = tagInfo.name; + + try { + // Get tasks for this tag + const result = await this.tmCore.tasks.list({ + tag: tagName, + includeSubtasks: options.withSubtasks + }); + + // Track total count before any filtering (consistent with getTasks) + totalTaskCount += result.tasks.length; + + // Build blocks map for this tag's tasks + const { blocksMap, invalidDependencies } = buildBlocksMap(result.tasks); + this.displayInvalidDependencyWarnings(invalidDependencies, tagName); + + // Enrich tasks with blocks field and tag name + const enrichedTasks: TaskWithTag[] = result.tasks.map((task) => ({ + ...task, + blocks: blocksMap.get(String(task.id)) || [], + tagName + })); + + // Apply ready filter per-tag to respect tag-scoped dependencies + // (task IDs may overlap between tags, so we must filter within each tag) + const tasksToAdd: TaskWithTag[] = options.ready + ? (filterReadyTasks(enrichedTasks) as TaskWithTag[]) + : enrichedTasks; + + allTaggedTasks.push(...tasksToAdd); + } catch (tagError: unknown) { + const errorMessage = + tagError instanceof Error ? tagError.message : String(tagError); + failedTags.push({ name: tagName, error: errorMessage }); + continue; // Skip this tag but continue with others + } + } + + // Warn about failed tags + if (failedTags.length > 0) { + console.warn( + chalk.yellow( + `\nWarning: Could not fetch tasks from ${failedTags.length} tag(s):` + ) + ); + failedTags.forEach(({ name, error }) => { + console.warn(chalk.gray(` ${name}: ${error}`)); + }); + } + + // If ALL tags failed, throw to surface the issue + if ( + failedTags.length === tagsResult.tags.length && + tagsResult.tags.length > 0 + ) { + throw new Error( + `Failed to fetch tasks from any tag. First error: ${failedTags[0].error}` + ); + } + + // Apply additional filters + let filteredTasks: TaskWithTag[] = allTaggedTasks; + + // Apply blocking filter if specified + if (options.blocking) { + filteredTasks = filteredTasks.filter((task) => task.blocks.length > 0); + } + + // Apply status filter if specified + if (options.status && options.status !== 'all') { + const statusValues = options.status + .split(',') + .map((s) => s.trim() as TaskStatus); + filteredTasks = filteredTasks.filter((task) => + statusValues.includes(task.status) + ); + } + + return { + tasks: filteredTasks, + total: totalTaskCount, + filtered: filteredTasks.length, + storageType: this.tmCore.tasks.getStorageType(), + allTags: true + }; + } + + /** + * Display warnings for invalid dependency references + * @param invalidDependencies - Array of invalid dependency references from buildBlocksMap + * @param tagName - Optional tag name for context in multi-tag mode + */ + private displayInvalidDependencyWarnings( + invalidDependencies: InvalidDependency[], + tagName?: string + ): void { + if (invalidDependencies.length === 0) { + return; + } + + const tagContext = tagName ? ` (tag: ${tagName})` : ''; + console.warn( + chalk.yellow( + `\nWarning: ${invalidDependencies.length} invalid dependency reference(s) found${tagContext}:` + ) + ); + invalidDependencies.slice(0, 5).forEach(({ taskId, depId }) => { + console.warn( + chalk.gray(` Task ${taskId} depends on non-existent task ${depId}`) + ); + }); + if (invalidDependencies.length > 5) { + console.warn( + chalk.gray(` ...and ${invalidDependencies.length - 5} more`) + ); + } } /** @@ -300,13 +517,12 @@ export class ListTasksCommand extends Command { options: ListCommandOptions ): void { // Resolve format: --json and --compact flags override --format option - const format = ( - options.json - ? 'json' - : options.compact - ? 'compact' - : options.format || 'text' - ) as OutputFormat; + let format: OutputFormat = options.format || 'text'; + if (options.json) { + format = 'json'; + } else if (options.compact) { + format = 'compact'; + } switch (format) { case 'json': @@ -335,8 +551,9 @@ export class ListTasksCommand extends Command { metadata: { total: data.total, filtered: data.filtered, - tag: data.tag, - storageType: data.storageType + tag: data.allTags ? 'all' : data.tag, + storageType: data.storageType, + allTags: data.allTags || false } }, null, @@ -352,29 +569,32 @@ export class ListTasksCommand extends Command { data: ListTasksResult, options: ListCommandOptions ): void { - const { tasks, tag, storageType } = data; + const { tasks, tag, storageType, allTags } = data; // Display header unless --no-header is set if (options.noHeader !== true) { displayCommandHeader(this.tmCore, { - tag: tag || 'master', + tag: allTags ? 'all tags' : tag || 'master', storageType }); } - tasks.forEach((task) => { + for (const task of tasks) { const icon = STATUS_ICONS[task.status]; - console.log(`${chalk.cyan(task.id)} ${icon} ${task.title}`); + // Show tag in compact format when listing all tags (tasks are TaskWithTag[]) + const tagPrefix = + allTags && 'tagName' in task ? chalk.magenta(`[${task.tagName}] `) : ''; + console.log(`${tagPrefix}${chalk.cyan(task.id)} ${icon} ${task.title}`); if (options.withSubtasks && task.subtasks?.length) { - task.subtasks.forEach((subtask) => { + for (const subtask of task.subtasks) { const subIcon = STATUS_ICONS[subtask.status]; console.log( ` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}` ); - }); + } } - }); + } } /** @@ -384,12 +604,12 @@ export class ListTasksCommand extends Command { data: ListTasksResult, options: ListCommandOptions ): void { - const { tasks, tag, storageType } = data; + const { tasks, tag, storageType, allTags } = data; // Display header unless --no-header is set if (options.noHeader !== true) { displayCommandHeader(this.tmCore, { - tag: tag || 'master', + tag: allTags ? 'all tags' : tag || 'master', storageType }); } @@ -414,43 +634,52 @@ export class ListTasksCommand extends Command { ? tasks.find((t) => String(t.id) === String(nextTaskInfo.id)) : undefined; - // Display dashboard boxes (nextTask already has complexity from storage enrichment) - displayDashboards( - taskStats, - subtaskStats, - priorityBreakdown, - depStats, - nextTask - ); + // Display dashboard boxes unless filtering by --ready, --blocking, or --all-tags + // (filtered/cross-tag dashboards would show misleading statistics) + const isFiltered = options.ready || options.blocking || allTags; + if (!isFiltered) { + displayDashboards( + taskStats, + subtaskStats, + priorityBreakdown, + depStats, + nextTask + ); + } // Task table console.log( ui.createTaskTable(tasks, { showSubtasks: options.withSubtasks, showDependencies: true, - showComplexity: true // Enable complexity column + showBlocks: true, // Show which tasks this one blocks + showComplexity: true, // Enable complexity column + showTag: allTags // Show tag column for cross-tag listing }) ); // Display recommended next task section immediately after table - // Don't show "no tasks available" message in list command - that's for tm next - if (nextTask) { - const description = getTaskDescription(nextTask); + // Skip when filtering by --ready or --blocking (user already knows what they're looking at) + if (!isFiltered) { + // Don't show "no tasks available" message in list command - that's for tm next + if (nextTask) { + const description = getTaskDescription(nextTask); - displayRecommendedNextTask({ - id: nextTask.id, - title: nextTask.title, - priority: nextTask.priority, - status: nextTask.status, - dependencies: nextTask.dependencies, - description, - complexity: nextTask.complexity as number | undefined - }); + displayRecommendedNextTask({ + id: nextTask.id, + title: nextTask.title, + priority: nextTask.priority, + status: nextTask.status, + dependencies: nextTask.dependencies, + description, + complexity: nextTask.complexity as number | undefined + }); + } + // If no next task, don't show any message - dashboard already shows the info + + // Display suggested next steps at the end + displaySuggestedNextSteps(); } - // If no next task, don't show any message - dashboard already shows the info - - // Display suggested next steps at the end - displaySuggestedNextSteps(); } /** diff --git a/apps/cli/src/commands/loop.command.spec.ts b/apps/cli/src/commands/loop.command.spec.ts index 43902e63..5c97a266 100644 --- a/apps/cli/src/commands/loop.command.spec.ts +++ b/apps/cli/src/commands/loop.command.spec.ts @@ -394,7 +394,7 @@ describe('LoopCommand', () => { mockLoopRun.mockResolvedValue(result); const execute = (loopCommand as any).execute.bind(loopCommand); - await execute({}); + await execute({ sandbox: true }); expect(mockTmCore.loop.checkSandboxAuth).toHaveBeenCalled(); }); @@ -405,7 +405,7 @@ describe('LoopCommand', () => { mockLoopRun.mockResolvedValue(result); const execute = (loopCommand as any).execute.bind(loopCommand); - await execute({}); + await execute({ sandbox: true }); expect(mockTmCore.loop.runInteractiveAuth).toHaveBeenCalled(); }); diff --git a/apps/cli/src/commands/tags.command.ts b/apps/cli/src/commands/tags.command.ts index 0cc3a7f8..b778bb2e 100644 --- a/apps/cli/src/commands/tags.command.ts +++ b/apps/cli/src/commands/tags.command.ts @@ -77,8 +77,10 @@ export class TagsCommand extends Command { constructor(name?: string) { super(name || 'tags'); - // Configure the command - this.description('Manage tags for task organization'); + // Configure the command with options that apply to default list action + this.description('Manage tags for task organization') + .option('--show-metadata', 'Show additional tag metadata') + .option('--ready', 'Show only tags with ready tasks available'); // Add subcommands this.addListCommand(); @@ -88,9 +90,9 @@ export class TagsCommand extends Command { this.addRenameCommand(); this.addCopyCommand(); - // Default action: list tags - this.action(async () => { - await this.executeList(); + // Default action: list tags (with options from parent command) + this.action(async (options) => { + await this.executeList(options); }); } @@ -101,6 +103,7 @@ export class TagsCommand extends Command { this.command('list') .description('List all tags with statistics (default action)') .option('--show-metadata', 'Show additional tag metadata') + .option('--ready', 'Show only tags with ready tasks available') .addHelpText( 'after', ` @@ -108,6 +111,7 @@ Examples: $ tm tags # List all tags (default) $ tm tags list # List all tags (explicit) $ tm tags list --show-metadata # List with metadata + $ tm tags list --ready # Show only tags with parallelizable work ` ) .action(async (options) => { @@ -245,6 +249,7 @@ Examples: */ private async executeList(options?: { showMetadata?: boolean; + ready?: boolean; }): Promise { try { // Initialize tmCore first (needed by bridge functions) @@ -257,7 +262,8 @@ Examples: tasksPath, { showTaskCounts: true, - showMetadata: options?.showMetadata || false + showMetadata: options?.showMetadata || false, + ready: options?.ready || false }, { projectRoot }, 'text' diff --git a/apps/cli/src/ui/display/tables.ts b/apps/cli/src/ui/display/tables.ts index 982232a4..ed812c23 100644 --- a/apps/cli/src/ui/display/tables.ts +++ b/apps/cli/src/ui/display/tables.ts @@ -6,6 +6,7 @@ import type { Subtask, Task, TaskPriority } from '@tm/core'; import chalk from 'chalk'; import Table from 'cli-table3'; +import { isTaskComplete } from '../../utils/task-status.js'; import { getComplexityWithColor } from '../formatters/complexity-formatters.js'; import { getPriorityWithColor } from '../formatters/priority-formatters.js'; import { getStatusWithColor } from '../formatters/status-formatters.js'; @@ -16,59 +17,94 @@ import { getBoxWidth, truncate } from '../layout/helpers.js'; */ const DEFAULT_PRIORITY: TaskPriority = 'medium'; +/** + * Task-like object that can optionally have blocks field and tag name + * Used for table display - accepts both enriched TaskWithBlocks and regular Task/Subtask + */ +export type TaskTableItem = (Task | Subtask) & { + blocks?: string[]; + tagName?: string; +}; + +/** + * Column width ratios indexed by number of optional columns + * Each array contains ratios for: [Tag?, ID, Title, Status, Priority, Dependencies?, Blocks?, Complexity?] + */ +const COLUMN_WIDTH_RATIOS: Record = { + 0: [0.1, 0.5, 0.2, 0.2], + 1: [0.08, 0.4, 0.18, 0.14, 0.2], + 2: [0.08, 0.35, 0.14, 0.11, 0.16, 0.16], + 3: [0.07, 0.3, 0.12, 0.1, 0.14, 0.14, 0.1], + 4: [0.12, 0.06, 0.2, 0.1, 0.1, 0.12, 0.12, 0.1] +}; + /** * Create a task table for display */ export function createTaskTable( - tasks: (Task | Subtask)[], + tasks: TaskTableItem[], options?: { showSubtasks?: boolean; showComplexity?: boolean; showDependencies?: boolean; + showBlocks?: boolean; + showTag?: boolean; } ): string { const { showSubtasks = false, showComplexity = false, - showDependencies = true + showDependencies = true, + showBlocks = false, + showTag = false } = options || {}; // Calculate dynamic column widths based on terminal width const tableWidth = getBoxWidth(0.9, 100); - // Adjust column widths to better match the original layout - const baseColWidths = showComplexity - ? [ - Math.floor(tableWidth * 0.1), - Math.floor(tableWidth * 0.4), - Math.floor(tableWidth * 0.15), - Math.floor(tableWidth * 0.1), - Math.floor(tableWidth * 0.2), - Math.floor(tableWidth * 0.1) - ] // ID, Title, Status, Priority, Dependencies, Complexity - : [ - Math.floor(tableWidth * 0.08), - Math.floor(tableWidth * 0.4), - Math.floor(tableWidth * 0.18), - Math.floor(tableWidth * 0.12), - Math.floor(tableWidth * 0.2) - ]; // ID, Title, Status, Priority, Dependencies - const headers = [ + // Count optional columns and get corresponding width ratios + const optionalCols = + (showTag ? 1 : 0) + + (showDependencies ? 1 : 0) + + (showBlocks ? 1 : 0) + + (showComplexity ? 1 : 0); + + const ratios = COLUMN_WIDTH_RATIOS[optionalCols] || COLUMN_WIDTH_RATIOS[4]; + const baseColWidths = ratios.map((ratio) => Math.floor(tableWidth * ratio)); + + // Build headers and column widths dynamically + const headers: string[] = []; + const colWidths: number[] = []; + let colIndex = 0; + + if (showTag) { + headers.push(chalk.blue.bold('Tag')); + colWidths.push(baseColWidths[colIndex++]); + } + + // Core columns: ID, Title, Status, Priority + headers.push( chalk.blue.bold('ID'), chalk.blue.bold('Title'), chalk.blue.bold('Status'), chalk.blue.bold('Priority') - ]; - const colWidths = baseColWidths.slice(0, 4); + ); + colWidths.push(...baseColWidths.slice(colIndex, colIndex + 4)); + colIndex += 4; if (showDependencies) { headers.push(chalk.blue.bold('Dependencies')); - colWidths.push(baseColWidths[4]); + colWidths.push(baseColWidths[colIndex++]); + } + + if (showBlocks) { + headers.push(chalk.blue.bold('Blocks')); + colWidths.push(baseColWidths[colIndex++]); } if (showComplexity) { headers.push(chalk.blue.bold('Complexity')); - colWidths.push(baseColWidths[5] || 12); + colWidths.push(baseColWidths[colIndex] || 12); } const table = new Table({ @@ -79,17 +115,27 @@ export function createTaskTable( }); tasks.forEach((task) => { - const row: string[] = [ + const row: string[] = []; + + // Tag goes first when showing all tags + if (showTag) { + row.push(chalk.magenta(task.tagName || '-')); + } + + // Core columns: ID, Title, Status, Priority + // Title column index depends on whether tag is shown + const titleColIndex = showTag ? 2 : 1; + row.push( chalk.cyan(task.id.toString()), - truncate(task.title, colWidths[1] - 3), + truncate(task.title, colWidths[titleColIndex] - 3), getStatusWithColor(task.status, true), // Use table version getPriorityWithColor(task.priority) - ]; + ); if (showDependencies) { // For table display, show simple format without status icons if (!task.dependencies || task.dependencies.length === 0) { - row.push(chalk.gray('None')); + row.push(chalk.gray('-')); } else { row.push( chalk.cyan(task.dependencies.map((d) => String(d)).join(', ')) @@ -97,6 +143,21 @@ export function createTaskTable( } } + if (showBlocks) { + // Show tasks that depend on this one + if (!task.blocks || task.blocks.length === 0) { + row.push(chalk.gray('-')); + } else { + // Gray out blocks for completed tasks (no longer blocking) + const blocksText = task.blocks.join(', '); + row.push( + isTaskComplete(task.status) + ? chalk.gray(blocksText) + : chalk.yellow(blocksText) + ); + } + } + if (showComplexity) { // Show complexity score from report if available if (typeof task.complexity === 'number') { @@ -111,23 +172,38 @@ export function createTaskTable( // Add subtasks if requested if (showSubtasks && task.subtasks && task.subtasks.length > 0) { task.subtasks.forEach((subtask) => { - const subRow: string[] = [ + const subRow: string[] = []; + + // Tag goes first when showing all tags + if (showTag) { + // Subtasks inherit parent's tag, just show dash + subRow.push(chalk.gray('-')); + } + + // Core subtask columns: ID, Title, Status, Priority + const subTitleColIndex = showTag ? 2 : 1; + subRow.push( chalk.gray(` └─ ${subtask.id}`), - chalk.gray(truncate(subtask.title, colWidths[1] - 6)), + chalk.gray(truncate(subtask.title, colWidths[subTitleColIndex] - 6)), chalk.gray(getStatusWithColor(subtask.status, true)), chalk.gray(subtask.priority || DEFAULT_PRIORITY) - ]; + ); if (showDependencies) { subRow.push( chalk.gray( subtask.dependencies && subtask.dependencies.length > 0 ? subtask.dependencies.map((dep) => String(dep)).join(', ') - : 'None' + : '-' ) ); } + if (showBlocks) { + // Subtasks don't typically have blocks, show dash + subRow.push(chalk.gray('-')); + } + if (showComplexity) { const complexityDisplay = typeof subtask.complexity === 'number' diff --git a/apps/cli/tests/integration/commands/loop.command.test.ts b/apps/cli/tests/integration/commands/loop.command.test.ts index 3cda8d9c..99d712a7 100644 --- a/apps/cli/tests/integration/commands/loop.command.test.ts +++ b/apps/cli/tests/integration/commands/loop.command.test.ts @@ -14,9 +14,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createTask, createTasksFile } from '@tm/core/testing'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getCliBinPath } from '../../helpers/test-utils.js'; +// Increase hook timeout for this file - init command can be slow in CI +vi.setConfig({ hookTimeout: 30000 }); + // Capture initial working directory at module load time const initialCwd = process.cwd(); @@ -144,13 +147,6 @@ describe('loop command', () => { expect(output).toContain('--tag'); }); - it('should show --json option in help', () => { - const { output, exitCode } = runHelp(); - - expect(exitCode).toBe(0); - expect(output).toContain('--json'); - }); - it('should show --progress-file option in help', () => { const { output, exitCode } = runHelp(); @@ -191,44 +187,6 @@ describe('loop command', () => { }); }); - describe('option parsing', () => { - it('should accept valid iterations', () => { - // Command will fail when trying to run claude, but validation should pass - const { output } = runLoop('-n 5'); - - // Should NOT contain validation error for iterations - expect(output.toLowerCase()).not.toContain('invalid iterations'); - }); - - it('should accept custom prompt preset', () => { - const { output } = runLoop('-p test-coverage'); - - // Should NOT contain validation error for prompt - expect(output.toLowerCase()).not.toContain('invalid prompt'); - }); - - it('should accept tag filter', () => { - const { output } = runLoop('-t feature'); - - // Should NOT contain validation error for tag - expect(output.toLowerCase()).not.toContain('invalid tag'); - }); - - it('should accept progress-file option', () => { - const { output } = runLoop('--progress-file /tmp/test-progress.txt'); - - // Should NOT contain validation error for progress-file - expect(output.toLowerCase()).not.toContain('invalid progress'); - }); - - it('should accept multiple options together', () => { - const { output } = runLoop('-n 3 -p default -t test'); - - // Should NOT contain validation errors - expect(output.toLowerCase()).not.toContain('invalid iterations'); - }); - }); - describe('error messages', () => { it('should show helpful error for invalid iterations', () => { const { output, exitCode } = runLoop('-n invalid'); @@ -239,23 +197,4 @@ describe('loop command', () => { expect(output.toLowerCase()).toContain('positive'); }); }); - - describe('project detection', () => { - it('should work in initialized project directory', () => { - // The project is already initialized in beforeEach - // Command will fail when trying to run claude, but project detection should work - const { output } = runLoop('-n 1'); - - // Should NOT contain "not a task-master project" or similar - expect(output.toLowerCase()).not.toContain('not initialized'); - expect(output.toLowerCase()).not.toContain('no project'); - }); - - it('should accept --project option for explicit path', () => { - const { output } = runLoop(`--project "${testDir}" -n 1`); - - // Should NOT contain validation error for project path - expect(output.toLowerCase()).not.toContain('invalid project'); - }); - }); }); diff --git a/apps/cli/tests/unit/commands/list.command.spec.ts b/apps/cli/tests/unit/commands/list.command.spec.ts deleted file mode 100644 index 5aa47efe..00000000 --- a/apps/cli/tests/unit/commands/list.command.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @fileoverview Unit tests for ListTasksCommand - */ - -import type { TmCore } from '@tm/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// Mock dependencies -vi.mock('@tm/core', () => ({ - createTmCore: vi.fn(), - OUTPUT_FORMATS: ['text', 'json', 'compact'], - TASK_STATUSES: [ - 'pending', - 'in-progress', - 'done', - 'review', - 'deferred', - 'cancelled' - ], - STATUS_ICONS: { - pending: '⏳', - 'in-progress': 'πŸ”„', - done: 'βœ…', - review: 'πŸ‘€', - deferred: '⏸️', - cancelled: '❌' - } -})); - -vi.mock('../../../src/utils/project-root.js', () => ({ - getProjectRoot: vi.fn((path?: string) => path || '/test/project') -})); - -vi.mock('../../../src/utils/error-handler.js', () => ({ - displayError: vi.fn() -})); - -vi.mock('../../../src/utils/display-helpers.js', () => ({ - displayCommandHeader: vi.fn() -})); - -vi.mock('../../../src/ui/index.js', () => ({ - calculateDependencyStatistics: vi.fn(() => ({ total: 0, blocked: 0 })), - calculateSubtaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })), - calculateTaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })), - displayDashboards: vi.fn(), - displayRecommendedNextTask: vi.fn(), - displaySuggestedNextSteps: vi.fn(), - getPriorityBreakdown: vi.fn(() => ({})), - getTaskDescription: vi.fn(() => 'Test description') -})); - -vi.mock('../../../src/utils/ui.js', () => ({ - createTaskTable: vi.fn(() => 'Table output'), - displayWarning: vi.fn() -})); - -import { ListTasksCommand } from '../../../src/commands/list.command.js'; - -describe('ListTasksCommand', () => { - let consoleLogSpy: any; - let mockTmCore: Partial; - - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - mockTmCore = { - tasks: { - list: vi.fn().mockResolvedValue({ - tasks: [{ id: '1', title: 'Test Task', status: 'pending' }], - total: 1, - filtered: 1, - storageType: 'json' - }), - getStorageType: vi.fn().mockReturnValue('json') - } as any, - config: { - getActiveTag: vi.fn().mockReturnValue('master') - } as any - }; - }); - - afterEach(() => { - vi.clearAllMocks(); - consoleLogSpy.mockRestore(); - }); - - describe('JSON output format', () => { - it('should use JSON format when --json flag is set', async () => { - const command = new ListTasksCommand(); - - // Mock the tmCore initialization - (command as any).tmCore = mockTmCore; - - // Execute with --json flag - await (command as any).executeCommand({ - json: true, - format: 'text' // Should be overridden by --json - }); - - // Verify JSON output was called - expect(consoleLogSpy).toHaveBeenCalled(); - const output = consoleLogSpy.mock.calls[0][0]; - - // Should be valid JSON - expect(() => JSON.parse(output)).not.toThrow(); - - const parsed = JSON.parse(output); - expect(parsed).toHaveProperty('tasks'); - expect(parsed).toHaveProperty('metadata'); - }); - - it('should override --format when --json is set', async () => { - const command = new ListTasksCommand(); - (command as any).tmCore = mockTmCore; - - await (command as any).executeCommand({ - json: true, - format: 'compact' // Should be overridden - }); - - // Should output JSON, not compact format - const output = consoleLogSpy.mock.calls[0][0]; - expect(() => JSON.parse(output)).not.toThrow(); - }); - - it('should use specified format when --json is not set', async () => { - const command = new ListTasksCommand(); - (command as any).tmCore = mockTmCore; - - await (command as any).executeCommand({ - format: 'compact' - }); - - // Should use compact format (not JSON) - const output = consoleLogSpy.mock.calls; - // In compact mode, output is not JSON - expect(output.length).toBeGreaterThan(0); - }); - - it('should default to text format when neither flag is set', async () => { - const command = new ListTasksCommand(); - (command as any).tmCore = mockTmCore; - - await (command as any).executeCommand({}); - - // Should use text format (not JSON) - // If any console.log was called, verify it's not JSON - if (consoleLogSpy.mock.calls.length > 0) { - const output = consoleLogSpy.mock.calls[0][0]; - // Text format output should not be parseable JSON - // or should be the table string we mocked - expect( - output === 'Table output' || - (() => { - try { - JSON.parse(output); - return false; - } catch { - return true; - } - })() - ).toBe(true); - } - }); - }); - - describe('format validation', () => { - it('should accept valid formats', () => { - const command = new ListTasksCommand(); - - expect((command as any).validateOptions({ format: 'text' })).toBe(true); - expect((command as any).validateOptions({ format: 'json' })).toBe(true); - expect((command as any).validateOptions({ format: 'compact' })).toBe( - true - ); - }); - - it('should reject invalid formats', () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const command = new ListTasksCommand(); - - expect((command as any).validateOptions({ format: 'invalid' })).toBe( - false - ); - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid format: invalid') - ); - - consoleErrorSpy.mockRestore(); - }); - }); -}); diff --git a/apps/cli/tests/unit/commands/show.command.spec.ts b/apps/cli/tests/unit/commands/show.command.spec.ts index 1083446c..a5c6e435 100644 --- a/apps/cli/tests/unit/commands/show.command.spec.ts +++ b/apps/cli/tests/unit/commands/show.command.spec.ts @@ -5,10 +5,14 @@ import type { TmCore } from '@tm/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Mock dependencies -vi.mock('@tm/core', () => ({ - createTmCore: vi.fn() -})); +// Mock dependencies - use partial mock to keep TaskIdSchema and other exports +vi.mock('@tm/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createTmCore: vi.fn() + }; +}); vi.mock('../../../src/utils/project-root.js', () => ({ getProjectRoot: vi.fn((path?: string) => path || '/test/project') diff --git a/package-lock.json b/package-lock.json index 3d8550c7..27ba2d33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "task-master-ai", - "version": "0.41.0", + "version": "0.40.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "task-master-ai", - "version": "0.41.0", + "version": "0.40.1", "license": "MIT WITH Commons-Clause", "workspaces": [ "apps/*", diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 06b9f04d..14636e6d 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -204,6 +204,17 @@ export { type GenerateTaskFilesResult } from './modules/tasks/services/task-file-generator.service.js'; +// Task filtering utilities +export { + buildBlocksMap, + filterReadyTasks, + filterBlockingTasks, + ACTIONABLE_STATUSES, + type TaskWithBlocks, + type InvalidDependency, + type BuildBlocksMapResult +} from './modules/tasks/utils/index.js'; + // Integration - Advanced export { ExportService, diff --git a/packages/tm-core/src/modules/tasks/utils/index.ts b/packages/tm-core/src/modules/tasks/utils/index.ts new file mode 100644 index 00000000..07729ff4 --- /dev/null +++ b/packages/tm-core/src/modules/tasks/utils/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Task utility exports + * Re-exports task filtering and analysis utilities + */ + +export * from './task-filters.js'; diff --git a/packages/tm-core/src/modules/tasks/utils/task-filters.ts b/packages/tm-core/src/modules/tasks/utils/task-filters.ts new file mode 100644 index 00000000..99d8b58a --- /dev/null +++ b/packages/tm-core/src/modules/tasks/utils/task-filters.ts @@ -0,0 +1,168 @@ +/** + * @fileoverview Task filtering utilities for dependency and readiness analysis + * Business logic for filtering tasks by actionable status, dependencies, and blocking relationships + */ + +import type { Task, TaskStatus } from '../../../common/types/index.js'; +import { + TASK_STATUSES, + isTaskComplete +} from '../../../common/constants/index.js'; +import { getLogger } from '../../../common/logger/index.js'; + +const logger = getLogger('TaskFilters'); + +/** + * Task with blocks field (inverse of dependencies) + * A task's blocks array contains IDs of tasks that depend on it + */ +export type TaskWithBlocks = Task & { blocks: string[] }; + +/** + * Statuses that are actionable (not deferred, blocked, or terminal) + * Tasks with these statuses can be worked on when dependencies are satisfied + */ +export const ACTIONABLE_STATUSES: readonly TaskStatus[] = [ + 'pending', + 'in-progress', + 'review' +] as const; + +/** + * Invalid dependency reference (task depends on non-existent task) + */ +export interface InvalidDependency { + /** ID of the task with the invalid dependency */ + taskId: string; + /** ID of the non-existent dependency */ + depId: string; +} + +/** + * Result of building the blocks map with validation information + */ +export interface BuildBlocksMapResult { + /** Map of task ID -> array of task IDs that depend on it */ + blocksMap: Map; + /** Array of invalid dependency references (dependencies to non-existent tasks) */ + invalidDependencies: InvalidDependency[]; +} + +/** + * Build a map of task ID -> array of task IDs that depend on it (blocks) + * This is the inverse of the dependencies relationship + * + * Also validates dependencies and returns any references to non-existent tasks. + * + * @param tasks - Array of tasks to analyze + * @returns Object containing the blocks map and any invalid dependency references + * + * @example + * ```typescript + * const tasks = [ + * { id: '1', dependencies: [] }, + * { id: '2', dependencies: ['1'] }, + * { id: '3', dependencies: ['1', '2'] } + * ]; + * const { blocksMap, invalidDependencies } = buildBlocksMap(tasks); + * // blocksMap.get('1') => ['2', '3'] // Task 1 blocks tasks 2 and 3 + * // blocksMap.get('2') => ['3'] // Task 2 blocks task 3 + * // blocksMap.get('3') => [] // Task 3 blocks nothing + * // invalidDependencies => [] // No invalid deps in this example + * ``` + */ +export function buildBlocksMap(tasks: Task[]): BuildBlocksMapResult { + const blocksMap = new Map( + tasks.map((task) => [String(task.id), []]) + ); + const invalidDependencies: InvalidDependency[] = []; + + // For each task, add it to the blocks list of each of its dependencies + for (const task of tasks) { + for (const depId of task.dependencies ?? []) { + const depIdStr = String(depId); + const blocks = blocksMap.get(depIdStr); + if (blocks) { + blocks.push(String(task.id)); + } else { + // Dependency references a non-existent task + invalidDependencies.push({ + taskId: String(task.id), + depId: depIdStr + }); + } + } + } + + return { blocksMap, invalidDependencies }; +} + +/** + * Filter to only tasks that are ready to work on + * A task is ready when: + * 1. It has an actionable status (pending, in-progress, or review) + * 2. All its dependencies are complete (done, completed, or cancelled) + * + * @param tasks - Array of tasks with blocks information + * @returns Filtered array of tasks that are ready to work on + * + * @example + * ```typescript + * const tasks = [ + * { id: '1', status: 'done', dependencies: [], blocks: ['2'] }, + * { id: '2', status: 'pending', dependencies: ['1'], blocks: [] }, + * { id: '3', status: 'pending', dependencies: ['2'], blocks: [] } + * ]; + * const readyTasks = filterReadyTasks(tasks); + * // Returns only task 2: status is actionable and dependency '1' is done + * // Task 3 is not ready because dependency '2' is still pending + * ``` + */ +export function filterReadyTasks(tasks: TaskWithBlocks[]): TaskWithBlocks[] { + // Build set of completed task IDs for dependency checking + const completedIds = new Set( + tasks.filter((t) => isTaskComplete(t.status)).map((t) => String(t.id)) + ); + + return tasks.filter((task) => { + // Validate status is a known value + if (!TASK_STATUSES.includes(task.status)) { + logger.warn( + `Task ${task.id} has unexpected status "${task.status}". Valid statuses are: ${TASK_STATUSES.join(', ')}` + ); + } + + // Must be in an actionable status (excludes deferred, blocked, done, cancelled) + if (!ACTIONABLE_STATUSES.includes(task.status)) { + return false; + } + + // Ready if no dependencies or all dependencies are completed + const deps = task.dependencies ?? []; + return deps.every((depId) => completedIds.has(String(depId))); + }); +} + +/** + * Filter to only tasks that block other tasks + * These are tasks that have at least one other task depending on them + * + * @param tasks - Array of tasks with blocks information + * @returns Filtered array of tasks that have dependents (block other tasks) + * + * @example + * ```typescript + * const tasks = [ + * { id: '1', blocks: ['2', '3'] }, // Blocks tasks 2 and 3 + * { id: '2', blocks: [] }, // Blocks nothing + * { id: '3', blocks: [] } // Blocks nothing + * ]; + * const blockingTasks = filterBlockingTasks(tasks); + * // Returns only task 1 (the only task with non-empty blocks) + * ``` + */ +export function filterBlockingTasks( + tasks: TaskWithBlocks[] +): TaskWithBlocks[] { + return tasks.filter((task) => task.blocks.length > 0); +} diff --git a/scripts/modules/task-manager/tag-management.js b/scripts/modules/task-manager/tag-management.js index be9fcea6..1c042f46 100644 --- a/scripts/modules/task-manager/tag-management.js +++ b/scripts/modules/task-manager/tag-management.js @@ -10,6 +10,7 @@ import { tryListTagsViaRemote, tryUseTagViaRemote } from '@tm/bridge'; +import { filterReadyTasks, isTaskComplete } from '@tm/core'; import { displayBanner, getStatusWithColor } from '../ui.js'; import { findProjectRoot, @@ -531,6 +532,7 @@ async function enhanceTagsWithMetadata(tasksPath, rawData, context = {}) { * @param {Object} options - Options object * @param {boolean} [options.showTaskCounts=true] - Whether to show task counts * @param {boolean} [options.showMetadata=false] - Whether to show metadata + * @param {boolean} [options.ready=false] - Whether to filter to only tags with ready tasks * @param {Object} context - Context object containing session and projectRoot * @param {string} [context.projectRoot] - Project root path * @param {Object} [context.mcpLog] - MCP logger object (optional) @@ -544,7 +546,11 @@ async function tags( outputFormat = 'text' ) { const { mcpLog, projectRoot } = context; - const { showTaskCounts = true, showMetadata = false } = options; + const { + showTaskCounts = true, + showMetadata = false, + ready = false + } = options; // Create a consistent logFn object regardless of context const logFn = mcpLog || { @@ -619,12 +625,16 @@ async function tags( const tasks = tagData.tasks || []; const metadata = tagData.metadata || {}; + // Use centralized filtering from @tm/core + // Note: filterReadyTasks expects TaskWithBlocks[] but only uses status/dependencies at runtime + const tasksWithBlocks = tasks.map((t) => ({ ...t, blocks: [] })); + const readyTasks = filterReadyTasks(tasksWithBlocks); + tagList.push({ name: tagName, isCurrent: tagName === currentTag, - completedTasks: tasks.filter( - (t) => t.status === 'done' || t.status === 'completed' - ).length, + completedTasks: tasks.filter((t) => isTaskComplete(t.status)).length, + readyTasks: readyTasks.length, tasks: tasks || [], created: metadata.created || 'Unknown', description: metadata.description || 'No description' @@ -638,22 +648,32 @@ async function tags( return a.name.localeCompare(b.name); }); - logFn.success(`Found ${tagList.length} tags`); + // Filter to only tags with ready tasks if --ready flag is set + let filteredTagList = tagList; + if (ready) { + filteredTagList = tagList.filter((tag) => tag.readyTasks > 0); + logFn.info(`Filtered to ${filteredTagList.length} tags with ready tasks`); + } + + logFn.success(`Found ${filteredTagList.length} tags`); // For JSON output, return structured data if (outputFormat === 'json') { return { - tags: tagList, + tags: filteredTagList, currentTag, - totalTags: tagList.length + totalTags: filteredTagList.length }; } // For text output, display formatted table if (outputFormat === 'text') { - if (tagList.length === 0) { + if (filteredTagList.length === 0) { + const message = ready + ? 'No tags with ready tasks found' + : 'No tags found'; console.log( - boxen(chalk.yellow('No tags found'), { + boxen(chalk.yellow(message), { padding: 1, borderColor: 'yellow', borderStyle: 'round', @@ -667,7 +687,8 @@ async function tags( const headers = [chalk.cyan.bold('Tag Name')]; if (showTaskCounts) { headers.push(chalk.cyan.bold('Tasks')); - headers.push(chalk.cyan.bold('Completed')); + headers.push(chalk.cyan.bold('Ready')); + headers.push(chalk.cyan.bold('Done')); } if (showMetadata) { headers.push(chalk.cyan.bold('Created')); @@ -680,16 +701,16 @@ async function tags( let colWidths; if (showMetadata) { - // With metadata: Tag Name, Tasks, Completed, Created, Description - const widths = [0.25, 0.1, 0.12, 0.15, 0.38]; + // With metadata: Tag Name, Tasks, Ready, Done, Created, Description + const widths = [0.22, 0.08, 0.08, 0.08, 0.14, 0.38]; colWidths = widths.map((w, i) => - Math.max(Math.floor(usableWidth * w), i === 0 ? 15 : 8) + Math.max(Math.floor(usableWidth * w), i === 0 ? 15 : 6) ); } else { - // Without metadata: Tag Name, Tasks, Completed - const widths = [0.7, 0.15, 0.15]; + // Without metadata: Tag Name, Tasks, Ready, Done + const widths = [0.6, 0.13, 0.13, 0.13]; colWidths = widths.map((w, i) => - Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 10) + Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 8) ); } @@ -700,7 +721,7 @@ async function tags( }); // Add rows - tagList.forEach((tag) => { + filteredTagList.forEach((tag) => { const row = []; // Tag name with current indicator @@ -711,6 +732,11 @@ async function tags( if (showTaskCounts) { row.push(chalk.white(tag.tasks.length.toString())); + row.push( + tag.readyTasks > 0 + ? chalk.yellow(tag.readyTasks.toString()) + : chalk.gray('0') + ); row.push(chalk.green(tag.completedTasks.toString())); } @@ -743,9 +769,9 @@ async function tags( } return { - tags: tagList, + tags: filteredTagList, currentTag, - totalTags: tagList.length + totalTags: filteredTagList.length }; } catch (error) { logFn.error(`Error listing tags: ${error.message}`); diff --git a/tests/unit/task-manager/tag-management.test.js b/tests/unit/task-manager/tag-management.test.js index c0e0aa42..792251a9 100644 --- a/tests/unit/task-manager/tag-management.test.js +++ b/tests/unit/task-manager/tag-management.test.js @@ -113,3 +113,180 @@ describe('Tag Management – writeJSON context preservation', () => { expect(tagNames).not.toContain('copy'); }); }); + +describe('Tag Management – ready tasks count', () => { + beforeEach(() => { + fs.mkdirSync(TEMP_DIR, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + }); + + it('should count tasks with no dependencies as ready', async () => { + const data = { + master: { + tasks: [ + { id: 1, title: 'Task 1', status: 'pending', dependencies: [] }, + { id: 2, title: 'Task 2', status: 'pending', dependencies: [] }, + { id: 3, title: 'Task 3', status: 'done', dependencies: [] } + ], + metadata: { created: new Date().toISOString() } + } + }; + fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2)); + + const result = await listTags( + TASKS_PATH, + { showTaskCounts: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + const masterTag = result.tags.find((t) => t.name === 'master'); + expect(masterTag.readyTasks).toBe(2); // 2 pending, 1 done (not ready) + }); + + it('should count tasks with satisfied dependencies as ready', async () => { + const data = { + master: { + tasks: [ + { id: 1, title: 'Task 1', status: 'done', dependencies: [] }, + { id: 2, title: 'Task 2', status: 'pending', dependencies: [1] }, // deps satisfied + { id: 3, title: 'Task 3', status: 'pending', dependencies: [2] } // deps NOT satisfied + ], + metadata: { created: new Date().toISOString() } + } + }; + fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2)); + + const result = await listTags( + TASKS_PATH, + { showTaskCounts: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + const masterTag = result.tags.find((t) => t.name === 'master'); + expect(masterTag.readyTasks).toBe(1); // only task 2 is ready + }); + + it('should exclude deferred and blocked tasks from ready count', async () => { + const data = { + master: { + tasks: [ + { id: 1, title: 'Task 1', status: 'pending', dependencies: [] }, + { id: 2, title: 'Task 2', status: 'deferred', dependencies: [] }, + { id: 3, title: 'Task 3', status: 'blocked', dependencies: [] }, + { id: 4, title: 'Task 4', status: 'in-progress', dependencies: [] }, + { id: 5, title: 'Task 5', status: 'review', dependencies: [] } + ], + metadata: { created: new Date().toISOString() } + } + }; + fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2)); + + const result = await listTags( + TASKS_PATH, + { showTaskCounts: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + const masterTag = result.tags.find((t) => t.name === 'master'); + // Only pending, in-progress, review are actionable + expect(masterTag.readyTasks).toBe(3); // tasks 1, 4, 5 + }); +}); + +describe('Tag Management – --ready filter', () => { + beforeEach(() => { + fs.mkdirSync(TEMP_DIR, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + }); + + it('should filter out tags with no ready tasks when --ready is set', async () => { + const data = { + 'has-ready': { + tasks: [ + { id: 1, title: 'Task 1', status: 'pending', dependencies: [] } + ], + metadata: { created: new Date().toISOString() } + }, + 'no-ready': { + tasks: [ + { id: 1, title: 'Task 1', status: 'done', dependencies: [] }, + { id: 2, title: 'Task 2', status: 'deferred', dependencies: [] } + ], + metadata: { created: new Date().toISOString() } + }, + 'all-blocked': { + tasks: [ + { id: 1, title: 'Task 1', status: 'blocked', dependencies: [] } + ], + metadata: { created: new Date().toISOString() } + } + }; + fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2)); + + const result = await listTags( + TASKS_PATH, + { showTaskCounts: true, ready: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + expect(result.tags.length).toBe(1); + expect(result.tags[0].name).toBe('has-ready'); + expect(result.totalTags).toBe(1); + }); + + it('should include all tags when --ready is not set', async () => { + const data = { + 'has-ready': { + tasks: [ + { id: 1, title: 'Task 1', status: 'pending', dependencies: [] } + ], + metadata: { created: new Date().toISOString() } + }, + 'no-ready': { + tasks: [{ id: 1, title: 'Task 1', status: 'done', dependencies: [] }], + metadata: { created: new Date().toISOString() } + } + }; + fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2)); + + const result = await listTags( + TASKS_PATH, + { showTaskCounts: true, ready: false }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + expect(result.tags.length).toBe(2); + expect(result.totalTags).toBe(2); + }); + + it('should return empty list when no tags have ready tasks', async () => { + const data = { + 'all-done': { + tasks: [{ id: 1, title: 'Task 1', status: 'done', dependencies: [] }], + metadata: { created: new Date().toISOString() } + } + }; + fs.writeFileSync(TASKS_PATH, JSON.stringify(data, null, 2)); + + const result = await listTags( + TASKS_PATH, + { showTaskCounts: true, ready: true }, + { projectRoot: TEMP_DIR }, + 'json' + ); + + expect(result.tags.length).toBe(0); + expect(result.totalTags).toBe(0); + }); +});