feat(list): Add --ready and --blocking filters to identify parallelizable tasks (#1533)

Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>

- fixes #1532
This commit is contained in:
Ben Coombs
2026-01-14 21:59:19 +00:00
committed by GitHub
parent 097c8edcb0
commit 6c3a92c439
15 changed files with 1805 additions and 383 deletions

View File

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

View File

@@ -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<typeof import('@tm/core')>();
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<TmCore>;
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');
});
});
});

View File

@@ -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<StorageType, 'auto'>;
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<ListTasksResult> {
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,7 +634,10 @@ 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)
// 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,
@@ -422,17 +645,22 @@ export class ListTasksCommand extends Command {
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
// 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);
@@ -452,6 +680,7 @@ export class ListTasksCommand extends Command {
// Display suggested next steps at the end
displaySuggestedNextSteps();
}
}
/**
* Set the last result for programmatic access

View File

@@ -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();
});

View File

@@ -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<void> {
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'

View File

@@ -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<number, number[]> = {
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'

View File

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

View File

@@ -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<TmCore>;
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();
});
});
});

View File

@@ -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', () => ({
// Mock dependencies - use partial mock to keep TaskIdSchema and other exports
vi.mock('@tm/core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tm/core')>();
return {
...actual,
createTmCore: vi.fn()
}));
};
});
vi.mock('../../../src/utils/project-root.js', () => ({
getProjectRoot: vi.fn((path?: string) => path || '/test/project')

4
package-lock.json generated
View File

@@ -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/*",

View File

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

View File

@@ -0,0 +1,6 @@
/**
* @fileoverview Task utility exports
* Re-exports task filtering and analysis utilities
*/
export * from './task-filters.js';

View File

@@ -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<string, string[]>;
/** 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<string, string[]>(
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<string>(
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);
}

View File

@@ -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}`);

View File

@@ -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);
});
});