Merge pull request #1577 from eyaltoledano/next

This commit is contained in:
Ralph Khreish
2026-01-15 16:47:37 +01:00
committed by GitHub
45 changed files with 3870 additions and 861 deletions

View File

@@ -0,0 +1,9 @@
---
"task-master-ai": patch
---
Improve concurrency safety by adopting modifyJson pattern in file-storage
- Refactor saveTasks, createTag, deleteTag, renameTag to use modifyJson for atomic read-modify-write operations
- This prevents lost updates when multiple processes concurrently modify tasks.json
- Complements the cross-process file locking added in PR #1566

View File

@@ -0,0 +1,13 @@
---
"task-master-ai": patch
---
Fix race condition when multiple Claude Code windows write to tasks.json simultaneously
- Add cross-process file locking to prevent concurrent write collisions
- Implement atomic writes using temp file + rename pattern to prevent partial writes
- Re-read file inside lock to get current state, preventing lost updates from stale snapshots
- Add stale lock detection and automatic cleanup (10-second timeout)
- Export `withFileLock` and `withFileLockSync` utilities for use by other modules
This fix prevents data loss that could occur when multiple Task Master instances (e.g., multiple Claude Code windows) access the same tasks.json file concurrently.

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,10 @@
---
"task-master-ai": "patch"
---
Improve loop command error handling and use dangerously-skip-permissions
- Add proper spawn error handling (ENOENT, EACCES) with actionable messages
- Return error info from checkSandboxAuth and runInteractiveAuth instead of silent failures
- Use --dangerously-skip-permissions for unattended loop execution
- Fix null exit code masking issue

View File

@@ -0,0 +1,9 @@
---
"task-master-ai": patch
---
Make Docker sandbox mode opt-in for loop command
- Add `--sandbox` flag to `task-master loop` (default: use plain `claude -p`)
- Preserve progress.txt between runs (append instead of overwrite)
- Display execution mode in loop startup output

View File

@@ -0,0 +1,10 @@
---
"task-master-ai": patch
---
Update Codex CLI supported models to match current available models
- Remove deprecated models: gpt-5, gpt-5-codex, gpt-5.1
- Add gpt-5.2-codex as the current default model
- Add gpt-5.1-codex-mini for faster, cheaper option
- Keep gpt-5.1-codex-max and gpt-5.2

View File

@@ -16,8 +16,8 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:unit": "vitest run -t unit", "test:unit": "vitest run '**/*.spec.ts'",
"test:integration": "vitest run -t integration", "test:integration": "vitest run '**/*.test.ts'",
"test:e2e": "vitest run --dir tests/e2e", "test:e2e": "vitest run --dir tests/e2e",
"test:ci": "vitest run --coverage --reporter=dot" "test:ci": "vitest run --coverage --reporter=dot"
}, },

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 { import {
OUTPUT_FORMATS, OUTPUT_FORMATS,
buildBlocksMap,
createTmCore,
filterBlockingTasks,
filterReadyTasks,
isTaskComplete,
type InvalidDependency,
type OutputFormat, type OutputFormat,
STATUS_ICONS, STATUS_ICONS,
TASK_STATUSES, TASK_STATUSES,
type Task, type Task,
type TaskStatus, type TaskStatus,
type TaskWithBlocks,
type TmCore, type TmCore,
type WatchSubscription, type WatchSubscription
createTmCore
} from '@tm/core'; } from '@tm/core';
import type { StorageType } from '@tm/core'; import type { StorageType } from '@tm/core';
import chalk from 'chalk'; import chalk from 'chalk';
@@ -33,7 +39,6 @@ import {
import { displayCommandHeader } from '../utils/display-helpers.js'; import { displayCommandHeader } from '../utils/display-helpers.js';
import { displayError } from '../utils/error-handler.js'; import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js'; import { getProjectRoot } from '../utils/project-root.js';
import { isTaskComplete } from '../utils/task-status.js';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
/** /**
@@ -50,17 +55,26 @@ export interface ListCommandOptions {
silent?: boolean; silent?: boolean;
project?: string; project?: string;
watch?: boolean; 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 * Result type from list command
*/ */
export interface ListTasksResult { export interface ListTasksResult {
tasks: Task[]; tasks: TaskWithBlocks[] | TaskWithTag[];
total: number; total: number;
filtered: number; filtered: number;
tag?: string; tag?: string;
storageType: Exclude<StorageType, 'auto'>; storageType: Exclude<StorageType, 'auto'>;
allTags?: boolean;
} }
/** /**
@@ -104,6 +118,15 @@ export class ListTasksCommand extends Command {
'Project root directory (auto-detected if not provided)' 'Project root directory (auto-detected if not provided)'
) )
.option('-w, --watch', 'Watch for changes and update list automatically') .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) => { .action(async (statusArg?: string, options?: ListCommandOptions) => {
// Handle special "all" keyword to show with subtasks // Handle special "all" keyword to show with subtasks
let status = statusArg || options?.status; let status = statusArg || options?.status;
@@ -142,7 +165,10 @@ export class ListTasksCommand extends Command {
if (options.watch) { if (options.watch) {
await this.watchTasks(options); await this.watchTasks(options);
} else { } 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 // Store result for programmatic access
this.setLastResult(result); this.setLastResult(result);
@@ -195,8 +221,15 @@ export class ListTasksCommand extends Command {
// Show sync message with timestamp // Show sync message with timestamp
displaySyncMessage(storageType, lastSync); displaySyncMessage(storageType, lastSync);
displayWatchFooter(storageType, lastSync); displayWatchFooter(storageType, lastSync);
} catch { } catch (refreshError: unknown) {
// Ignore errors during watch (e.g. partial writes) // 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) { } else if (event.type === 'error' && event.error) {
console.error(chalk.red(`\n⚠ Watch error: ${event.error.message}`)); 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; return true;
} }
@@ -272,14 +316,20 @@ export class ListTasksCommand extends Command {
throw new Error('TmCore not initialized'); throw new Error('TmCore not initialized');
} }
// Build filter // Parse status filter values
const filter = const statusFilterValues =
options.status && options.status !== 'all' options.status && options.status !== 'all'
? { ? options.status.split(',').map((s: string) => s.trim() as TaskStatus)
status: options.status : undefined;
.split(',')
.map((s: string) => s.trim() as TaskStatus) // 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; : undefined;
// Call tm-core // Call tm-core
@@ -289,7 +339,174 @@ export class ListTasksCommand extends Command {
includeSubtasks: options.withSubtasks 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 options: ListCommandOptions
): void { ): void {
// Resolve format: --json and --compact flags override --format option // Resolve format: --json and --compact flags override --format option
const format = ( let format: OutputFormat = options.format || 'text';
options.json if (options.json) {
? 'json' format = 'json';
: options.compact } else if (options.compact) {
? 'compact' format = 'compact';
: options.format || 'text' }
) as OutputFormat;
switch (format) { switch (format) {
case 'json': case 'json':
@@ -335,8 +551,9 @@ export class ListTasksCommand extends Command {
metadata: { metadata: {
total: data.total, total: data.total,
filtered: data.filtered, filtered: data.filtered,
tag: data.tag, tag: data.allTags ? 'all' : data.tag,
storageType: data.storageType storageType: data.storageType,
allTags: data.allTags || false
} }
}, },
null, null,
@@ -352,29 +569,32 @@ export class ListTasksCommand extends Command {
data: ListTasksResult, data: ListTasksResult,
options: ListCommandOptions options: ListCommandOptions
): void { ): void {
const { tasks, tag, storageType } = data; const { tasks, tag, storageType, allTags } = data;
// Display header unless --no-header is set // Display header unless --no-header is set
if (options.noHeader !== true) { if (options.noHeader !== true) {
displayCommandHeader(this.tmCore, { displayCommandHeader(this.tmCore, {
tag: tag || 'master', tag: allTags ? 'all tags' : tag || 'master',
storageType storageType
}); });
} }
tasks.forEach((task) => { for (const task of tasks) {
const icon = STATUS_ICONS[task.status]; 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) { if (options.withSubtasks && task.subtasks?.length) {
task.subtasks.forEach((subtask) => { for (const subtask of task.subtasks) {
const subIcon = STATUS_ICONS[subtask.status]; const subIcon = STATUS_ICONS[subtask.status];
console.log( console.log(
` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}` ` ${chalk.gray(String(subtask.id))} ${subIcon} ${chalk.gray(subtask.title)}`
); );
}); }
} }
}); }
} }
/** /**
@@ -384,12 +604,12 @@ export class ListTasksCommand extends Command {
data: ListTasksResult, data: ListTasksResult,
options: ListCommandOptions options: ListCommandOptions
): void { ): void {
const { tasks, tag, storageType } = data; const { tasks, tag, storageType, allTags } = data;
// Display header unless --no-header is set // Display header unless --no-header is set
if (options.noHeader !== true) { if (options.noHeader !== true) {
displayCommandHeader(this.tmCore, { displayCommandHeader(this.tmCore, {
tag: tag || 'master', tag: allTags ? 'all tags' : tag || 'master',
storageType storageType
}); });
} }
@@ -414,43 +634,52 @@ export class ListTasksCommand extends Command {
? tasks.find((t) => String(t.id) === String(nextTaskInfo.id)) ? tasks.find((t) => String(t.id) === String(nextTaskInfo.id))
: undefined; : undefined;
// Display dashboard boxes (nextTask already has complexity from storage enrichment) // Display dashboard boxes unless filtering by --ready, --blocking, or --all-tags
displayDashboards( // (filtered/cross-tag dashboards would show misleading statistics)
taskStats, const isFiltered = options.ready || options.blocking || allTags;
subtaskStats, if (!isFiltered) {
priorityBreakdown, displayDashboards(
depStats, taskStats,
nextTask subtaskStats,
); priorityBreakdown,
depStats,
nextTask
);
}
// Task table // Task table
console.log( console.log(
ui.createTaskTable(tasks, { ui.createTaskTable(tasks, {
showSubtasks: options.withSubtasks, showSubtasks: options.withSubtasks,
showDependencies: true, 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 // Display recommended next task section immediately after table
// Don't show "no tasks available" message in list command - that's for tm next // Skip when filtering by --ready or --blocking (user already knows what they're looking at)
if (nextTask) { if (!isFiltered) {
const description = getTaskDescription(nextTask); // Don't show "no tasks available" message in list command - that's for tm next
if (nextTask) {
const description = getTaskDescription(nextTask);
displayRecommendedNextTask({ displayRecommendedNextTask({
id: nextTask.id, id: nextTask.id,
title: nextTask.title, title: nextTask.title,
priority: nextTask.priority, priority: nextTask.priority,
status: nextTask.status, status: nextTask.status,
dependencies: nextTask.dependencies, dependencies: nextTask.dependencies,
description, description,
complexity: nextTask.complexity as number | undefined 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();
} }
/** /**

View File

@@ -77,8 +77,8 @@ describe('LoopCommand', () => {
mockTmCore = { mockTmCore = {
loop: { loop: {
run: mockLoopRun, run: mockLoopRun,
checkSandboxAuth: vi.fn().mockReturnValue(true), checkSandboxAuth: vi.fn().mockReturnValue({ ready: true }),
runInteractiveAuth: vi.fn(), runInteractiveAuth: vi.fn().mockReturnValue({ success: true }),
resolveIterations: vi.fn().mockImplementation((opts) => { resolveIterations: vi.fn().mockImplementation((opts) => {
// Mirror the real implementation logic for accurate testing // Mirror the real implementation logic for accurate testing
if (opts.userIterations !== undefined) return opts.userIterations; if (opts.userIterations !== undefined) return opts.userIterations;
@@ -389,27 +389,79 @@ describe('LoopCommand', () => {
); );
}); });
it('should check sandbox auth before running', async () => { it('should check sandbox auth when --sandbox flag is provided', async () => {
const result = createMockResult(); const result = createMockResult();
mockLoopRun.mockResolvedValue(result); mockLoopRun.mockResolvedValue(result);
mockTmCore.loop.checkSandboxAuth.mockReturnValue({ ready: true });
const execute = (loopCommand as any).execute.bind(loopCommand); const execute = (loopCommand as any).execute.bind(loopCommand);
await execute({}); await execute({ sandbox: true });
expect(mockTmCore.loop.checkSandboxAuth).toHaveBeenCalled(); expect(mockTmCore.loop.checkSandboxAuth).toHaveBeenCalled();
}); });
it('should run interactive auth when sandbox not ready', async () => { it('should not check sandbox auth when --sandbox flag is not provided', async () => {
mockTmCore.loop.checkSandboxAuth.mockReturnValue(false);
const result = createMockResult(); const result = createMockResult();
mockLoopRun.mockResolvedValue(result); mockLoopRun.mockResolvedValue(result);
const execute = (loopCommand as any).execute.bind(loopCommand); const execute = (loopCommand as any).execute.bind(loopCommand);
await execute({}); await execute({});
expect(mockTmCore.loop.checkSandboxAuth).not.toHaveBeenCalled();
});
it('should run interactive auth when sandbox not ready', async () => {
mockTmCore.loop.checkSandboxAuth.mockReturnValue({ ready: false });
mockTmCore.loop.runInteractiveAuth.mockReturnValue({ success: true });
const result = createMockResult();
mockLoopRun.mockResolvedValue(result);
const execute = (loopCommand as any).execute.bind(loopCommand);
await execute({ sandbox: true });
expect(mockTmCore.loop.runInteractiveAuth).toHaveBeenCalled(); expect(mockTmCore.loop.runInteractiveAuth).toHaveBeenCalled();
}); });
it('should throw error when sandbox auth has error', async () => {
mockTmCore.loop.checkSandboxAuth.mockReturnValue({
error: 'Sandbox auth failed'
});
const execute = (loopCommand as any).execute.bind(loopCommand);
try {
await execute({ sandbox: true });
} catch {
// Expected - processExitSpy mock throws to simulate process.exit
}
expect(displayError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Sandbox auth failed' }),
{ skipExit: true }
);
});
it('should throw error when interactive auth fails', async () => {
mockTmCore.loop.checkSandboxAuth.mockReturnValue({ ready: false });
mockTmCore.loop.runInteractiveAuth.mockReturnValue({
success: false,
error: 'Auth failed'
});
const execute = (loopCommand as any).execute.bind(loopCommand);
try {
await execute({ sandbox: true });
} catch {
// Expected - processExitSpy mock throws to simulate process.exit
}
expect(displayError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Auth failed' }),
{ skipExit: true }
);
});
it('should show next task before starting loop', async () => { it('should show next task before starting loop', async () => {
const result = createMockResult(); const result = createMockResult();
mockLoopRun.mockResolvedValue(result); mockLoopRun.mockResolvedValue(result);

View File

@@ -6,9 +6,9 @@ import path from 'node:path';
import { import {
type LoopConfig, type LoopConfig,
type LoopResult, type LoopResult,
PRESET_NAMES,
type TmCore, type TmCore,
createTmCore, createTmCore
PRESET_NAMES
} from '@tm/core'; } from '@tm/core';
import chalk from 'chalk'; import chalk from 'chalk';
import { Command } from 'commander'; import { Command } from 'commander';
@@ -22,6 +22,7 @@ export interface LoopCommandOptions {
progressFile?: string; progressFile?: string;
tag?: string; tag?: string;
project?: string; project?: string;
sandbox?: boolean;
} }
export class LoopCommand extends Command { export class LoopCommand extends Command {
@@ -47,6 +48,7 @@ export class LoopCommand extends Command {
'--project <path>', '--project <path>',
'Project root directory (auto-detected if not provided)' 'Project root directory (auto-detected if not provided)'
) )
.option('--sandbox', 'Run Claude in Docker sandbox mode')
.action((options: LoopCommandOptions) => this.execute(options)); .action((options: LoopCommandOptions) => this.execute(options));
} }
@@ -80,11 +82,17 @@ export class LoopCommand extends Command {
storageType: this.tmCore.tasks.getStorageType() storageType: this.tmCore.tasks.getStorageType()
}); });
this.handleSandboxAuth(); // Only check sandbox auth when --sandbox flag is used
if (options.sandbox) {
this.handleSandboxAuth();
}
console.log(chalk.cyan('Starting Task Master Loop...')); console.log(chalk.cyan('Starting Task Master Loop...'));
console.log(chalk.dim(`Preset: ${prompt}`)); console.log(chalk.dim(`Preset: ${prompt}`));
console.log(chalk.dim(`Max iterations: ${iterations}`)); console.log(chalk.dim(`Max iterations: ${iterations}`));
console.log(
chalk.dim(`Mode: ${options.sandbox ? 'Docker sandbox' : 'Claude CLI'}`)
);
// Show next task only for default preset (other presets don't use Task Master tasks) // Show next task only for default preset (other presets don't use Task Master tasks)
if (prompt === 'default') { if (prompt === 'default') {
@@ -105,7 +113,8 @@ export class LoopCommand extends Command {
iterations, iterations,
prompt, prompt,
progressFile, progressFile,
tag: options.tag tag: options.tag,
sandbox: options.sandbox
}; };
const result = await this.tmCore.loop.run(config); const result = await this.tmCore.loop.run(config);
@@ -118,9 +127,13 @@ export class LoopCommand extends Command {
private handleSandboxAuth(): void { private handleSandboxAuth(): void {
console.log(chalk.dim('Checking sandbox auth...')); console.log(chalk.dim('Checking sandbox auth...'));
const isAuthed = this.tmCore.loop.checkSandboxAuth(); const authCheck = this.tmCore.loop.checkSandboxAuth();
if (isAuthed) { if (authCheck.error) {
throw new Error(authCheck.error);
}
if (authCheck.ready) {
console.log(chalk.green('✓ Sandbox ready')); console.log(chalk.green('✓ Sandbox ready'));
return; return;
} }
@@ -132,7 +145,10 @@ export class LoopCommand extends Command {
); );
console.log(chalk.dim('Please complete auth, then Ctrl+C to continue.\n')); console.log(chalk.dim('Please complete auth, then Ctrl+C to continue.\n'));
this.tmCore.loop.runInteractiveAuth(); const authResult = this.tmCore.loop.runInteractiveAuth();
if (!authResult.success) {
throw new Error(authResult.error || 'Interactive authentication failed');
}
console.log(chalk.green('✓ Auth complete\n')); console.log(chalk.green('✓ Auth complete\n'));
} }

View File

@@ -77,8 +77,10 @@ export class TagsCommand extends Command {
constructor(name?: string) { constructor(name?: string) {
super(name || 'tags'); super(name || 'tags');
// Configure the command // Configure the command with options that apply to default list action
this.description('Manage tags for task organization'); 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 // Add subcommands
this.addListCommand(); this.addListCommand();
@@ -88,9 +90,9 @@ export class TagsCommand extends Command {
this.addRenameCommand(); this.addRenameCommand();
this.addCopyCommand(); this.addCopyCommand();
// Default action: list tags // Default action: list tags (with options from parent command)
this.action(async () => { this.action(async (options) => {
await this.executeList(); await this.executeList(options);
}); });
} }
@@ -101,6 +103,7 @@ export class TagsCommand extends Command {
this.command('list') this.command('list')
.description('List all tags with statistics (default action)') .description('List all tags with statistics (default action)')
.option('--show-metadata', 'Show additional tag metadata') .option('--show-metadata', 'Show additional tag metadata')
.option('--ready', 'Show only tags with ready tasks available')
.addHelpText( .addHelpText(
'after', 'after',
` `
@@ -108,6 +111,7 @@ Examples:
$ tm tags # List all tags (default) $ tm tags # List all tags (default)
$ tm tags list # List all tags (explicit) $ tm tags list # List all tags (explicit)
$ tm tags list --show-metadata # List with metadata $ tm tags list --show-metadata # List with metadata
$ tm tags list --ready # Show only tags with parallelizable work
` `
) )
.action(async (options) => { .action(async (options) => {
@@ -245,6 +249,7 @@ Examples:
*/ */
private async executeList(options?: { private async executeList(options?: {
showMetadata?: boolean; showMetadata?: boolean;
ready?: boolean;
}): Promise<void> { }): Promise<void> {
try { try {
// Initialize tmCore first (needed by bridge functions) // Initialize tmCore first (needed by bridge functions)
@@ -257,7 +262,8 @@ Examples:
tasksPath, tasksPath,
{ {
showTaskCounts: true, showTaskCounts: true,
showMetadata: options?.showMetadata || false showMetadata: options?.showMetadata || false,
ready: options?.ready || false
}, },
{ projectRoot }, { projectRoot },
'text' 'text'

View File

@@ -6,6 +6,7 @@
import type { Subtask, Task, TaskPriority } from '@tm/core'; import type { Subtask, Task, TaskPriority } from '@tm/core';
import chalk from 'chalk'; import chalk from 'chalk';
import Table from 'cli-table3'; import Table from 'cli-table3';
import { isTaskComplete } from '../../utils/task-status.js';
import { getComplexityWithColor } from '../formatters/complexity-formatters.js'; import { getComplexityWithColor } from '../formatters/complexity-formatters.js';
import { getPriorityWithColor } from '../formatters/priority-formatters.js'; import { getPriorityWithColor } from '../formatters/priority-formatters.js';
import { getStatusWithColor } from '../formatters/status-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'; 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 * Create a task table for display
*/ */
export function createTaskTable( export function createTaskTable(
tasks: (Task | Subtask)[], tasks: TaskTableItem[],
options?: { options?: {
showSubtasks?: boolean; showSubtasks?: boolean;
showComplexity?: boolean; showComplexity?: boolean;
showDependencies?: boolean; showDependencies?: boolean;
showBlocks?: boolean;
showTag?: boolean;
} }
): string { ): string {
const { const {
showSubtasks = false, showSubtasks = false,
showComplexity = false, showComplexity = false,
showDependencies = true showDependencies = true,
showBlocks = false,
showTag = false
} = options || {}; } = options || {};
// Calculate dynamic column widths based on terminal width // Calculate dynamic column widths based on terminal width
const tableWidth = getBoxWidth(0.9, 100); 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('ID'),
chalk.blue.bold('Title'), chalk.blue.bold('Title'),
chalk.blue.bold('Status'), chalk.blue.bold('Status'),
chalk.blue.bold('Priority') chalk.blue.bold('Priority')
]; );
const colWidths = baseColWidths.slice(0, 4); colWidths.push(...baseColWidths.slice(colIndex, colIndex + 4));
colIndex += 4;
if (showDependencies) { if (showDependencies) {
headers.push(chalk.blue.bold('Dependencies')); 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) { if (showComplexity) {
headers.push(chalk.blue.bold('Complexity')); headers.push(chalk.blue.bold('Complexity'));
colWidths.push(baseColWidths[5] || 12); colWidths.push(baseColWidths[colIndex] || 12);
} }
const table = new Table({ const table = new Table({
@@ -79,17 +115,27 @@ export function createTaskTable(
}); });
tasks.forEach((task) => { 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()), chalk.cyan(task.id.toString()),
truncate(task.title, colWidths[1] - 3), truncate(task.title, colWidths[titleColIndex] - 3),
getStatusWithColor(task.status, true), // Use table version getStatusWithColor(task.status, true), // Use table version
getPriorityWithColor(task.priority) getPriorityWithColor(task.priority)
]; );
if (showDependencies) { if (showDependencies) {
// For table display, show simple format without status icons // For table display, show simple format without status icons
if (!task.dependencies || task.dependencies.length === 0) { if (!task.dependencies || task.dependencies.length === 0) {
row.push(chalk.gray('None')); row.push(chalk.gray('-'));
} else { } else {
row.push( row.push(
chalk.cyan(task.dependencies.map((d) => String(d)).join(', ')) 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) { if (showComplexity) {
// Show complexity score from report if available // Show complexity score from report if available
if (typeof task.complexity === 'number') { if (typeof task.complexity === 'number') {
@@ -111,23 +172,38 @@ export function createTaskTable(
// Add subtasks if requested // Add subtasks if requested
if (showSubtasks && task.subtasks && task.subtasks.length > 0) { if (showSubtasks && task.subtasks && task.subtasks.length > 0) {
task.subtasks.forEach((subtask) => { 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(` └─ ${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(getStatusWithColor(subtask.status, true)),
chalk.gray(subtask.priority || DEFAULT_PRIORITY) chalk.gray(subtask.priority || DEFAULT_PRIORITY)
]; );
if (showDependencies) { if (showDependencies) {
subRow.push( subRow.push(
chalk.gray( chalk.gray(
subtask.dependencies && subtask.dependencies.length > 0 subtask.dependencies && subtask.dependencies.length > 0
? subtask.dependencies.map((dep) => String(dep)).join(', ') ? subtask.dependencies.map((dep) => String(dep)).join(', ')
: 'None' : '-'
) )
); );
} }
if (showBlocks) {
// Subtasks don't typically have blocks, show dash
subRow.push(chalk.gray('-'));
}
if (showComplexity) { if (showComplexity) {
const complexityDisplay = const complexityDisplay =
typeof subtask.complexity === 'number' typeof subtask.complexity === 'number'

View File

@@ -14,9 +14,12 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { createTask, createTasksFile } from '@tm/core/testing'; 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'; 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 // Capture initial working directory at module load time
const initialCwd = process.cwd(); const initialCwd = process.cwd();
@@ -144,11 +147,11 @@ describe('loop command', () => {
expect(output).toContain('--tag'); expect(output).toContain('--tag');
}); });
it('should show --json option in help', () => { it('should show --sandbox option in help', () => {
const { output, exitCode } = runHelp(); const { output, exitCode } = runHelp();
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
expect(output).toContain('--json'); expect(output).toContain('--sandbox');
}); });
it('should show --progress-file option in help', () => { it('should show --progress-file option in help', () => {
@@ -191,44 +194,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', () => { describe('error messages', () => {
it('should show helpful error for invalid iterations', () => { it('should show helpful error for invalid iterations', () => {
const { output, exitCode } = runLoop('-n invalid'); const { output, exitCode } = runLoop('-n invalid');
@@ -239,23 +204,4 @@ describe('loop command', () => {
expect(output.toLowerCase()).toContain('positive'); 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 type { TmCore } from '@tm/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies // Mock dependencies - use partial mock to keep TaskIdSchema and other exports
vi.mock('@tm/core', () => ({ vi.mock('@tm/core', async (importOriginal) => {
createTmCore: vi.fn() const actual = await importOriginal<typeof import('@tm/core')>();
})); return {
...actual,
createTmCore: vi.fn()
};
});
vi.mock('../../../src/utils/project-root.js', () => ({ vi.mock('../../../src/utils/project-root.js', () => ({
getProjectRoot: vi.fn((path?: string) => path || '/test/project') getProjectRoot: vi.fn((path?: string) => path || '/test/project')

View File

@@ -317,7 +317,7 @@ Without test strategy, the AI won't know what to test during implementation.
- [PRD Creation and Parsing Guide](/getting-started/quick-start/prd-quick) - [PRD Creation and Parsing Guide](/getting-started/quick-start/prd-quick)
- [Task Structure Documentation](/capabilities/task-structure) - [Task Structure Documentation](/capabilities/task-structure)
- [Microsoft Research RPG Paper](https://arxiv.org/abs/2410.21376) (Original methodology) - [Microsoft Research RPG Paper](https://arxiv.org/html/2509.16198) (Original methodology)
--- ---

View File

@@ -17,8 +17,8 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:unit": "vitest run -t unit", "test:unit": "vitest run '**/*.spec.ts'",
"test:integration": "vitest run -t integration", "test:integration": "vitest run '**/*.test.ts'",
"test:ci": "vitest run --coverage --reporter=dot" "test:ci": "vitest run --coverage --reporter=dot"
}, },
"dependencies": { "dependencies": {

View File

@@ -158,7 +158,7 @@ describe('generate MCP tool', () => {
const response = callMCPTool('generate', { projectRoot: testDir }); const response = callMCPTool('generate', { projectRoot: testDir });
expect(response.data.orphanedFilesRemoved).toBe(1); expect(response.data.orphanedFilesRemoved).toBe(1);
}, 15000); }, 30000); // Longer timeout: this test makes 2 MCP calls (generate, then regenerate)
it('should accept output parameter for custom directory', () => { it('should accept output parameter for custom directory', () => {
const testData = createTasksFile({ const testData = createTasksFile({

View File

@@ -1,4 +1,4 @@
# Available Models as of December 18, 2025 # Available Models as of January 15, 2026
## Main Models ## Main Models
@@ -15,10 +15,9 @@
| claude-code | opus | 0.725 | 0 | 0 | | claude-code | opus | 0.725 | 0 | 0 |
| claude-code | sonnet | 0.727 | 0 | 0 | | claude-code | sonnet | 0.727 | 0 | 0 |
| claude-code | haiku | 0.45 | 0 | 0 | | claude-code | haiku | 0.45 | 0 | 0 |
| codex-cli | gpt-5 | 0.749 | 0 | 0 | | codex-cli | gpt-5.2-codex | 0.82 | 0 | 0 |
| codex-cli | gpt-5-codex | 0.749 | 0 | 0 |
| codex-cli | gpt-5.1 | 0.76 | 0 | 0 |
| codex-cli | gpt-5.1-codex-max | 0.78 | 0 | 0 | | codex-cli | gpt-5.1-codex-max | 0.78 | 0 | 0 |
| codex-cli | gpt-5.1-codex-mini | 0.72 | 0 | 0 |
| codex-cli | gpt-5.2 | 0.8 | 0 | 0 | | codex-cli | gpt-5.2 | 0.8 | 0 | 0 |
| mcp | mcp-sampling | — | 0 | 0 | | mcp | mcp-sampling | — | 0 | 0 |
| gemini-cli | gemini-3-flash-preview | — | 0 | 0 | | gemini-cli | gemini-3-flash-preview | — | 0 | 0 |
@@ -132,10 +131,9 @@
| claude-code | opus | 0.725 | 0 | 0 | | claude-code | opus | 0.725 | 0 | 0 |
| claude-code | sonnet | 0.727 | 0 | 0 | | claude-code | sonnet | 0.727 | 0 | 0 |
| claude-code | haiku | 0.45 | 0 | 0 | | claude-code | haiku | 0.45 | 0 | 0 |
| codex-cli | gpt-5 | 0.749 | 0 | 0 | | codex-cli | gpt-5.2-codex | 0.82 | 0 | 0 |
| codex-cli | gpt-5-codex | 0.749 | 0 | 0 |
| codex-cli | gpt-5.1 | 0.76 | 0 | 0 |
| codex-cli | gpt-5.1-codex-max | 0.78 | 0 | 0 | | codex-cli | gpt-5.1-codex-max | 0.78 | 0 | 0 |
| codex-cli | gpt-5.1-codex-mini | 0.72 | 0 | 0 |
| codex-cli | gpt-5.2 | 0.8 | 0 | 0 | | codex-cli | gpt-5.2 | 0.8 | 0 | 0 |
| mcp | mcp-sampling | — | 0 | 0 | | mcp | mcp-sampling | — | 0 | 0 |
| gemini-cli | gemini-3-flash-preview | — | 0 | 0 | | gemini-cli | gemini-3-flash-preview | — | 0 | 0 |
@@ -191,10 +189,9 @@
| claude-code | opus | 0.725 | 0 | 0 | | claude-code | opus | 0.725 | 0 | 0 |
| claude-code | sonnet | 0.727 | 0 | 0 | | claude-code | sonnet | 0.727 | 0 | 0 |
| claude-code | haiku | 0.45 | 0 | 0 | | claude-code | haiku | 0.45 | 0 | 0 |
| codex-cli | gpt-5 | 0.749 | 0 | 0 | | codex-cli | gpt-5.2-codex | 0.82 | 0 | 0 |
| codex-cli | gpt-5-codex | 0.749 | 0 | 0 |
| codex-cli | gpt-5.1 | 0.76 | 0 | 0 |
| codex-cli | gpt-5.1-codex-max | 0.78 | 0 | 0 | | codex-cli | gpt-5.1-codex-max | 0.78 | 0 | 0 |
| codex-cli | gpt-5.1-codex-mini | 0.72 | 0 | 0 |
| codex-cli | gpt-5.2 | 0.8 | 0 | 0 | | codex-cli | gpt-5.2 | 0.8 | 0 | 0 |
| mcp | mcp-sampling | — | 0 | 0 | | mcp | mcp-sampling | — | 0 | 0 |
| gemini-cli | gemini-3-flash-preview | — | 0 | 0 | | gemini-cli | gemini-3-flash-preview | — | 0 | 0 |

50
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "task-master-ai", "name": "task-master-ai",
"version": "0.41.0", "version": "0.40.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "task-master-ai", "name": "task-master-ai",
"version": "0.41.0", "version": "0.40.1",
"license": "MIT WITH Commons-Clause", "license": "MIT WITH Commons-Clause",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -67,6 +67,7 @@
"ollama-ai-provider-v2": "^1.3.1", "ollama-ai-provider-v2": "^1.3.1",
"open": "^10.2.0", "open": "^10.2.0",
"ora": "^8.2.0", "ora": "^8.2.0",
"proper-lockfile": "^4.1.2",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"steno": "^4.0.2", "steno": "^4.0.2",
"terminal-link": "^5.0.0", "terminal-link": "^5.0.0",
@@ -13713,6 +13714,16 @@
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/proper-lockfile": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/retry": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.8", "version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
@@ -13783,6 +13794,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/@types/retry": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/stack-utils": { "node_modules/@types/stack-utils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -29634,6 +29652,23 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/proper-lockfile/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/property-information": { "node_modules/property-information": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -31111,6 +31146,15 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/retry-request": { "node_modules/retry-request": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz",
@@ -36324,6 +36368,7 @@
"@supabase/supabase-js": "^2.57.4", "@supabase/supabase-js": "^2.57.4",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"proper-lockfile": "^4.1.2",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"steno": "^4.0.2", "steno": "^4.0.2",
"zod": "^4.1.11" "zod": "^4.1.11"
@@ -36331,6 +36376,7 @@
"devDependencies": { "devDependencies": {
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/proper-lockfile": "^4.1.4",
"@vitest/coverage-v8": "^4.0.10", "@vitest/coverage-v8": "^4.0.10",
"strip-literal": "3.1.0", "strip-literal": "3.1.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",

View File

@@ -20,6 +20,9 @@
"turbo:dev": "turbo dev", "turbo:dev": "turbo dev",
"turbo:build": "turbo build", "turbo:build": "turbo build",
"turbo:typecheck": "turbo typecheck", "turbo:typecheck": "turbo typecheck",
"turbo:test": "turbo test",
"turbo:test:unit": "turbo test:unit",
"turbo:test:integration": "turbo test:integration",
"build:build-config": "npm run build -w @tm/build-config", "build:build-config": "npm run build -w @tm/build-config",
"test": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/.bin/jest", "test": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/.bin/jest",
"test:unit": "node --experimental-vm-modules node_modules/.bin/jest --testPathPattern=unit", "test:unit": "node --experimental-vm-modules node_modules/.bin/jest --testPathPattern=unit",
@@ -117,7 +120,8 @@
"turndown": "^7.2.2", "turndown": "^7.2.2",
"undici": "^7.16.0", "undici": "^7.16.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"zod": "^4.1.12" "zod": "^4.1.12",
"proper-lockfile": "^4.1.2"
}, },
"optionalDependencies": { "optionalDependencies": {
"@anthropic-ai/claude-code": "^2.0.59", "@anthropic-ai/claude-code": "^2.0.59",

View File

@@ -21,6 +21,8 @@
}, },
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run",
"test:unit": "vitest run '**/*.spec.ts'",
"test:integration": "vitest run '**/*.test.ts'",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "biome check --write", "lint": "biome check --write",
@@ -34,12 +36,14 @@
"@supabase/supabase-js": "^2.57.4", "@supabase/supabase-js": "^2.57.4",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"proper-lockfile": "^4.1.2",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"steno": "^4.0.2", "steno": "^4.0.2",
"zod": "^4.1.11" "zod": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/proper-lockfile": "^4.1.4",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@vitest/coverage-v8": "^4.0.10", "@vitest/coverage-v8": "^4.0.10",
"strip-literal": "3.1.0", "strip-literal": "3.1.0",

View File

@@ -204,6 +204,17 @@ export {
type GenerateTaskFilesResult type GenerateTaskFilesResult
} from './modules/tasks/services/task-file-generator.service.js'; } 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 // Integration - Advanced
export { export {
ExportService, ExportService,

View File

@@ -34,72 +34,64 @@ describe('ConfigManager', () => {
} }
}); });
// Setup default mock behaviors // Setup default mock behaviors using class syntax for proper constructor mocking
vi.mocked(ConfigLoader).mockImplementation( vi.mocked(ConfigLoader).mockImplementation(function (this: any) {
() => this.getDefaultConfig = vi.fn().mockReturnValue({
({ models: { main: 'default-model', fallback: 'fallback-model' },
getDefaultConfig: vi.fn().mockReturnValue({ storage: { type: 'file' },
models: { main: 'default-model', fallback: 'fallback-model' }, version: '1.0.0'
storage: { type: 'file' }, });
version: '1.0.0' this.loadLocalConfig = vi.fn().mockResolvedValue(null);
}), this.loadGlobalConfig = vi.fn().mockResolvedValue(null);
loadLocalConfig: vi.fn().mockResolvedValue(null), this.hasLocalConfig = vi.fn().mockResolvedValue(false);
loadGlobalConfig: vi.fn().mockResolvedValue(null), this.hasGlobalConfig = vi.fn().mockResolvedValue(false);
hasLocalConfig: vi.fn().mockResolvedValue(false), return this;
hasGlobalConfig: vi.fn().mockResolvedValue(false) } as any);
}) as any
);
vi.mocked(ConfigMerger).mockImplementation( vi.mocked(ConfigMerger).mockImplementation(function (this: any) {
() => this.addSource = vi.fn();
({ this.clearSources = vi.fn();
addSource: vi.fn(), this.merge = vi.fn().mockReturnValue({
clearSources: vi.fn(), models: { main: 'merged-model', fallback: 'fallback-model' },
merge: vi.fn().mockReturnValue({ storage: { type: 'file' }
models: { main: 'merged-model', fallback: 'fallback-model' }, });
storage: { type: 'file' } this.getSources = vi.fn().mockReturnValue([]);
}), this.hasSource = vi.fn().mockReturnValue(false);
getSources: vi.fn().mockReturnValue([]), this.removeSource = vi.fn().mockReturnValue(false);
hasSource: vi.fn().mockReturnValue(false), return this;
removeSource: vi.fn().mockReturnValue(false) } as any);
}) as any
);
vi.mocked(RuntimeStateManager).mockImplementation( vi.mocked(RuntimeStateManager).mockImplementation(function (this: any) {
() => this.loadState = vi.fn().mockResolvedValue({ activeTag: 'master' });
({ this.saveState = vi.fn().mockResolvedValue(undefined);
loadState: vi.fn().mockResolvedValue({ activeTag: 'master' }), this.getCurrentTag = vi.fn().mockReturnValue('master');
saveState: vi.fn().mockResolvedValue(undefined), this.setCurrentTag = vi.fn().mockResolvedValue(undefined);
getCurrentTag: vi.fn().mockReturnValue('master'), this.getState = vi.fn().mockReturnValue({ activeTag: 'master' });
setCurrentTag: vi.fn().mockResolvedValue(undefined), this.updateMetadata = vi.fn().mockResolvedValue(undefined);
getState: vi.fn().mockReturnValue({ activeTag: 'master' }), this.clearState = vi.fn().mockResolvedValue(undefined);
updateMetadata: vi.fn().mockResolvedValue(undefined), return this;
clearState: vi.fn().mockResolvedValue(undefined) } as any);
}) as any
);
vi.mocked(ConfigPersistence).mockImplementation( vi.mocked(ConfigPersistence).mockImplementation(function (this: any) {
() => this.saveConfig = vi.fn().mockResolvedValue(undefined);
({ this.configExists = vi.fn().mockResolvedValue(false);
saveConfig: vi.fn().mockResolvedValue(undefined), this.deleteConfig = vi.fn().mockResolvedValue(undefined);
configExists: vi.fn().mockResolvedValue(false), this.getBackups = vi.fn().mockResolvedValue([]);
deleteConfig: vi.fn().mockResolvedValue(undefined), this.restoreFromBackup = vi.fn().mockResolvedValue(undefined);
getBackups: vi.fn().mockResolvedValue([]), return this;
restoreFromBackup: vi.fn().mockResolvedValue(undefined) } as any);
}) as any
);
vi.mocked(EnvironmentConfigProvider).mockImplementation( vi.mocked(EnvironmentConfigProvider).mockImplementation(function (
() => this: any
({ ) {
loadConfig: vi.fn().mockReturnValue({}), this.loadConfig = vi.fn().mockReturnValue({});
getRuntimeState: vi.fn().mockReturnValue({}), this.getRuntimeState = vi.fn().mockReturnValue({});
hasEnvVar: vi.fn().mockReturnValue(false), this.hasEnvVar = vi.fn().mockReturnValue(false);
getAllTaskmasterEnvVars: vi.fn().mockReturnValue({}), this.getAllTaskmasterEnvVars = vi.fn().mockReturnValue({});
addMapping: vi.fn(), this.addMapping = vi.fn();
getMappings: vi.fn().mockReturnValue([]) this.getMappings = vi.fn().mockReturnValue([]);
}) as any return this;
); } as any);
// Since constructor is private, we need to use the factory method // Since constructor is private, we need to use the factory method
// But for testing, we'll create a test instance using create() // But for testing, we'll create a test instance using create()
@@ -178,28 +170,30 @@ describe('ConfigManager', () => {
it('should return storage configuration', () => { it('should return storage configuration', () => {
const storage = manager.getStorageConfig(); const storage = manager.getStorageConfig();
expect(storage).toEqual({ type: 'file' }); expect(storage).toEqual({
type: 'file',
basePath: testProjectRoot,
apiConfigured: false
});
}); });
it('should return API storage configuration when configured', async () => { it('should return API storage configuration when configured', async () => {
// Create a new instance with API storage config // Create a new instance with API storage config
vi.mocked(ConfigMerger).mockImplementationOnce( vi.mocked(ConfigMerger).mockImplementationOnce(function (this: any) {
() => this.addSource = vi.fn();
({ this.clearSources = vi.fn();
addSource: vi.fn(), this.merge = vi.fn().mockReturnValue({
clearSources: vi.fn(), storage: {
merge: vi.fn().mockReturnValue({ type: 'api',
storage: { apiEndpoint: 'https://api.example.com',
type: 'api', apiAccessToken: 'token123'
apiEndpoint: 'https://api.example.com', }
apiAccessToken: 'token123' });
} this.getSources = vi.fn().mockReturnValue([]);
}), this.hasSource = vi.fn().mockReturnValue(false);
getSources: vi.fn().mockReturnValue([]), this.removeSource = vi.fn().mockReturnValue(false);
hasSource: vi.fn().mockReturnValue(false), return this;
removeSource: vi.fn().mockReturnValue(false) } as any);
}) as any
);
const apiManager = await ConfigManager.create(testProjectRoot); const apiManager = await ConfigManager.create(testProjectRoot);
@@ -207,7 +201,9 @@ describe('ConfigManager', () => {
expect(storage).toEqual({ expect(storage).toEqual({
type: 'api', type: 'api',
apiEndpoint: 'https://api.example.com', apiEndpoint: 'https://api.example.com',
apiAccessToken: 'token123' apiAccessToken: 'token123',
basePath: testProjectRoot,
apiConfigured: true
}); });
}); });

View File

@@ -2,17 +2,12 @@
* @fileoverview Unit tests for ConfigLoader service * @fileoverview Unit tests for ConfigLoader service
*/ */
import fs from 'node:fs/promises'; import * as fsPromises from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import { ConfigLoader } from './config-loader.service.js'; import { ConfigLoader } from './config-loader.service.js';
vi.mock('node:fs', () => ({ vi.mock('node:fs/promises');
promises: {
readFile: vi.fn(),
access: vi.fn()
}
}));
describe('ConfigLoader', () => { describe('ConfigLoader', () => {
let configLoader: ConfigLoader; let configLoader: ConfigLoader;
@@ -56,11 +51,13 @@ describe('ConfigLoader', () => {
storage: { type: 'api' as const } storage: { type: 'api' as const }
}; };
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig)); vi.mocked(fsPromises.readFile).mockResolvedValue(
JSON.stringify(mockConfig)
);
const result = await configLoader.loadLocalConfig(); const result = await configLoader.loadLocalConfig();
expect(fs.readFile).toHaveBeenCalledWith( expect(fsPromises.readFile).toHaveBeenCalledWith(
'/test/project/.taskmaster/config.json', '/test/project/.taskmaster/config.json',
'utf-8' 'utf-8'
); );
@@ -70,7 +67,7 @@ describe('ConfigLoader', () => {
it('should return null when config file does not exist', async () => { it('should return null when config file does not exist', async () => {
const error = new Error('File not found') as any; const error = new Error('File not found') as any;
error.code = 'ENOENT'; error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error); vi.mocked(fsPromises.readFile).mockRejectedValue(error);
const result = await configLoader.loadLocalConfig(); const result = await configLoader.loadLocalConfig();
@@ -79,7 +76,7 @@ describe('ConfigLoader', () => {
it('should throw TaskMasterError for other file errors', async () => { it('should throw TaskMasterError for other file errors', async () => {
const error = new Error('Permission denied'); const error = new Error('Permission denied');
vi.mocked(fs.readFile).mockRejectedValue(error); vi.mocked(fsPromises.readFile).mockRejectedValue(error);
await expect(configLoader.loadLocalConfig()).rejects.toThrow( await expect(configLoader.loadLocalConfig()).rejects.toThrow(
'Failed to load local configuration' 'Failed to load local configuration'
@@ -87,7 +84,7 @@ describe('ConfigLoader', () => {
}); });
it('should throw error for invalid JSON', async () => { it('should throw error for invalid JSON', async () => {
vi.mocked(fs.readFile).mockResolvedValue('invalid json'); vi.mocked(fsPromises.readFile).mockResolvedValue('invalid json');
await expect(configLoader.loadLocalConfig()).rejects.toThrow(); await expect(configLoader.loadLocalConfig()).rejects.toThrow();
}); });
@@ -102,18 +99,18 @@ describe('ConfigLoader', () => {
describe('hasLocalConfig', () => { describe('hasLocalConfig', () => {
it('should return true when local config exists', async () => { it('should return true when local config exists', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fsPromises.access).mockResolvedValue(undefined);
const result = await configLoader.hasLocalConfig(); const result = await configLoader.hasLocalConfig();
expect(fs.access).toHaveBeenCalledWith( expect(fsPromises.access).toHaveBeenCalledWith(
'/test/project/.taskmaster/config.json' '/test/project/.taskmaster/config.json'
); );
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false when local config does not exist', async () => { it('should return false when local config does not exist', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('Not found')); vi.mocked(fsPromises.access).mockRejectedValue(new Error('Not found'));
const result = await configLoader.hasLocalConfig(); const result = await configLoader.hasLocalConfig();
@@ -123,18 +120,18 @@ describe('ConfigLoader', () => {
describe('hasGlobalConfig', () => { describe('hasGlobalConfig', () => {
it('should check global config path', async () => { it('should check global config path', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fsPromises.access).mockResolvedValue(undefined);
const result = await configLoader.hasGlobalConfig(); const result = await configLoader.hasGlobalConfig();
expect(fs.access).toHaveBeenCalledWith( expect(fsPromises.access).toHaveBeenCalledWith(
expect.stringContaining('.taskmaster/config.json') expect.stringContaining('.taskmaster/config.json')
); );
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false when global config does not exist', async () => { it('should return false when global config does not exist', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('Not found')); vi.mocked(fsPromises.access).mockRejectedValue(new Error('Not found'));
const result = await configLoader.hasGlobalConfig(); const result = await configLoader.hasGlobalConfig();

View File

@@ -2,19 +2,12 @@
* @fileoverview Unit tests for RuntimeStateManager service * @fileoverview Unit tests for RuntimeStateManager service
*/ */
import fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import { RuntimeStateManager } from './runtime-state-manager.service.js'; import { RuntimeStateManager } from './runtime-state-manager.service.js';
vi.mock('node:fs', () => ({ vi.mock('node:fs/promises');
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
unlink: vi.fn()
}
}));
describe('RuntimeStateManager', () => { describe('RuntimeStateManager', () => {
let stateManager: RuntimeStateManager; let stateManager: RuntimeStateManager;
@@ -96,15 +89,10 @@ describe('RuntimeStateManager', () => {
it('should handle invalid JSON gracefully', async () => { it('should handle invalid JSON gracefully', async () => {
vi.mocked(fs.readFile).mockResolvedValue('invalid json'); vi.mocked(fs.readFile).mockResolvedValue('invalid json');
// Mock console.warn to avoid noise in tests
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const state = await stateManager.loadState(); const state = await stateManager.loadState();
// Should return default state when JSON is invalid
expect(state.currentTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG); expect(state.currentTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
}); });
}); });
@@ -124,7 +112,7 @@ describe('RuntimeStateManager', () => {
// Verify writeFile was called with correct data // Verify writeFile was called with correct data
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
'/test/project/.taskmaster/state.json', '/test/project/.taskmaster/state.json',
expect.stringContaining('"activeTag":"test-tag"'), expect.stringContaining('"currentTag": "test-tag"'),
'utf-8' 'utf-8'
); );

View File

@@ -102,7 +102,7 @@ describe('LoopDomain', () => {
tag: 'feature-branch' tag: 'feature-branch'
}; };
const config = (loopDomain as any).buildConfig(fullConfig); const config = (loopDomain as any).buildConfig(fullConfig);
expect(config).toEqual(fullConfig); expect(config).toMatchObject(fullConfig);
}); });
}); });

View File

@@ -31,9 +31,9 @@ export class LoopDomain {
/** /**
* Check if Docker sandbox auth is ready * Check if Docker sandbox auth is ready
* @returns true if ready, false if auth needed * @returns Object with ready status and optional error message
*/ */
checkSandboxAuth(): boolean { checkSandboxAuth(): { ready: boolean; error?: string } {
const service = new LoopService({ projectRoot: this.projectRoot }); const service = new LoopService({ projectRoot: this.projectRoot });
return service.checkSandboxAuth(); return service.checkSandboxAuth();
} }
@@ -41,10 +41,11 @@ export class LoopDomain {
/** /**
* Run Docker sandbox session for user authentication * Run Docker sandbox session for user authentication
* Blocks until user completes auth * Blocks until user completes auth
* @returns Object with success status and optional error message
*/ */
runInteractiveAuth(): void { runInteractiveAuth(): { success: boolean; error?: string } {
const service = new LoopService({ projectRoot: this.projectRoot }); const service = new LoopService({ projectRoot: this.projectRoot });
service.runInteractiveAuth(); return service.runInteractiveAuth();
} }
// ========== Loop Operations ========== // ========== Loop Operations ==========
@@ -188,7 +189,8 @@ export class LoopDomain {
partial.progressFile ?? partial.progressFile ??
path.join(this.projectRoot, '.taskmaster', 'progress.txt'), path.join(this.projectRoot, '.taskmaster', 'progress.txt'),
sleepSeconds: partial.sleepSeconds ?? 5, sleepSeconds: partial.sleepSeconds ?? 5,
tag: partial.tag tag: partial.tag,
sandbox: partial.sandbox ?? false
}; };
} }
} }

View File

@@ -122,7 +122,7 @@ describe('LoopService', () => {
}); });
describe('checkSandboxAuth()', () => { describe('checkSandboxAuth()', () => {
it('should return true when output contains ok', () => { it('should return ready=true when output contains ok', () => {
mockSpawnSync.mockReturnValue({ mockSpawnSync.mockReturnValue({
stdout: 'OK', stdout: 'OK',
stderr: '', stderr: '',
@@ -135,7 +135,7 @@ describe('LoopService', () => {
const service = new LoopService(defaultOptions); const service = new LoopService(defaultOptions);
const result = service.checkSandboxAuth(); const result = service.checkSandboxAuth();
expect(result).toBe(true); expect(result.ready).toBe(true);
expect(mockSpawnSync).toHaveBeenCalledWith( expect(mockSpawnSync).toHaveBeenCalledWith(
'docker', 'docker',
['sandbox', 'run', 'claude', '-p', 'Say OK'], ['sandbox', 'run', 'claude', '-p', 'Say OK'],
@@ -146,7 +146,7 @@ describe('LoopService', () => {
); );
}); });
it('should return false when output does not contain ok', () => { it('should return ready=false when output does not contain ok', () => {
mockSpawnSync.mockReturnValue({ mockSpawnSync.mockReturnValue({
stdout: 'Error: not authenticated', stdout: 'Error: not authenticated',
stderr: '', stderr: '',
@@ -159,7 +159,7 @@ describe('LoopService', () => {
const service = new LoopService(defaultOptions); const service = new LoopService(defaultOptions);
const result = service.checkSandboxAuth(); const result = service.checkSandboxAuth();
expect(result).toBe(false); expect(result.ready).toBe(false);
}); });
it('should check stderr as well as stdout', () => { it('should check stderr as well as stdout', () => {
@@ -175,7 +175,7 @@ describe('LoopService', () => {
const service = new LoopService(defaultOptions); const service = new LoopService(defaultOptions);
const result = service.checkSandboxAuth(); const result = service.checkSandboxAuth();
expect(result).toBe(true); expect(result.ready).toBe(true);
}); });
}); });
@@ -256,7 +256,7 @@ describe('LoopService', () => {
expect(mockSpawnSync).toHaveBeenCalledTimes(3); expect(mockSpawnSync).toHaveBeenCalledTimes(3);
}); });
it('should call spawnSync with docker sandbox run claude -p', async () => { it('should call spawnSync with claude -p by default (non-sandbox)', async () => {
mockSpawnSync.mockReturnValue({ mockSpawnSync.mockReturnValue({
stdout: 'Done', stdout: 'Done',
stderr: '', stderr: '',
@@ -274,14 +274,8 @@ describe('LoopService', () => {
}); });
expect(mockSpawnSync).toHaveBeenCalledWith( expect(mockSpawnSync).toHaveBeenCalledWith(
'docker', 'claude',
expect.arrayContaining([ expect.arrayContaining(['-p', expect.any(String)]),
'sandbox',
'run',
'claude',
'-p',
expect.any(String)
]),
expect.objectContaining({ expect.objectContaining({
cwd: '/test/project' cwd: '/test/project'
}) })
@@ -397,7 +391,8 @@ describe('LoopService', () => {
expect(fsPromises.mkdir).toHaveBeenCalledWith('/test', { expect(fsPromises.mkdir).toHaveBeenCalledWith('/test', {
recursive: true recursive: true
}); });
expect(fsPromises.writeFile).toHaveBeenCalledWith( // Uses appendFile instead of writeFile to preserve existing progress
expect(fsPromises.appendFile).toHaveBeenCalledWith(
'/test/progress.txt', '/test/progress.txt',
expect.stringContaining('# Task Master Loop Progress'), expect.stringContaining('# Task Master Loop Progress'),
'utf-8' 'utf-8'
@@ -449,8 +444,8 @@ describe('LoopService', () => {
// Verify spawn was called with prompt containing iteration info // Verify spawn was called with prompt containing iteration info
const spawnCall = mockSpawnSync.mock.calls[0]; const spawnCall = mockSpawnSync.mock.calls[0];
// Args are ['sandbox', 'run', 'claude', '-p', prompt] // Args are ['-p', prompt, '--dangerously-skip-permissions'] for non-sandbox
const promptArg = spawnCall[1][4]; const promptArg = spawnCall[1][1];
expect(promptArg).toContain('iteration 1 of 1'); expect(promptArg).toContain('iteration 1 of 1');
}); });

View File

@@ -1,9 +1,9 @@
/** /**
* @fileoverview Loop Service - Orchestrates running Claude Code in Docker sandbox iterations * @fileoverview Loop Service - Orchestrates running Claude Code iterations (sandbox or CLI mode)
*/ */
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; import { appendFile, mkdir, readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { PRESETS, isPreset as checkIsPreset } from '../presets/index.js'; import { PRESETS, isPreset as checkIsPreset } from '../presets/index.js';
import type { import type {
@@ -34,7 +34,7 @@ export class LoopService {
} }
/** Check if Docker sandbox auth is ready */ /** Check if Docker sandbox auth is ready */
checkSandboxAuth(): boolean { checkSandboxAuth(): { ready: boolean; error?: string } {
const result = spawnSync( const result = spawnSync(
'docker', 'docker',
['sandbox', 'run', 'claude', '-p', 'Say OK'], ['sandbox', 'run', 'claude', '-p', 'Say OK'],
@@ -42,16 +42,29 @@ export class LoopService {
cwd: this.projectRoot, cwd: this.projectRoot,
timeout: 30000, timeout: 30000,
encoding: 'utf-8', encoding: 'utf-8',
stdio: ['inherit', 'pipe', 'pipe'] // stdin from terminal, capture stdout/stderr stdio: ['inherit', 'pipe', 'pipe']
} }
); );
if (result.error) {
const code = (result.error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {
ready: false,
error:
'Docker is not installed. Install Docker Desktop to use --sandbox mode.'
};
}
return { ready: false, error: `Docker error: ${result.error.message}` };
}
const output = (result.stdout || '') + (result.stderr || ''); const output = (result.stdout || '') + (result.stderr || '');
return output.toLowerCase().includes('ok'); return { ready: output.toLowerCase().includes('ok') };
} }
/** Run interactive Docker sandbox session for user authentication */ /** Run interactive Docker sandbox session for user authentication */
runInteractiveAuth(): void { runInteractiveAuth(): { success: boolean; error?: string } {
spawnSync( const result = spawnSync(
'docker', 'docker',
[ [
'sandbox', 'sandbox',
@@ -64,6 +77,34 @@ export class LoopService {
stdio: 'inherit' stdio: 'inherit'
} }
); );
if (result.error) {
const code = (result.error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {
success: false,
error:
'Docker is not installed. Install Docker Desktop to use --sandbox mode.'
};
}
return { success: false, error: `Docker error: ${result.error.message}` };
}
if (result.status === null) {
return {
success: false,
error: 'Docker terminated abnormally (no exit code)'
};
}
if (result.status !== 0) {
return {
success: false,
error: `Docker exited with code ${result.status}`
};
}
return { success: true };
} }
/** Run a loop with the given configuration */ /** Run a loop with the given configuration */
@@ -80,7 +121,11 @@ export class LoopService {
console.log(`━━━ Iteration ${i} of ${config.iterations} ━━━`); console.log(`━━━ Iteration ${i} of ${config.iterations} ━━━`);
const prompt = await this.buildPrompt(config, i); const prompt = await this.buildPrompt(config, i);
const iteration = this.executeIteration(prompt, i); const iteration = this.executeIteration(
prompt,
i,
config.sandbox ?? false
);
iterations.push(iteration); iterations.push(iteration);
// Check for early exit conditions // Check for early exit conditions
@@ -135,9 +180,11 @@ export class LoopService {
private async initProgressFile(config: LoopConfig): Promise<void> { private async initProgressFile(config: LoopConfig): Promise<void> {
await mkdir(path.dirname(config.progressFile), { recursive: true }); await mkdir(path.dirname(config.progressFile), { recursive: true });
const tagLine = config.tag ? `# Tag: ${config.tag}\n` : ''; const tagLine = config.tag ? `# Tag: ${config.tag}\n` : '';
await writeFile( // Append to existing progress file instead of overwriting
await appendFile(
config.progressFile, config.progressFile,
`# Task Master Loop Progress `
# Task Master Loop Progress
# Started: ${new Date().toISOString()} # Started: ${new Date().toISOString()}
# Preset: ${config.prompt} # Preset: ${config.prompt}
# Max Iterations: ${config.iterations} # Max Iterations: ${config.iterations}
@@ -217,30 +264,64 @@ Loop iteration ${iteration} of ${config.iterations}${tagInfo}`;
private executeIteration( private executeIteration(
prompt: string, prompt: string,
iterationNum: number iterationNum: number,
sandbox: boolean
): LoopIteration { ): LoopIteration {
const startTime = Date.now(); const startTime = Date.now();
const result = spawnSync( // Use docker sandbox or plain claude based on config
'docker', const command = sandbox ? 'docker' : 'claude';
['sandbox', 'run', 'claude', '-p', prompt], const args = sandbox
{ ? ['sandbox', 'run', 'claude', '-p', prompt]
cwd: this.projectRoot, : ['-p', prompt, '--dangerously-skip-permissions'];
encoding: 'utf-8',
maxBuffer: 50 * 1024 * 1024, // 50MB buffer const result = spawnSync(command, args, {
stdio: ['inherit', 'pipe', 'pipe'] cwd: this.projectRoot,
encoding: 'utf-8',
maxBuffer: 50 * 1024 * 1024, // 50MB buffer
stdio: ['inherit', 'pipe', 'pipe']
});
// Check for spawn-level errors (command not found, permission denied, etc.)
if (result.error) {
const code = (result.error as NodeJS.ErrnoException).code;
let errorMessage: string;
if (code === 'ENOENT') {
errorMessage = sandbox
? 'Docker is not installed. Install Docker Desktop to use --sandbox mode.'
: 'Claude CLI is not installed. Install with: npm install -g @anthropic-ai/claude-code';
} else if (code === 'EACCES') {
errorMessage = `Permission denied executing '${command}'`;
} else {
errorMessage = `Failed to execute '${command}': ${result.error.message}`;
} }
);
console.error(`[Loop Error] ${errorMessage}`);
return {
iteration: iterationNum,
status: 'error',
duration: Date.now() - startTime,
message: errorMessage
};
}
const output = (result.stdout || '') + (result.stderr || ''); const output = (result.stdout || '') + (result.stderr || '');
// Print output to console (spawnSync with pipe captures but doesn't display) // Print output to console (spawnSync with pipe captures but doesn't display)
if (output) console.log(output); if (output) console.log(output);
const { status, message } = this.parseCompletion( // Handle null status (spawn failed but no error object - shouldn't happen but be safe)
output, if (result.status === null) {
result.status ?? 1 return {
); iteration: iterationNum,
status: 'error',
duration: Date.now() - startTime,
message: 'Command terminated abnormally (no exit code)'
};
}
const { status, message } = this.parseCompletion(output, result.status);
return { return {
iteration: iterationNum, iteration: iterationNum,
status, status,

View File

@@ -26,6 +26,8 @@ export interface LoopConfig {
sleepSeconds: number; sleepSeconds: number;
/** Tag context to operate on (optional) */ /** Tag context to operate on (optional) */
tag?: string; tag?: string;
/** Run Claude in Docker sandbox mode (default: false) */
sandbox?: boolean;
} }
/** /**

View File

@@ -0,0 +1,264 @@
/**
* Tests for FileOperations class
* Focuses on modifyJson and cross-process locking functionality
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { FileOperations } from './file-operations.js';
describe('FileOperations', () => {
let tempDir: string;
let testFilePath: string;
let fileOps: FileOperations;
beforeEach(async () => {
// Create a temp directory for each test
tempDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'tm-core-test-'));
testFilePath = path.join(tempDir, 'test.json');
fileOps = new FileOperations();
});
afterEach(async () => {
// Clean up
await fileOps.cleanup();
if (tempDir && fsSync.existsSync(tempDir)) {
fsSync.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('modifyJson', () => {
it('should modify existing JSON data', async () => {
// Set up initial data
await fs.writeFile(testFilePath, JSON.stringify({ count: 0 }));
// Modify data
await fileOps.modifyJson(testFilePath, (data: { count: number }) => ({
...data,
count: data.count + 1
}));
// Verify
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.count).toBe(1);
});
it('should create file if it does not exist', async () => {
const newFilePath = path.join(tempDir, 'new-file.json');
await fileOps.modifyJson(newFilePath, () => ({ created: true }));
expect(fsSync.existsSync(newFilePath)).toBe(true);
const result = JSON.parse(await fs.readFile(newFilePath, 'utf-8'));
expect(result.created).toBe(true);
});
it('should handle async modifier functions', async () => {
await fs.writeFile(testFilePath, JSON.stringify({ value: 'initial' }));
await fileOps.modifyJson(
testFilePath,
async (data: { value: string }) => {
// Simulate async operation
await new Promise((resolve) => setTimeout(resolve, 10));
return { ...data, value: 'modified' };
}
);
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.value).toBe('modified');
});
it('should re-read file inside lock to prevent stale data', async () => {
// Initial data
await fs.writeFile(testFilePath, JSON.stringify({ version: 1 }));
// Simulate two sequential modifications
await fileOps.modifyJson(testFilePath, (data: { version: number }) => ({
version: data.version + 1
}));
await fileOps.modifyJson(testFilePath, (data: { version: number }) => ({
version: data.version + 1
}));
// Both modifications should have been applied
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.version).toBe(3);
});
it('should not leave lock files on success', async () => {
await fs.writeFile(testFilePath, JSON.stringify({}));
await fileOps.modifyJson(testFilePath, (data) => ({
...data,
modified: true
}));
// Check no lock files exist
const files = await fs.readdir(tempDir);
const lockFiles = files.filter((f) => f.endsWith('.lock'));
expect(lockFiles).toHaveLength(0);
});
it('should release lock even if modifier throws', async () => {
await fs.writeFile(testFilePath, JSON.stringify({}));
await expect(
fileOps.modifyJson(testFilePath, () => {
throw new Error('Modifier error');
})
).rejects.toThrow('Modifier error');
// Should still be able to acquire lock for another operation
await fileOps.modifyJson(testFilePath, () => ({ recovered: true }));
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.recovered).toBe(true);
});
it('should handle empty file gracefully', async () => {
// Create empty file
await fs.writeFile(testFilePath, '');
await fileOps.modifyJson(testFilePath, () => ({ initialized: true }));
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.initialized).toBe(true);
});
it('should handle file with only whitespace', async () => {
await fs.writeFile(testFilePath, ' \n ');
await fileOps.modifyJson(testFilePath, () => ({ initialized: true }));
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.initialized).toBe(true);
});
it('should throw on corrupted JSON', async () => {
// Write invalid JSON that is not empty
await fs.writeFile(testFilePath, '{ invalid json content');
await expect(
fileOps.modifyJson(testFilePath, (data) => data)
).rejects.toThrow(/Corrupted JSON/);
});
it('should preserve complex nested structures', async () => {
const complexData = {
tasks: [
{
id: 1,
title: 'Task 1',
subtasks: [{ id: '1.1', title: 'Subtask' }]
}
],
metadata: {
created: '2024-01-01',
tags: ['tag1', 'tag2']
}
};
await fs.writeFile(testFilePath, JSON.stringify(complexData, null, 2));
await fileOps.modifyJson(testFilePath, (data: typeof complexData) => ({
...data,
tasks: [...data.tasks, { id: 2, title: 'Task 2', subtasks: [] }]
}));
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.tasks).toHaveLength(2);
expect(result.tasks[0].subtasks).toHaveLength(1);
expect(result.metadata.tags).toEqual(['tag1', 'tag2']);
});
});
describe('concurrent operations', () => {
it('should serialize truly concurrent modifyJson calls', async () => {
// Initial data
await fs.writeFile(testFilePath, JSON.stringify({ count: 0 }));
const numConcurrentWrites = 5;
const writes = [];
for (let i = 0; i < numConcurrentWrites; i++) {
writes.push(
fileOps.modifyJson(testFilePath, (data: { count: number }) => ({
count: data.count + 1
}))
);
}
await Promise.all(writes);
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.count).toBe(numConcurrentWrites);
});
it('should handle concurrent writes from multiple FileOperations instances', async () => {
// Initial data
await fs.writeFile(testFilePath, JSON.stringify({ count: 0 }));
const numInstances = 3;
const instances = Array.from(
{ length: numInstances },
() => new FileOperations()
);
const writes = instances.map((ops) =>
ops.modifyJson(testFilePath, (data: { count: number }) => ({
count: data.count + 1
}))
);
await Promise.all(writes);
// Cleanup all instances
await Promise.all(instances.map((ops) => ops.cleanup()));
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.count).toBe(numInstances);
});
});
describe('writeJson', () => {
it('should write JSON atomically', async () => {
const data = { test: 'value' };
await fileOps.writeJson(testFilePath, data);
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.test).toBe('value');
});
it('should not leave temp files on success', async () => {
await fileOps.writeJson(testFilePath, { test: true });
const files = await fs.readdir(tempDir);
const tempFiles = files.filter((f) => f.includes('.tmp'));
expect(tempFiles).toHaveLength(0);
// Also verify no lock files remain
const lockFiles = files.filter((f) => f.endsWith('.lock'));
expect(lockFiles).toHaveLength(0);
});
});
describe('cleanup', () => {
it('should clear cached writers', async () => {
// Write to create a cached writer
await fileOps.writeJson(testFilePath, { test: 1 });
// Cleanup
await fileOps.cleanup();
// Should still work after cleanup (creates new writer)
await fileOps.writeJson(testFilePath, { test: 2 });
const result = JSON.parse(await fs.readFile(testFilePath, 'utf-8'));
expect(result.test).toBe(2);
});
});
});

View File

@@ -1,16 +1,52 @@
/** /**
* @fileoverview File operations with atomic writes and locking * @fileoverview File operations with atomic writes and cross-process locking
*
* Uses steno for atomic writes (same pattern as workflow-state-manager.ts)
* and proper-lockfile for cross-process locking to prevent lost updates.
*/ */
import { constants } from 'node:fs'; import { constants } from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path';
import lockfile from 'proper-lockfile';
import { Writer } from 'steno';
import type { FileStorageData } from './format-handler.js'; import type { FileStorageData } from './format-handler.js';
/** /**
* Handles atomic file operations with locking mechanism * File locking configuration for cross-process safety
*/
const LOCK_OPTIONS = {
stale: 10000, // Consider lock stale after 10 seconds
retries: {
retries: 5,
factor: 2,
minTimeout: 100,
maxTimeout: 1000
},
realpath: false // Don't resolve symlinks (faster)
};
/**
* Handles atomic file operations with cross-process locking mechanism.
*
* Writers are cached for reuse. Call {@link cleanup} when disposing of
* long-lived instances to prevent memory leaks.
*/ */
export class FileOperations { export class FileOperations {
private fileLocks: Map<string, Promise<void>> = new Map(); /** Map of file paths to steno Writers for reuse */
private writers = new Map<string, Writer>();
/**
* Get or create a steno Writer for a file path
*/
private getWriter(filePath: string): Writer {
let writer = this.writers.get(filePath);
if (!writer) {
writer = new Writer(filePath);
this.writers.set(filePath, writer);
}
return writer;
}
/** /**
* Read and parse JSON file * Read and parse JSON file
@@ -31,52 +67,132 @@ export class FileOperations {
} }
/** /**
* Write JSON file with atomic operation and locking * Write JSON file with atomic operation and cross-process locking.
* Uses steno for atomic writes and proper-lockfile for cross-process safety.
* WARNING: This replaces the entire file. For concurrent modifications,
* use modifyJson() instead to prevent lost updates.
*/ */
async writeJson( async writeJson(
filePath: string, filePath: string,
data: FileStorageData | any data: FileStorageData | any
): Promise<void> { ): Promise<void> {
// Use file locking to prevent concurrent writes // Ensure file exists for locking (proper-lockfile requires this)
const lockKey = filePath; await this.ensureFileExists(filePath);
const existingLock = this.fileLocks.get(lockKey);
if (existingLock) {
await existingLock;
}
const lockPromise = this.performAtomicWrite(filePath, data);
this.fileLocks.set(lockKey, lockPromise);
// Acquire cross-process lock
let release: (() => Promise<void>) | null = null;
try { try {
await lockPromise; release = await lockfile.lock(filePath, LOCK_OPTIONS);
// Use steno Writer for atomic writes (same pattern as workflow-state-manager)
const content = JSON.stringify(data, null, 2);
const writer = this.getWriter(filePath);
await writer.write(content);
} finally { } finally {
this.fileLocks.delete(lockKey); if (release) {
try {
await release();
} catch (err: any) {
// Log but don't throw - lock may have been released already
// Other errors should be visible for debugging
if (process.env.DEBUG || process.env.TASKMASTER_DEBUG === 'true') {
console.warn(
`[WARN] Lock release warning for ${filePath}: ${err.message}`
);
}
}
}
} }
} }
/** /**
* Perform atomic write operation using temporary file * Read-modify-write JSON file with cross-process locking.
* Uses steno for atomic writes and proper-lockfile for cross-process safety.
* Re-reads file inside lock to prevent lost updates from stale snapshots.
* @param filePath - Path to the JSON file
* @param modifier - Function that receives current data and returns modified data
*/ */
private async performAtomicWrite(filePath: string, data: any): Promise<void> { async modifyJson<T = any>(
const tempPath = `${filePath}.tmp`; filePath: string,
modifier: (currentData: T) => T | Promise<T>
): Promise<void> {
// Ensure file exists for locking (proper-lockfile requires this)
await this.ensureFileExists(filePath);
// Acquire cross-process lock
let release: (() => Promise<void>) | null = null;
try { try {
// Write to temp file first release = await lockfile.lock(filePath, LOCK_OPTIONS);
const content = JSON.stringify(data, null, 2);
await fs.writeFile(tempPath, content, 'utf-8');
// Atomic rename // Re-read file INSIDE lock to get current state
await fs.rename(tempPath, filePath); // This prevents lost updates from stale snapshots
} catch (error: any) { let currentData: T;
// Clean up temp file if it exists
try { try {
await fs.unlink(tempPath); const content = await fs.readFile(filePath, 'utf-8');
} catch { currentData = JSON.parse(content);
// Ignore cleanup errors } catch (err: any) {
// Distinguish between expected empty/new files and actual corruption
if (err.code === 'ENOENT') {
// File doesn't exist yet - start fresh
currentData = {} as T;
} else if (err instanceof SyntaxError) {
// Check if it's just an empty file (our ensureFileExists writes '{}')
const content = await fs.readFile(filePath, 'utf-8').catch(() => '');
if (content.trim() === '' || content.trim() === '{}') {
currentData = {} as T;
} else {
// Actual JSON corruption - this is a serious error
throw new Error(
`Corrupted JSON in ${filePath}: ${err.message}. File contains: ${content.substring(0, 100)}...`
);
}
} else {
// Other errors (permission, I/O) should be surfaced
throw new Error(
`Failed to read ${filePath} for modification: ${err.message}`
);
}
} }
throw new Error(`Failed to write file ${filePath}: ${error.message}`); // Apply modification
const newData = await modifier(currentData);
// Write atomically using steno (same pattern as workflow-state-manager)
const content = JSON.stringify(newData, null, 2);
const writer = this.getWriter(filePath);
await writer.write(content);
} finally {
if (release) {
try {
await release();
} catch (err: any) {
// Log but don't throw - lock may have been released already
// Other errors should be visible for debugging
if (process.env.DEBUG || process.env.TASKMASTER_DEBUG === 'true') {
console.warn(
`[WARN] Lock release warning for ${filePath}: ${err.message}`
);
}
}
}
}
}
/**
* Ensure file exists for locking (proper-lockfile requires the file to exist).
* Uses atomic creation with 'wx' flag to prevent TOCTOU race conditions.
*/
private async ensureFileExists(filePath: string): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
try {
// Use 'wx' flag for atomic create - fails if file exists (prevents race)
await fs.writeFile(filePath, '{}', { flag: 'wx' });
} catch (err: any) {
// EEXIST is expected if another process created the file - that's fine
if (err.code !== 'EEXIST') {
throw err;
}
} }
} }
@@ -159,13 +275,14 @@ export class FileOperations {
} }
/** /**
* Clean up all pending file operations * Clean up resources - releases cached steno Writers
* Call this when the FileOperations instance is no longer needed
* to prevent memory leaks in long-running processes.
*/ */
async cleanup(): Promise<void> { async cleanup(): Promise<void> {
const locks = Array.from(this.fileLocks.values()); // Clear cached Writers to allow garbage collection
if (locks.length > 0) { // Note: steno Writers don't have explicit close methods;
await Promise.all(locks); // they handle file descriptor cleanup internally
} this.writers.clear();
this.fileLocks.clear();
} }
} }

View File

@@ -220,6 +220,7 @@ export class FileStorage implements IStorage {
/** /**
* Save tasks for a specific tag in the single tasks.json file * Save tasks for a specific tag in the single tasks.json file
* Uses modifyJson for atomic read-modify-write to prevent lost updates
*/ */
async saveTasks(tasks: Task[], tag?: string): Promise<void> { async saveTasks(tasks: Task[], tag?: string): Promise<void> {
const filePath = this.pathResolver.getTasksPath(); const filePath = this.pathResolver.getTasksPath();
@@ -228,17 +229,6 @@ export class FileStorage implements IStorage {
// Ensure directory exists // Ensure directory exists
await this.fileOps.ensureDir(this.pathResolver.getTasksDir()); await this.fileOps.ensureDir(this.pathResolver.getTasksDir());
// Get existing data from the file
let existingData: any = {};
try {
existingData = await this.fileOps.readJson(filePath);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw new Error(`Failed to read existing tasks: ${error.message}`);
}
// File doesn't exist, start with empty data
}
// Create metadata for this tag // Create metadata for this tag
const metadata: TaskMetadata = { const metadata: TaskMetadata = {
version: '1.0.0', version: '1.0.0',
@@ -251,43 +241,44 @@ export class FileStorage implements IStorage {
// Normalize tasks // Normalize tasks
const normalizedTasks = this.normalizeTaskIds(tasks); const normalizedTasks = this.normalizeTaskIds(tasks);
// Update the specific tag in the existing data structure // Use modifyJson for atomic read-modify-write
if ( await this.fileOps.modifyJson(filePath, (existingData: any) => {
this.formatHandler.detectFormat(existingData) === 'legacy' || // Update the specific tag in the existing data structure
Object.keys(existingData).some( if (
(key) => key !== 'tasks' && key !== 'metadata' this.formatHandler.detectFormat(existingData) === 'legacy' ||
) Object.keys(existingData).some(
) { (key) => key !== 'tasks' && key !== 'metadata'
// Legacy format - update/add the tag )
existingData[resolvedTag] = { ) {
tasks: normalizedTasks, // Legacy format - update/add the tag
metadata existingData[resolvedTag] = {
};
} else if (resolvedTag === 'master') {
// Standard format for master tag
existingData = {
tasks: normalizedTasks,
metadata
};
} else {
// Convert to legacy format when adding non-master tags
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || metadata;
existingData = {
master: {
tasks: masterTasks,
metadata: masterMetadata
},
[resolvedTag]: {
tasks: normalizedTasks, tasks: normalizedTasks,
metadata metadata
} };
}; return existingData;
} } else if (resolvedTag === 'master') {
// Standard format for master tag
return {
tasks: normalizedTasks,
metadata
};
} else {
// Convert to legacy format when adding non-master tags
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || metadata;
// Write the updated file return {
await this.fileOps.writeJson(filePath, existingData); master: {
tasks: masterTasks,
metadata: masterMetadata
},
[resolvedTag]: {
tasks: normalizedTasks,
metadata
}
};
}
});
} }
/** /**
@@ -594,6 +585,7 @@ export class FileStorage implements IStorage {
/** /**
* Create a new tag in the tasks.json file * Create a new tag in the tasks.json file
* Uses modifyJson for atomic read-modify-write to prevent lost updates
*/ */
async createTag( async createTag(
tagName: string, tagName: string,
@@ -602,62 +594,33 @@ export class FileStorage implements IStorage {
const filePath = this.pathResolver.getTasksPath(); const filePath = this.pathResolver.getTasksPath();
try { try {
const existingData = await this.fileOps.readJson(filePath); await this.fileOps.modifyJson(filePath, (existingData: any) => {
const format = this.formatHandler.detectFormat(existingData); const format = this.formatHandler.detectFormat(existingData);
if (format === 'legacy') { if (format === 'legacy') {
// Legacy format - add new tag key // Legacy format - add new tag key
if (tagName in existingData) { if (tagName in existingData) {
throw new TaskMasterError( throw new TaskMasterError(
`Tag ${tagName} already exists`, `Tag ${tagName} already exists`,
ERROR_CODES.VALIDATION_ERROR ERROR_CODES.VALIDATION_ERROR
);
}
// Get tasks to copy if specified
let tasksToCopy = [];
if (options?.copyFrom) {
if (
options.copyFrom in existingData &&
existingData[options.copyFrom].tasks
) {
tasksToCopy = JSON.parse(
JSON.stringify(existingData[options.copyFrom].tasks)
); );
} }
}
// Create new tag structure // Get tasks to copy if specified
existingData[tagName] = { let tasksToCopy: any[] = [];
tasks: tasksToCopy, if (options?.copyFrom) {
metadata: { if (
created: new Date().toISOString(), options.copyFrom in existingData &&
updatedAt: new Date().toISOString(), existingData[options.copyFrom].tasks
description: ) {
options?.description || tasksToCopy = JSON.parse(
`Tag created on ${new Date().toLocaleDateString()}`, JSON.stringify(existingData[options.copyFrom].tasks)
tags: [tagName] );
}
} }
};
await this.fileOps.writeJson(filePath, existingData); // Create new tag structure
} else { existingData[tagName] = {
// Standard format - need to convert to legacy format first
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || {};
// Get tasks to copy (from master in this case)
let tasksToCopy = [];
if (options?.copyFrom === 'master' || !options?.copyFrom) {
tasksToCopy = JSON.parse(JSON.stringify(masterTasks));
}
const newData = {
master: {
tasks: masterTasks,
metadata: { ...masterMetadata, tags: ['master'] }
},
[tagName]: {
tasks: tasksToCopy, tasks: tasksToCopy,
metadata: { metadata: {
created: new Date().toISOString(), created: new Date().toISOString(),
@@ -667,11 +630,39 @@ export class FileStorage implements IStorage {
`Tag created on ${new Date().toLocaleDateString()}`, `Tag created on ${new Date().toLocaleDateString()}`,
tags: [tagName] tags: [tagName]
} }
} };
};
await this.fileOps.writeJson(filePath, newData); return existingData;
} } else {
// Standard format - need to convert to legacy format first
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || {};
// Get tasks to copy (from master in this case)
let tasksToCopy: any[] = [];
if (options?.copyFrom === 'master' || !options?.copyFrom) {
tasksToCopy = JSON.parse(JSON.stringify(masterTasks));
}
return {
master: {
tasks: masterTasks,
metadata: { ...masterMetadata, tags: ['master'] }
},
[tagName]: {
tasks: tasksToCopy,
metadata: {
created: new Date().toISOString(),
updatedAt: new Date().toISOString(),
description:
options?.description ||
`Tag created on ${new Date().toLocaleDateString()}`,
tags: [tagName]
}
}
};
}
});
} catch (error: any) { } catch (error: any) {
if (error.code === 'ENOENT') { if (error.code === 'ENOENT') {
throw new Error('Tasks file not found - initialize project first'); throw new Error('Tasks file not found - initialize project first');
@@ -682,26 +673,41 @@ export class FileStorage implements IStorage {
/** /**
* Delete a tag from the single tasks.json file * Delete a tag from the single tasks.json file
* Uses modifyJson for atomic read-modify-write to prevent lost updates
*/ */
async deleteTag(tag: string): Promise<void> { async deleteTag(tag: string): Promise<void> {
const filePath = this.pathResolver.getTasksPath(); const filePath = this.pathResolver.getTasksPath();
try { try {
const existingData = await this.fileOps.readJson(filePath); // Use modifyJson to handle all cases atomically
let shouldDeleteFile = false;
if (this.formatHandler.detectFormat(existingData) === 'legacy') { await this.fileOps.modifyJson(filePath, (data: any) => {
// Legacy format - remove the tag key if (
if (tag in existingData) { this.formatHandler.detectFormat(data) !== 'legacy' &&
delete existingData[tag]; tag === 'master'
await this.fileOps.writeJson(filePath, existingData); ) {
} else { // Standard format - mark for file deletion after lock release
throw new Error(`Tag ${tag} not found`); shouldDeleteFile = true;
return data; // Return unchanged, we'll delete the file after
} }
} else if (tag === 'master') {
// Standard format - delete the entire file for master tag if (this.formatHandler.detectFormat(data) === 'legacy') {
// Legacy format - remove the tag key
if (tag in data) {
delete data[tag];
return data;
} else {
throw new Error(`Tag ${tag} not found`);
}
} else {
throw new Error(`Tag ${tag} not found in standard format`);
}
});
// Delete the file if we're removing master tag from standard format
if (shouldDeleteFile) {
await this.fileOps.deleteFile(filePath); await this.fileOps.deleteFile(filePath);
} else {
throw new Error(`Tag ${tag} not found in standard format`);
} }
} catch (error: any) { } catch (error: any) {
if (error.code === 'ENOENT') { if (error.code === 'ENOENT') {
@@ -713,44 +719,43 @@ export class FileStorage implements IStorage {
/** /**
* Rename a tag within the single tasks.json file * Rename a tag within the single tasks.json file
* Uses modifyJson for atomic read-modify-write to prevent lost updates
*/ */
async renameTag(oldTag: string, newTag: string): Promise<void> { async renameTag(oldTag: string, newTag: string): Promise<void> {
const filePath = this.pathResolver.getTasksPath(); const filePath = this.pathResolver.getTasksPath();
try { try {
const existingData = await this.fileOps.readJson(filePath); await this.fileOps.modifyJson(filePath, (existingData: any) => {
if (this.formatHandler.detectFormat(existingData) === 'legacy') {
// Legacy format - rename the tag key
if (oldTag in existingData) {
existingData[newTag] = existingData[oldTag];
delete existingData[oldTag];
if (this.formatHandler.detectFormat(existingData) === 'legacy') { // Update metadata tags array
// Legacy format - rename the tag key if (existingData[newTag].metadata) {
if (oldTag in existingData) { existingData[newTag].metadata.tags = [newTag];
existingData[newTag] = existingData[oldTag]; }
delete existingData[oldTag];
// Update metadata tags array return existingData;
if (existingData[newTag].metadata) { } else {
existingData[newTag].metadata.tags = [newTag]; throw new Error(`Tag ${oldTag} not found`);
} }
} else if (oldTag === 'master') {
// Convert standard format to legacy when renaming master
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || {};
await this.fileOps.writeJson(filePath, existingData); return {
[newTag]: {
tasks: masterTasks,
metadata: { ...masterMetadata, tags: [newTag] }
}
};
} else { } else {
throw new Error(`Tag ${oldTag} not found`); throw new Error(`Tag ${oldTag} not found in standard format`);
} }
} else if (oldTag === 'master') { });
// Convert standard format to legacy when renaming master
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || {};
const newData = {
[newTag]: {
tasks: masterTasks,
metadata: { ...masterMetadata, tags: [newTag] }
}
};
await this.fileOps.writeJson(filePath, newData);
} else {
throw new Error(`Tag ${oldTag} not found in standard format`);
}
} catch (error: any) { } catch (error: any) {
if (error.code === 'ENOENT') { if (error.code === 'ENOENT') {
throw new Error(`Tag ${oldTag} not found - file doesn't exist`); throw new Error(`Tag ${oldTag} not found - file doesn't exist`);

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

@@ -6,14 +6,15 @@
* *
* FILE STORAGE (local): * FILE STORAGE (local):
* - Main tasks: "1", "2", "15" * - Main tasks: "1", "2", "15"
* - Subtasks: "1.2", "15.3" (one level only) * - Subtasks: "1.2", "15.3"
* - Sub-subtasks: "1.2.3", "15.3.1"
* *
* API STORAGE (Hamster): * API STORAGE (Hamster):
* - Main tasks: "HAM-1", "ham-1", "HAM1", "ham1" (all normalized to "HAM-1") * - Main tasks: "HAM-1", "ham-1", "HAM1", "ham1" (all normalized to "HAM-1")
* - Variable-length prefixes: "PROJ-456", "TAS-1", "abc-999"
* - No subtasks (API doesn't use dot notation) * - No subtasks (API doesn't use dot notation)
* *
* NOT supported: * NOT supported:
* - Deep nesting: "1.2.3" (file storage only has one subtask level)
* - API subtasks: "HAM-1.2" (doesn't exist) * - API subtasks: "HAM-1.2" (doesn't exist)
*/ */
@@ -24,10 +25,10 @@ import { normalizeDisplayId } from '../../../common/schemas/task-id.schema.js';
* Pattern for validating a single task ID * Pattern for validating a single task ID
* Permissive input - accepts with or without hyphen for API IDs * Permissive input - accepts with or without hyphen for API IDs
* - Numeric: "1", "15", "999" * - Numeric: "1", "15", "999"
* - Numeric subtasks: "1.2" (one level only) * - Numeric subtasks: "1.2", "1.2.3" (multi-level supported)
* - API display IDs: "HAM-1", "ham-1", "HAM1", "ham1" * - API display IDs: "HAM-1", "PROJ-456", "TAS-1", "abc-999"
*/ */
export const TASK_ID_PATTERN = /^(\d+(\.\d+)?|[A-Za-z]{3}-?\d+)$/; export const TASK_ID_PATTERN = /^(\d+(\.\d+)*|[A-Za-z]+-?\d+)$/;
/** /**
* Validates a single task ID string * Validates a single task ID string
@@ -39,9 +40,10 @@ export const TASK_ID_PATTERN = /^(\d+(\.\d+)?|[A-Za-z]{3}-?\d+)$/;
* ```typescript * ```typescript
* isValidTaskIdFormat("1"); // true * isValidTaskIdFormat("1"); // true
* isValidTaskIdFormat("1.2"); // true * isValidTaskIdFormat("1.2"); // true
* isValidTaskIdFormat("1.2.3"); // true (multi-level subtasks)
* isValidTaskIdFormat("HAM-1"); // true * isValidTaskIdFormat("HAM-1"); // true
* isValidTaskIdFormat("PROJ-456"); // true (variable-length prefix)
* isValidTaskIdFormat("ham1"); // true (permissive input) * isValidTaskIdFormat("ham1"); // true (permissive input)
* isValidTaskIdFormat("1.2.3"); // false (too deep)
* isValidTaskIdFormat("HAM-1.2"); // false (no API subtasks) * isValidTaskIdFormat("HAM-1.2"); // false (no API subtasks)
* isValidTaskIdFormat("abc"); // false * isValidTaskIdFormat("abc"); // false
* ``` * ```

View File

@@ -10,6 +10,8 @@
}, },
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run",
"test:unit": "vitest run '**/*.spec.ts'",
"test:integration": "vitest run '**/*.test.ts'",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "biome check --write", "lint": "biome check --write",

View File

@@ -130,41 +130,21 @@
], ],
"codex-cli": [ "codex-cli": [
{ {
"id": "gpt-5", "id": "gpt-5.2-codex",
"swe_score": 0.749, "name": "GPT-5.2 Codex",
"swe_score": 0.82,
"cost_per_1m_tokens": { "cost_per_1m_tokens": {
"input": 0, "input": 0,
"output": 0 "output": 0
}, },
"allowed_roles": ["main", "fallback", "research"], "allowed_roles": ["main", "fallback", "research"],
"max_tokens": 128000, "max_tokens": 128000,
"supported": true "reasoning_efforts": ["none", "low", "medium", "high", "xhigh"],
},
{
"id": "gpt-5-codex",
"swe_score": 0.749,
"cost_per_1m_tokens": {
"input": 0,
"output": 0
},
"allowed_roles": ["main", "fallback", "research"],
"max_tokens": 128000,
"supported": true
},
{
"id": "gpt-5.1",
"swe_score": 0.76,
"cost_per_1m_tokens": {
"input": 0,
"output": 0
},
"allowed_roles": ["main", "fallback", "research"],
"max_tokens": 128000,
"reasoning_efforts": ["none", "low", "medium", "high"],
"supported": true "supported": true
}, },
{ {
"id": "gpt-5.1-codex-max", "id": "gpt-5.1-codex-max",
"name": "GPT-5.1 Codex Max",
"swe_score": 0.78, "swe_score": 0.78,
"cost_per_1m_tokens": { "cost_per_1m_tokens": {
"input": 0, "input": 0,
@@ -175,8 +155,22 @@
"reasoning_efforts": ["none", "low", "medium", "high", "xhigh"], "reasoning_efforts": ["none", "low", "medium", "high", "xhigh"],
"supported": true "supported": true
}, },
{
"id": "gpt-5.1-codex-mini",
"name": "GPT-5.1 Codex Mini",
"swe_score": 0.72,
"cost_per_1m_tokens": {
"input": 0,
"output": 0
},
"allowed_roles": ["main", "fallback", "research"],
"max_tokens": 128000,
"reasoning_efforts": ["none", "low", "medium", "high"],
"supported": true
},
{ {
"id": "gpt-5.2", "id": "gpt-5.2",
"name": "GPT-5.2",
"swe_score": 0.8, "swe_score": 0.8,
"cost_per_1m_tokens": { "cost_per_1m_tokens": {
"input": 0, "input": 0,

View File

@@ -10,6 +10,7 @@ import {
tryListTagsViaRemote, tryListTagsViaRemote,
tryUseTagViaRemote tryUseTagViaRemote
} from '@tm/bridge'; } from '@tm/bridge';
import { filterReadyTasks, isTaskComplete } from '@tm/core';
import { displayBanner, getStatusWithColor } from '../ui.js'; import { displayBanner, getStatusWithColor } from '../ui.js';
import { import {
findProjectRoot, findProjectRoot,
@@ -531,6 +532,7 @@ async function enhanceTagsWithMetadata(tasksPath, rawData, context = {}) {
* @param {Object} options - Options object * @param {Object} options - Options object
* @param {boolean} [options.showTaskCounts=true] - Whether to show task counts * @param {boolean} [options.showTaskCounts=true] - Whether to show task counts
* @param {boolean} [options.showMetadata=false] - Whether to show metadata * @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 {Object} context - Context object containing session and projectRoot
* @param {string} [context.projectRoot] - Project root path * @param {string} [context.projectRoot] - Project root path
* @param {Object} [context.mcpLog] - MCP logger object (optional) * @param {Object} [context.mcpLog] - MCP logger object (optional)
@@ -544,7 +546,11 @@ async function tags(
outputFormat = 'text' outputFormat = 'text'
) { ) {
const { mcpLog, projectRoot } = context; 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 // Create a consistent logFn object regardless of context
const logFn = mcpLog || { const logFn = mcpLog || {
@@ -619,12 +625,16 @@ async function tags(
const tasks = tagData.tasks || []; const tasks = tagData.tasks || [];
const metadata = tagData.metadata || {}; 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({ tagList.push({
name: tagName, name: tagName,
isCurrent: tagName === currentTag, isCurrent: tagName === currentTag,
completedTasks: tasks.filter( completedTasks: tasks.filter((t) => isTaskComplete(t.status)).length,
(t) => t.status === 'done' || t.status === 'completed' readyTasks: readyTasks.length,
).length,
tasks: tasks || [], tasks: tasks || [],
created: metadata.created || 'Unknown', created: metadata.created || 'Unknown',
description: metadata.description || 'No description' description: metadata.description || 'No description'
@@ -638,22 +648,32 @@ async function tags(
return a.name.localeCompare(b.name); 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 // For JSON output, return structured data
if (outputFormat === 'json') { if (outputFormat === 'json') {
return { return {
tags: tagList, tags: filteredTagList,
currentTag, currentTag,
totalTags: tagList.length totalTags: filteredTagList.length
}; };
} }
// For text output, display formatted table // For text output, display formatted table
if (outputFormat === 'text') { 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( console.log(
boxen(chalk.yellow('No tags found'), { boxen(chalk.yellow(message), {
padding: 1, padding: 1,
borderColor: 'yellow', borderColor: 'yellow',
borderStyle: 'round', borderStyle: 'round',
@@ -667,7 +687,8 @@ async function tags(
const headers = [chalk.cyan.bold('Tag Name')]; const headers = [chalk.cyan.bold('Tag Name')];
if (showTaskCounts) { if (showTaskCounts) {
headers.push(chalk.cyan.bold('Tasks')); 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) { if (showMetadata) {
headers.push(chalk.cyan.bold('Created')); headers.push(chalk.cyan.bold('Created'));
@@ -680,16 +701,16 @@ async function tags(
let colWidths; let colWidths;
if (showMetadata) { if (showMetadata) {
// With metadata: Tag Name, Tasks, Completed, Created, Description // With metadata: Tag Name, Tasks, Ready, Done, Created, Description
const widths = [0.25, 0.1, 0.12, 0.15, 0.38]; const widths = [0.22, 0.08, 0.08, 0.08, 0.14, 0.38];
colWidths = widths.map((w, i) => 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 { } else {
// Without metadata: Tag Name, Tasks, Completed // Without metadata: Tag Name, Tasks, Ready, Done
const widths = [0.7, 0.15, 0.15]; const widths = [0.6, 0.13, 0.13, 0.13];
colWidths = widths.map((w, i) => 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 // Add rows
tagList.forEach((tag) => { filteredTagList.forEach((tag) => {
const row = []; const row = [];
// Tag name with current indicator // Tag name with current indicator
@@ -711,6 +732,11 @@ async function tags(
if (showTaskCounts) { if (showTaskCounts) {
row.push(chalk.white(tag.tasks.length.toString())); 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())); row.push(chalk.green(tag.completedTasks.toString()));
} }
@@ -743,9 +769,9 @@ async function tags(
} }
return { return {
tags: tagList, tags: filteredTagList,
currentTag, currentTag,
totalTags: tagList.length totalTags: filteredTagList.length
}; };
} catch (error) { } catch (error) {
logFn.error(`Error listing tags: ${error.message}`); logFn.error(`Error listing tags: ${error.message}`);

View File

@@ -19,6 +19,278 @@ import * as gitUtils from './utils/git-utils.js';
// Global silent mode flag // Global silent mode flag
let silentMode = false; let silentMode = false;
// File locking configuration for cross-process safety
const LOCK_CONFIG = {
maxRetries: 5,
retryDelay: 100, // ms
staleLockAge: 10000 // 10 seconds
};
/**
* Async sleep helper
*/
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Synchronous sleep helper with Atomics.wait fallback
* Uses Atomics.wait when SharedArrayBuffer is available (proper non-busy wait),
* otherwise falls back to a busy-wait loop (less efficient but always works).
* @param {number} ms - Milliseconds to sleep
*/
function sleepSync(ms) {
// Check if SharedArrayBuffer and Atomics.wait are available
// They may not be available in some environments (e.g., browsers without proper headers)
if (
typeof SharedArrayBuffer !== 'undefined' &&
typeof Atomics !== 'undefined' &&
typeof Atomics.wait === 'function'
) {
try {
const sharedBuffer = new SharedArrayBuffer(4);
const int32 = new Int32Array(sharedBuffer);
Atomics.wait(int32, 0, 0, ms);
return;
} catch {
// Fall through to busy-wait fallback
}
}
// Fallback: busy-wait loop (less efficient but universally compatible)
// Note: This may cause high CPU usage for longer waits. Consider if this
// becomes an issue with large exponential backoff delays.
const end = Date.now() + ms;
while (Date.now() < end) {
// Busy wait - intentionally empty
}
}
/**
* Acquires an exclusive lock on a file and executes a callback
* Uses same lock file format as withFileLockSync for cross-process compatibility
* @param {string} filepath - Path to the file to lock
* @param {Function} callback - Async function to execute while holding the lock
* @param {Object} [options] - Options for lock behavior
* @param {boolean} [options.createIfMissing=false] - If true, creates the file with '{}' if it doesn't exist.
* Set to true for write operations. Leave false for read-only operations that should handle
* file-not-found scenarios in the callback.
* @returns {Promise<*>} Result of the callback
*/
async function withFileLock(filepath, callback, options = {}) {
const { createIfMissing = false } = options;
const fsPromises = fs.promises;
// Ensure parent directory exists
const dir = path.dirname(filepath);
await fsPromises.mkdir(dir, { recursive: true });
// Only create the file if explicitly requested (for write operations)
if (createIfMissing) {
try {
// Use 'wx' flag for atomic create - fails if file exists (prevents race)
await fsPromises.writeFile(filepath, '{}', { flag: 'wx' });
} catch (err) {
// EEXIST is expected if another process created the file - that's fine
if (err.code !== 'EEXIST') {
throw err;
}
}
}
const lockPath = `${filepath}.lock`;
const { maxRetries, retryDelay, staleLockAge } = LOCK_CONFIG;
// Try to acquire lock with retries
let acquired = false;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
// Try to create lock file exclusively first
const lockContent = JSON.stringify({
pid: process.pid,
timestamp: Date.now()
});
await fsPromises.writeFile(lockPath, lockContent, { flag: 'wx' });
acquired = true;
break;
} catch (err) {
if (err.code === 'EEXIST') {
// Lock file exists - check if it's stale
try {
const lockStat = await fsPromises.stat(lockPath);
const age = Date.now() - lockStat.mtimeMs;
if (age > staleLockAge) {
// Stale lock - use atomic rename to safely take ownership
// This prevents race where we delete another process's fresh lock
const stalePath = `${lockPath}.stale.${process.pid}.${Date.now()}`;
try {
await fsPromises.rename(lockPath, stalePath);
// We successfully took ownership of the stale lock
// Clean it up and retry immediately
try {
await fsPromises.unlink(stalePath);
} catch {
// Ignore cleanup errors
}
continue; // Retry lock acquisition
} catch {
// Rename failed - another process handled it or lock was refreshed
// Just continue to retry
}
}
} catch (statErr) {
// Lock was removed between writeFile and stat - retry immediately
if (statErr.code === 'ENOENT') {
continue;
}
throw statErr;
}
// Lock exists and isn't stale (or we couldn't handle it), wait and retry
if (attempt < maxRetries - 1) {
const waitMs = retryDelay * Math.pow(2, attempt);
await sleep(waitMs);
}
} else {
throw err;
}
}
}
if (!acquired) {
throw new Error(
`Failed to acquire lock on ${filepath} after ${maxRetries} attempts`
);
}
try {
return await callback();
} finally {
// Release lock
try {
await fsPromises.unlink(lockPath);
} catch (releaseError) {
// Always log lock release failures - they indicate potential issues
log(
'warn',
`Failed to release lock for ${filepath}: ${releaseError.message}`
);
}
}
}
/**
* Synchronous version of file locking for compatibility with existing sync code
* Uses a lock file approach with retries and stale lock detection
* @param {string} filepath - Path to the file to lock
* @param {Function} callback - Sync function to execute while holding the lock
* @param {Object} [options] - Options for lock behavior
* @param {boolean} [options.createIfMissing=false] - If true, creates the file with '{}' if it doesn't exist.
* Set to true for write operations. Leave false for read-only operations that should handle
* file-not-found scenarios in the callback.
* @returns {*} Result of the callback
*/
function withFileLockSync(filepath, callback, options = {}) {
const { createIfMissing = false } = options;
// Ensure parent directory exists
const dir = path.dirname(filepath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Only create the file if explicitly requested (for write operations)
if (createIfMissing) {
try {
// Use 'wx' flag for atomic create - fails if file exists (prevents race)
fs.writeFileSync(filepath, '{}', { flag: 'wx' });
} catch (err) {
// EEXIST is expected if another process created the file - that's fine
if (err.code !== 'EEXIST') {
throw err;
}
}
}
const lockPath = `${filepath}.lock`;
const { maxRetries, retryDelay, staleLockAge } = LOCK_CONFIG;
// Try to acquire lock with retries
let acquired = false;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
// Try to create lock file exclusively first
const lockContent = JSON.stringify({
pid: process.pid,
timestamp: Date.now()
});
fs.writeFileSync(lockPath, lockContent, { flag: 'wx' });
acquired = true;
break;
} catch (err) {
if (err.code === 'EEXIST') {
// Lock file exists - check if it's stale
try {
const lockStat = fs.statSync(lockPath);
const age = Date.now() - lockStat.mtimeMs;
if (age > staleLockAge) {
// Stale lock - use atomic rename to safely take ownership
// This prevents race where we delete another process's fresh lock
const stalePath = `${lockPath}.stale.${process.pid}.${Date.now()}`;
try {
fs.renameSync(lockPath, stalePath);
// We successfully took ownership of the stale lock
// Clean it up and retry immediately
try {
fs.unlinkSync(stalePath);
} catch {
// Ignore cleanup errors
}
continue; // Retry lock acquisition
} catch {
// Rename failed - another process handled it or lock was refreshed
// Just continue to retry
}
}
} catch (statErr) {
// Lock was removed between writeFile and stat - retry immediately
if (statErr.code === 'ENOENT') {
continue;
}
throw statErr;
}
// Lock exists and isn't stale (or we couldn't handle it), wait and retry
if (attempt < maxRetries - 1) {
const waitMs = retryDelay * Math.pow(2, attempt);
sleepSync(waitMs);
}
} else {
throw err;
}
}
}
if (!acquired) {
throw new Error(
`Failed to acquire lock on ${filepath} after ${maxRetries} attempts`
);
}
try {
return callback();
} finally {
// Release lock
try {
fs.unlinkSync(lockPath);
} catch (releaseError) {
// Always log lock release failures - they indicate potential issues
log(
'warn',
`Failed to release lock for ${filepath}: ${releaseError.message}`
);
}
}
}
// --- Environment Variable Resolution Utility --- // --- Environment Variable Resolution Utility ---
/** /**
* Resolves an environment variable's value. * Resolves an environment variable's value.
@@ -703,6 +975,7 @@ function markMigrationForNotice(tasksJsonPath) {
/** /**
* Writes and saves a JSON file. Handles tagged task lists properly. * Writes and saves a JSON file. Handles tagged task lists properly.
* Uses cross-process file locking and atomic writes to prevent race conditions.
* @param {string} filepath - Path to the JSON file * @param {string} filepath - Path to the JSON file
* @param {Object} data - Data to write (can be resolved tag data or raw tagged data) * @param {Object} data - Data to write (can be resolved tag data or raw tagged data)
* @param {string} projectRoot - Optional project root for tag context * @param {string} projectRoot - Optional project root for tag context
@@ -712,107 +985,172 @@ function writeJSON(filepath, data, projectRoot = null, tag = null) {
const isDebug = process.env.TASKMASTER_DEBUG === 'true'; const isDebug = process.env.TASKMASTER_DEBUG === 'true';
try { try {
let finalData = data; // Use file locking to prevent concurrent write race conditions
// This ensures the entire read-modify-write cycle is atomic
// createIfMissing: true because writeJSON is a write operation
withFileLockSync(
filepath,
() => {
let finalData = data;
// If data represents resolved tag data but lost _rawTaggedData (edge-case observed in MCP path) // If data represents resolved tag data but lost _rawTaggedData (edge-case observed in MCP path)
if ( if (
!data._rawTaggedData && data &&
projectRoot && !data._rawTaggedData &&
Array.isArray(data.tasks) && projectRoot &&
!hasTaggedStructure(data) Array.isArray(data.tasks) &&
) { !hasTaggedStructure(data)
const resolvedTag = tag || getCurrentTag(projectRoot); ) {
const resolvedTag = tag || getCurrentTag(projectRoot);
if (isDebug) { if (isDebug) {
console.log( console.log(
`writeJSON: Detected resolved tag data missing _rawTaggedData. Re-reading raw data to prevent data loss for tag '${resolvedTag}'.` `writeJSON: Detected resolved tag data missing _rawTaggedData. Re-reading raw data to prevent data loss for tag '${resolvedTag}'.`
); );
} }
// Re-read the full file to get the complete tagged structure // Re-read the full file to get the complete tagged structure
const rawFullData = JSON.parse(fs.readFileSync(filepath, 'utf8')); // This is now safe because we hold the lock
let rawFullData = {};
// Merge the updated data into the full structure try {
finalData = { rawFullData = JSON.parse(fs.readFileSync(filepath, 'utf8'));
...rawFullData, } catch (readError) {
[resolvedTag]: { // File might be empty or invalid, start fresh
// Preserve existing tag metadata if it exists, otherwise use what's passed if (isDebug) {
...(rawFullData[resolvedTag]?.metadata || {}), console.log(
...(data.metadata ? { metadata: data.metadata } : {}), `writeJSON: Could not read existing file, starting fresh: ${readError.message}`
tasks: data.tasks // The updated tasks array is the source of truth here );
}
};
}
// If we have _rawTaggedData, this means we're working with resolved tag data
// and need to merge it back into the full tagged structure
else if (data && data._rawTaggedData && projectRoot) {
const resolvedTag = tag || getCurrentTag(projectRoot);
// Get the original tagged data
const originalTaggedData = data._rawTaggedData;
// Create a clean copy of the current resolved data (without internal properties)
const { _rawTaggedData, tag: _, ...cleanResolvedData } = data;
// Update the specific tag with the resolved data
finalData = {
...originalTaggedData,
[resolvedTag]: cleanResolvedData
};
if (isDebug) {
console.log(
`writeJSON: Merging resolved data back into tag '${resolvedTag}'`
);
}
}
// Clean up any internal properties that shouldn't be persisted
let cleanData = finalData;
if (cleanData && typeof cleanData === 'object') {
// Remove any _rawTaggedData or tag properties from root level
const { _rawTaggedData, tag: tagProp, ...rootCleanData } = cleanData;
cleanData = rootCleanData;
// Additional cleanup for tag objects
if (typeof cleanData === 'object' && !Array.isArray(cleanData)) {
const finalCleanData = {};
for (const [key, value] of Object.entries(cleanData)) {
if (
value &&
typeof value === 'object' &&
Array.isArray(value.tasks)
) {
// This is a tag object - clean up any rogue root-level properties
const { created, description, ...cleanTagData } = value;
// Only keep the description if there's no metadata.description
if (
description &&
(!cleanTagData.metadata || !cleanTagData.metadata.description)
) {
cleanTagData.description = description;
} }
}
finalCleanData[key] = cleanTagData; // Merge the updated data into the full structure
} else { finalData = {
finalCleanData[key] = value; ...rawFullData,
[resolvedTag]: {
// Preserve existing tag metadata, merged with any new metadata
metadata: {
...(rawFullData[resolvedTag]?.metadata || {}),
...(data.metadata || {})
},
tasks: data.tasks // The updated tasks array is the source of truth here
}
};
}
// If we have _rawTaggedData, this means we're working with resolved tag data
// and need to merge it back into the full tagged structure
else if (data && data._rawTaggedData && projectRoot) {
const resolvedTag = tag || getCurrentTag(projectRoot);
// IMPORTANT: Re-read the file to get the CURRENT state instead of using
// potentially stale _rawTaggedData. This prevents lost updates from other processes.
let currentTaggedData;
try {
currentTaggedData = JSON.parse(fs.readFileSync(filepath, 'utf8'));
} catch (readError) {
// Fall back to _rawTaggedData if file can't be read
currentTaggedData = data._rawTaggedData;
if (isDebug) {
console.log(
`writeJSON: Using _rawTaggedData as fallback: ${readError.message}`
);
}
}
// Create a clean copy of the current resolved data (without internal properties)
const { _rawTaggedData, tag: _, ...cleanResolvedData } = data;
// Update the specific tag with the resolved data, preserving other tags
finalData = {
...currentTaggedData,
[resolvedTag]: cleanResolvedData
};
if (isDebug) {
console.log(
`writeJSON: Merging resolved data back into tag '${resolvedTag}'`
);
} }
} }
cleanData = finalCleanData;
}
}
fs.writeFileSync(filepath, JSON.stringify(cleanData, null, 2), 'utf8'); // Clean up any internal properties that shouldn't be persisted
let cleanData = finalData;
if (cleanData && typeof cleanData === 'object') {
// Remove any _rawTaggedData or tag properties from root level
const { _rawTaggedData, tag: tagProp, ...rootCleanData } = cleanData;
cleanData = rootCleanData;
if (isDebug) { // Additional cleanup for tag objects
console.log(`writeJSON: Successfully wrote to ${filepath}`); if (typeof cleanData === 'object' && !Array.isArray(cleanData)) {
} const finalCleanData = {};
for (const [key, value] of Object.entries(cleanData)) {
if (
value &&
typeof value === 'object' &&
Array.isArray(value.tasks)
) {
// This is a tag object - clean up any rogue root-level properties
// Move created/description to metadata if they're at root level
const { created, description, ...cleanTagData } = value;
// Ensure metadata object exists
if (!cleanTagData.metadata) {
cleanTagData.metadata = {};
}
// Preserve created timestamp in metadata if it exists at root level
if (created && !cleanTagData.metadata.created) {
cleanTagData.metadata.created = created;
}
// Preserve description in metadata if it exists at root level
if (description && !cleanTagData.metadata.description) {
cleanTagData.metadata.description = description;
}
finalCleanData[key] = cleanTagData;
} else {
finalCleanData[key] = value;
}
}
cleanData = finalCleanData;
}
}
// Use atomic write: write to temp file then rename
// This prevents partial writes from corrupting the file
const tempPath = `${filepath}.tmp.${process.pid}`;
try {
fs.writeFileSync(
tempPath,
JSON.stringify(cleanData, null, 2),
'utf8'
);
fs.renameSync(tempPath, filepath);
} catch (writeError) {
// Clean up temp file on failure
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch {
// Ignore cleanup errors
}
throw writeError;
}
if (isDebug) {
console.log(`writeJSON: Successfully wrote to ${filepath}`);
}
},
{ createIfMissing: true }
);
} catch (error) { } catch (error) {
log('error', `Error writing JSON file ${filepath}:`, error.message); log('error', `Error writing JSON file ${filepath}:`, error.message);
if (isDebug) { if (isDebug) {
log('error', 'Full error details:', error); log('error', 'Full error details:', error);
} }
// Re-throw so callers know the write failed
throw error;
} }
} }
@@ -1616,5 +1954,7 @@ export {
flattenTasksWithSubtasks, flattenTasksWithSubtasks,
ensureTagMetadata, ensureTagMetadata,
stripAnsiCodes, stripAnsiCodes,
normalizeTaskIds normalizeTaskIds,
withFileLock,
withFileLockSync
}; };

View File

@@ -0,0 +1,595 @@
/**
* Tests for file locking and atomic write functionality
* Verifies that concurrent access to tasks.json is properly serialized
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Import the utils module
const utilsPath = path.join(__dirname, '../../scripts/modules/utils.js');
describe('File Locking and Atomic Writes', () => {
let tempDir;
let testFilePath;
let utils;
beforeEach(async () => {
// Create a temp directory for each test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taskmaster-test-'));
testFilePath = path.join(tempDir, 'tasks.json');
// Initialize with empty tasks structure
fs.writeFileSync(
testFilePath,
JSON.stringify(
{
master: {
tasks: [],
metadata: { created: new Date().toISOString() }
}
},
null,
2
)
);
// Import utils fresh for each test
utils = await import(utilsPath + `?cachebust=${Date.now()}`);
});
afterEach(() => {
// Clean up temp directory and any lock files
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('withFileLockSync', () => {
it('should execute callback while holding lock', () => {
const result = utils.withFileLockSync(testFilePath, () => {
return 'callback executed';
});
expect(result).toBe('callback executed');
});
it('should release lock after callback completes', () => {
utils.withFileLockSync(testFilePath, () => {
// First lock
});
// Should be able to acquire lock again
const result = utils.withFileLockSync(testFilePath, () => {
return 'second lock acquired';
});
expect(result).toBe('second lock acquired');
});
it('should release lock even if callback throws', () => {
expect(() => {
utils.withFileLockSync(testFilePath, () => {
throw new Error('Test error');
});
}).toThrow('Test error');
// Should still be able to acquire lock
const result = utils.withFileLockSync(testFilePath, () => 'recovered');
expect(result).toBe('recovered');
});
it('should create file if createIfMissing is true', () => {
const newFilePath = path.join(tempDir, 'new-file.json');
utils.withFileLockSync(
newFilePath,
() => {
// Lock acquired on new file
},
{ createIfMissing: true }
);
expect(fs.existsSync(newFilePath)).toBe(true);
});
it('should not create file if createIfMissing is false (default)', () => {
const newFilePath = path.join(tempDir, 'should-not-exist.json');
utils.withFileLockSync(newFilePath, () => {
// Lock acquired, but file should not be created
});
expect(fs.existsSync(newFilePath)).toBe(false);
});
it('should clean up lock file after completion', () => {
utils.withFileLockSync(testFilePath, () => {
// Do something
});
// Lock file should be cleaned up
expect(fs.existsSync(`${testFilePath}.lock`)).toBe(false);
});
it('should clean up lock file even on error', () => {
try {
utils.withFileLockSync(testFilePath, () => {
throw new Error('Test error');
});
} catch {
// Expected
}
// Lock file should be cleaned up
expect(fs.existsSync(`${testFilePath}.lock`)).toBe(false);
});
});
describe('withFileLock (async)', () => {
it('should execute async callback while holding lock', async () => {
const result = await utils.withFileLock(testFilePath, async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return 'async callback executed';
});
expect(result).toBe('async callback executed');
});
it('should release lock after async callback completes', async () => {
await utils.withFileLock(testFilePath, async () => {
// First lock
});
// Should be able to acquire lock again
const result = await utils.withFileLock(testFilePath, async () => {
return 'second lock acquired';
});
expect(result).toBe('second lock acquired');
});
it('should release lock even if async callback rejects', async () => {
await expect(
utils.withFileLock(testFilePath, async () => {
throw new Error('Async error');
})
).rejects.toThrow('Async error');
// Should still be able to acquire lock
const result = await utils.withFileLock(
testFilePath,
async () => 'recovered'
);
expect(result).toBe('recovered');
});
it('should create file if createIfMissing is true', async () => {
const newFilePath = path.join(tempDir, 'new-async-file.json');
await utils.withFileLock(
newFilePath,
async () => {
// Lock acquired on new file
},
{ createIfMissing: true }
);
expect(fs.existsSync(newFilePath)).toBe(true);
});
it('should not create file if createIfMissing is false (default)', async () => {
const newFilePath = path.join(tempDir, 'should-not-exist-async.json');
await utils.withFileLock(newFilePath, async () => {
// Lock acquired, but file should not be created
});
expect(fs.existsSync(newFilePath)).toBe(false);
});
it('should clean up lock file after completion', async () => {
await utils.withFileLock(testFilePath, async () => {
// Do something
});
// Lock file should be cleaned up
expect(fs.existsSync(`${testFilePath}.lock`)).toBe(false);
});
it('should clean up lock file even on error', async () => {
try {
await utils.withFileLock(testFilePath, async () => {
throw new Error('Test error');
});
} catch {
// Expected
}
// Lock file should be cleaned up
expect(fs.existsSync(`${testFilePath}.lock`)).toBe(false);
});
it('should serialize truly concurrent writes', async () => {
const numConcurrentWrites = 5;
const writes = [];
for (let i = 0; i < numConcurrentWrites; i++) {
writes.push(
utils.withFileLock(testFilePath, async () => {
const data = JSON.parse(fs.readFileSync(testFilePath, 'utf8'));
data.master.tasks.push({
id: String(data.master.tasks.length + 1)
});
fs.writeFileSync(testFilePath, JSON.stringify(data, null, 2));
})
);
}
await Promise.all(writes);
const finalData = JSON.parse(fs.readFileSync(testFilePath, 'utf8'));
expect(finalData.master.tasks).toHaveLength(numConcurrentWrites);
});
});
describe('writeJSON atomic writes', () => {
it('should not leave temp files on success', () => {
// Create a tagged structure that writeJSON expects
const taggedData = {
master: {
tasks: [{ id: '1', title: 'Test task', status: 'pending' }],
metadata: { created: new Date().toISOString() }
}
};
utils.writeJSON(testFilePath, taggedData, null, null);
const files = fs.readdirSync(tempDir);
const tempFiles = files.filter((f) => f.includes('.tmp'));
expect(tempFiles).toHaveLength(0);
});
it('should preserve data from other tags when writing to one tag', () => {
// Set up initial data with multiple tags
const initialData = {
master: {
tasks: [{ id: '1', title: 'Master task', status: 'pending' }],
metadata: { created: new Date().toISOString() }
},
feature: {
tasks: [{ id: '1', title: 'Feature task', status: 'pending' }],
metadata: { created: new Date().toISOString() }
}
};
fs.writeFileSync(testFilePath, JSON.stringify(initialData, null, 2));
// Write directly with tagged structure (simulating what commands do internally)
const updatedData = {
...initialData,
master: {
...initialData.master,
tasks: [
{ id: '1', title: 'Updated master task', status: 'pending' },
{ id: '2', title: 'New task', status: 'pending' }
]
}
};
utils.writeJSON(testFilePath, updatedData, null, null);
const written = JSON.parse(fs.readFileSync(testFilePath, 'utf8'));
// Master should be updated
expect(written.master.tasks).toHaveLength(2);
expect(written.master.tasks[0].title).toBe('Updated master task');
// Feature should be preserved
expect(written.feature.tasks).toHaveLength(1);
expect(written.feature.tasks[0].title).toBe('Feature task');
});
it('should not leave lock files on success', () => {
const taggedData = {
master: {
tasks: [{ id: '1', title: 'Test task', status: 'pending' }],
metadata: {}
}
};
utils.writeJSON(testFilePath, taggedData, null, null);
expect(fs.existsSync(`${testFilePath}.lock`)).toBe(false);
});
});
describe('Concurrent write simulation', () => {
it('should handle rapid sequential writes without data loss', () => {
// Perform many rapid writes
const numWrites = 10;
for (let i = 0; i < numWrites; i++) {
// Read current data
let currentData;
try {
currentData = JSON.parse(fs.readFileSync(testFilePath, 'utf8'));
} catch {
currentData = { master: { tasks: [], metadata: {} } };
}
// Add a new task
currentData.master.tasks.push({
id: String(i + 1),
title: `Task ${i + 1}`,
status: 'pending'
});
// Write with locking
utils.writeJSON(testFilePath, currentData, null, null);
}
const finalData = JSON.parse(fs.readFileSync(testFilePath, 'utf8'));
expect(finalData.master.tasks).toHaveLength(numWrites);
});
});
describe('True concurrent process writes', () => {
it('should handle multiple processes writing simultaneously without data loss', async () => {
const { spawn } = await import('child_process');
const numProcesses = 5;
const tasksPerProcess = 3;
// Create a worker script with inline locking implementation
// This mirrors the withFileLockSync implementation but without external dependencies
const workerScript = `
import fs from 'fs';
const filepath = process.argv[2];
const processId = process.argv[3];
const numTasks = parseInt(process.argv[4], 10);
const LOCK_CONFIG = {
maxRetries: 10,
retryDelay: 50,
staleLockAge: 10000
};
function sleepSync(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {
// Busy wait
}
}
function withFileLockSync(filepath, callback) {
const lockPath = filepath + '.lock';
const { maxRetries, retryDelay, staleLockAge } = LOCK_CONFIG;
let acquired = false;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const lockContent = JSON.stringify({
pid: process.pid,
timestamp: Date.now()
});
fs.writeFileSync(lockPath, lockContent, { flag: 'wx' });
acquired = true;
break;
} catch (err) {
if (err.code === 'EEXIST') {
try {
const lockStat = fs.statSync(lockPath);
const age = Date.now() - lockStat.mtimeMs;
if (age > staleLockAge) {
const stalePath = lockPath + '.stale.' + process.pid + '.' + Date.now();
try {
fs.renameSync(lockPath, stalePath);
try { fs.unlinkSync(stalePath); } catch {}
continue;
} catch {}
}
} catch (statErr) {
if (statErr.code === 'ENOENT') continue;
throw statErr;
}
if (attempt < maxRetries - 1) {
const waitMs = retryDelay * Math.pow(2, attempt);
sleepSync(waitMs);
}
} else {
throw err;
}
}
}
if (!acquired) {
throw new Error('Failed to acquire lock on ' + filepath + ' after ' + maxRetries + ' attempts');
}
try {
return callback();
} finally {
try {
fs.unlinkSync(lockPath);
} catch {}
}
}
async function main() {
for (let i = 0; i < numTasks; i++) {
withFileLockSync(filepath, () => {
let currentData;
try {
currentData = JSON.parse(fs.readFileSync(filepath, 'utf8'));
} catch {
currentData = { master: { tasks: [], metadata: {} } };
}
currentData.master.tasks.push({
id: 'P' + processId + '-' + (i + 1),
title: 'Task from process ' + processId + ' #' + (i + 1),
status: 'pending'
});
fs.writeFileSync(filepath, JSON.stringify(currentData, null, 2), 'utf8');
});
// Small delay to increase chance of interleaving
await new Promise(r => setTimeout(r, 10));
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});
`;
// Write worker script to temp file
const workerPath = path.join(tempDir, 'worker.mjs');
fs.writeFileSync(workerPath, workerScript);
// Spawn multiple processes that write concurrently
const processes = [];
for (let i = 0; i < numProcesses; i++) {
const proc = spawn(
'node',
[workerPath, testFilePath, String(i), String(tasksPerProcess)],
{
stdio: ['ignore', 'pipe', 'pipe']
}
);
processes.push(
new Promise((resolve, reject) => {
let stderr = '';
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(`Process ${i} exited with code ${code}: ${stderr}`)
);
}
});
proc.on('error', reject);
})
);
}
// Wait for all processes to complete
await Promise.all(processes);
// Verify all tasks were written
const finalData = JSON.parse(fs.readFileSync(testFilePath, 'utf8'));
const expectedTasks = numProcesses * tasksPerProcess;
expect(finalData.master.tasks.length).toBe(expectedTasks);
// Verify we have tasks from all processes
for (let i = 0; i < numProcesses; i++) {
const tasksFromProcess = finalData.master.tasks.filter((t) =>
t.id.startsWith(`P${i}-`)
);
expect(tasksFromProcess.length).toBe(tasksPerProcess);
}
}, 30000); // 30 second timeout for concurrent test
});
});
describe('readJSON', () => {
let tempDir;
let testFilePath;
let utils;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taskmaster-test-'));
testFilePath = path.join(tempDir, 'tasks.json');
// Create .taskmaster directory for state.json
fs.mkdirSync(path.join(tempDir, '.taskmaster'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, '.taskmaster', 'state.json'),
JSON.stringify({
currentTag: 'master'
})
);
utils = await import(utilsPath + `?cachebust=${Date.now()}`);
});
afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('should read tagged task data correctly', () => {
const data = {
master: {
tasks: [{ id: '1', title: 'Test', status: 'pending' }],
metadata: { created: new Date().toISOString() }
}
};
fs.writeFileSync(testFilePath, JSON.stringify(data, null, 2));
const result = utils.readJSON(testFilePath, tempDir, 'master');
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].title).toBe('Test');
});
it('should return null for non-existent file', () => {
const result = utils.readJSON(path.join(tempDir, 'nonexistent.json'));
expect(result).toBeNull();
});
});
describe('Lock file stale detection', () => {
let tempDir;
let testFilePath;
let utils;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taskmaster-test-'));
testFilePath = path.join(tempDir, 'tasks.json');
fs.writeFileSync(testFilePath, '{}');
utils = await import(utilsPath + `?cachebust=${Date.now()}`);
});
afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('should remove stale lock files', () => {
const lockPath = `${testFilePath}.lock`;
// Create a lock file with old timestamp
fs.writeFileSync(
lockPath,
JSON.stringify({
pid: 99999, // Non-existent PID
timestamp: Date.now() - 20000 // 20 seconds ago
})
);
// Touch the file to make it old
const pastTime = new Date(Date.now() - 20000);
fs.utimesSync(lockPath, pastTime, pastTime);
// Should be able to acquire lock despite existing lock file
const result = utils.withFileLockSync(testFilePath, () => 'acquired');
expect(result).toBe('acquired');
});
});

View File

@@ -113,3 +113,180 @@ describe('Tag Management writeJSON context preservation', () => {
expect(tagNames).not.toContain('copy'); 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);
});
});

View File

@@ -39,6 +39,36 @@
"!{packages,apps}/**/node_modules/**" "!{packages,apps}/**/node_modules/**"
], ],
"outputLogs": "new-only" "outputLogs": "new-only"
},
"test": {
"dependsOn": ["^build"],
"inputs": [
"$TURBO_DEFAULT$",
"!{packages,apps}/**/dist/**",
"!{packages,apps}/**/node_modules/**"
],
"outputLogs": "new-only",
"cache": false
},
"test:unit": {
"dependsOn": ["^build"],
"inputs": [
"$TURBO_DEFAULT$",
"!{packages,apps}/**/dist/**",
"!{packages,apps}/**/node_modules/**"
],
"outputLogs": "new-only",
"cache": false
},
"test:integration": {
"dependsOn": ["^build"],
"inputs": [
"$TURBO_DEFAULT$",
"!{packages,apps}/**/dist/**",
"!{packages,apps}/**/node_modules/**"
],
"outputLogs": "new-only",
"cache": false
} }
}, },
"globalDependencies": ["turbo.json", "tsconfig.json", ".env*"] "globalDependencies": ["turbo.json", "tsconfig.json", ".env*"]