feat: reintroduce task-master generate command for task file generation (#1446)

This commit is contained in:
Ralph Khreish
2025-11-25 18:32:50 +01:00
committed by GitHub
parent 1936f2aba0
commit 2316e94b28
25 changed files with 1602 additions and 22 deletions

View File

@@ -0,0 +1,11 @@
---
"task-master-ai": minor
---
Bring back `task-master generate` as a command and mcp tool (after popular demand)
- Generated files are now `.md` instead of `.txt`
- They also follow the markdownlint format making them look like more standard md files
- added parameters to generate allowing you to generate with the `--tag` flag
- If I am on an active tag and want to generate files from another tag, I can with the tag parameter
- See `task-master generate --help` for more information.

View File

@@ -10,6 +10,7 @@ import { AutopilotCommand } from './commands/autopilot/index.js';
import { BriefsCommand } from './commands/briefs.command.js';
import { ContextCommand } from './commands/context.command.js';
import { ExportCommand } from './commands/export.command.js';
import { GenerateCommand } from './commands/generate.command.js';
// Import all commands
import { ListTasksCommand } from './commands/list.command.js';
import { NextCommand } from './commands/next.command.js';
@@ -105,6 +106,12 @@ export class CommandRegistry {
description: 'Manage briefs (Hamster only)',
commandClass: BriefsCommand as any,
category: 'task'
},
{
name: 'generate',
description: 'Generate individual task files from tasks.json',
commandClass: GenerateCommand as any,
category: 'utility'
}
];

View File

@@ -0,0 +1,251 @@
/**
* @fileoverview Generate command for generating individual task files from tasks.json
* This is a thin presentation layer over @tm/core
*/
import path from 'node:path';
import {
type GenerateTaskFilesResult,
type TmCore,
createTmCore
} from '@tm/core';
import boxen from 'boxen';
import chalk from 'chalk';
import { Command } from 'commander';
import { displayCommandHeader } from '../utils/display-helpers.js';
import { displayError } from '../utils/error-handler.js';
import { getProjectRoot } from '../utils/project-root.js';
/**
* Options interface for the generate command
*/
export interface GenerateCommandOptions {
tag?: string;
output?: string;
project?: string;
format?: 'text' | 'json';
}
/**
* GenerateCommand extending Commander's Command class
* Generates individual task files from tasks.json
*/
export class GenerateCommand extends Command {
private tmCore?: TmCore;
private lastResult?: GenerateTaskFilesResult;
constructor(name?: string) {
super(name || 'generate');
// Configure the command
this.description('Generate individual task files from tasks.json')
.option('-t, --tag <tag>', 'Tag context for task operations')
.option(
'-o, --output <dir>',
'Output directory for generated files (defaults to .taskmaster/tasks)'
)
.option(
'-p, --project <path>',
'Project root directory (auto-detected if not provided)'
)
.option('-f, --format <format>', 'Output format (text, json)', 'text')
.action(async (options: GenerateCommandOptions) => {
await this.executeCommand(options);
});
}
/**
* Execute the generate command
*/
private async executeCommand(options: GenerateCommandOptions): Promise<void> {
let hasError = false;
try {
// Validate options
this.validateOptions(options);
// Initialize tm-core
const projectRoot = getProjectRoot(options.project);
await this.initializeCore(projectRoot);
// Generate task files
const result = await this.generateFiles(projectRoot, options);
// Store result for programmatic access
this.lastResult = result;
// Display results
this.displayResults(result, options);
} catch (error: any) {
hasError = true;
displayError(error, { skipExit: true });
} finally {
await this.cleanup();
}
if (hasError) {
process.exit(1);
}
}
/**
* Validate command options
*/
private validateOptions(options: GenerateCommandOptions): void {
if (options.format && !['text', 'json'].includes(options.format)) {
throw new Error(
`Invalid format: ${options.format}. Valid formats are: text, json`
);
}
}
/**
* Initialize TmCore
*/
private async initializeCore(projectRoot: string): Promise<void> {
if (!this.tmCore) {
const resolved = path.resolve(projectRoot);
this.tmCore = await createTmCore({ projectPath: resolved });
}
}
/**
* Generate task files using tm-core
*/
private async generateFiles(
projectRoot: string,
options: GenerateCommandOptions
): Promise<GenerateTaskFilesResult> {
if (!this.tmCore) {
throw new Error('TmCore not initialized');
}
// Resolve output directory
const outputDir = options.output
? path.resolve(projectRoot, options.output)
: undefined;
// Call tm-core to generate task files
return this.tmCore.tasks.generateTaskFiles({
tag: options.tag,
outputDir
});
}
/**
* Display results based on format
*/
private displayResults(
result: GenerateTaskFilesResult,
options: GenerateCommandOptions
): void {
const format = options.format || 'text';
switch (format) {
case 'json':
this.displayJson(result);
break;
case 'text':
default:
this.displayText(result, options);
break;
}
}
/**
* Display in JSON format
*/
private displayJson(result: GenerateTaskFilesResult): void {
console.log(JSON.stringify(result, null, 2));
}
/**
* Display in text format
*/
private displayText(
result: GenerateTaskFilesResult,
options: GenerateCommandOptions
): void {
// Display header with storage info
if (this.tmCore) {
const storageType = this.tmCore.tasks.getStorageType();
const activeTag = options.tag || this.tmCore.config.getActiveTag();
displayCommandHeader(this.tmCore, {
tag: activeTag,
storageType
});
}
if (!result.success) {
// Error occurred
console.log(
boxen(chalk.red(`Error: ${result.error || 'Unknown error'}`), {
padding: 1,
borderStyle: 'round',
borderColor: 'red',
title: '❌ GENERATION FAILED',
titleAlignment: 'center'
})
);
return;
}
if (result.count === 0) {
// No tasks to generate
console.log(
boxen(chalk.yellow('No tasks found to generate files for.'), {
padding: 1,
borderStyle: 'round',
borderColor: 'yellow',
title: '⚠️ NO TASKS',
titleAlignment: 'center'
})
);
return;
}
// Success message
let message = `${chalk.green('✓')} Generated ${chalk.cyan(result.count)} task file(s)`;
message += `\n\n${chalk.dim('Output directory:')} ${result.directory}`;
if (result.orphanedFilesRemoved > 0) {
message += `\n${chalk.dim('Orphaned files removed:')} ${result.orphanedFilesRemoved}`;
}
console.log(
boxen(message, {
padding: 1,
borderStyle: 'round',
borderColor: 'green',
title: '📄 TASK FILES GENERATED',
titleAlignment: 'center'
})
);
}
/**
* Get the last result (for programmatic usage)
*/
getLastResult(): GenerateTaskFilesResult | undefined {
return this.lastResult;
}
/**
* Clean up resources
*/
async cleanup(): Promise<void> {
if (this.tmCore) {
this.tmCore = undefined;
}
}
/**
* Register this command on an existing program
*/
static register(program: Command, name?: string): GenerateCommand {
const generateCommand = new GenerateCommand(name);
program.addCommand(generateCommand);
return generateCommand;
}
}

View File

@@ -0,0 +1,224 @@
/**
* @fileoverview Integration tests for 'task-master generate' command
*
* Tests CLI-specific behavior: argument parsing, output formatting, exit codes.
* Core file generation logic is tested in tm-core's task-file-generator.service.spec.ts.
*
* @integration
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createTask, createTasksFile } from '@tm/core/testing';
import { getCliBinPath } from '../../helpers/test-utils';
// Capture initial working directory at module load time
const initialCwd = process.cwd();
describe('generate command', () => {
let testDir: string;
let tasksPath: string;
let binPath: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-generate-test-'));
process.chdir(testDir);
process.env.TASKMASTER_SKIP_AUTO_UPDATE = '1';
binPath = getCliBinPath();
execSync(`node "${binPath}" init --yes`, {
stdio: 'pipe',
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
});
tasksPath = path.join(testDir, '.taskmaster', 'tasks', 'tasks.json');
// Use fixture to create initial empty tasks file
const initialTasks = createTasksFile();
fs.writeFileSync(tasksPath, JSON.stringify(initialTasks, null, 2));
});
afterEach(() => {
try {
// Restore to the original working directory captured at module load
process.chdir(initialCwd);
} catch {
// Fallback to home directory if initial directory no longer exists
process.chdir(os.homedir());
}
if (testDir && fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
delete process.env.TASKMASTER_SKIP_AUTO_UPDATE;
});
const writeTasks = (tasksData: any) => {
fs.writeFileSync(tasksPath, JSON.stringify(tasksData, null, 2));
};
const runGenerate = (args = ''): { output: string; exitCode: number } => {
try {
const output = execSync(`node "${binPath}" generate ${args}`, {
encoding: 'utf-8',
stdio: 'pipe',
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
});
return { output, exitCode: 0 };
} catch (error: any) {
return {
output: error.stderr?.toString() || error.stdout?.toString() || '',
exitCode: error.status || 1
};
}
};
// ========== CLI-specific tests ==========
it('should exit with code 0 on success', () => {
const testData = createTasksFile({
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
});
writeTasks(testData);
const { exitCode } = runGenerate();
expect(exitCode).toBe(0);
});
it('should display user-friendly message when no tasks exist', () => {
const { output, exitCode } = runGenerate();
expect(exitCode).toBe(0);
expect(output.toLowerCase()).toContain('no tasks');
});
it('should display task count in success message', () => {
const testData = createTasksFile({
tasks: [
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
createTask({ id: 2, title: 'Task 2', status: 'pending' }),
createTask({ id: 3, title: 'Task 3', status: 'pending' })
]
});
writeTasks(testData);
const { output, exitCode } = runGenerate();
expect(exitCode).toBe(0);
expect(output).toContain('3');
});
it('should mention orphaned files in output when cleaned up', () => {
// Create tasks and generate files
const testData = createTasksFile({
tasks: [
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
createTask({ id: 2, title: 'Task 2', status: 'pending' }),
createTask({ id: 3, title: 'Task 3', status: 'pending' })
]
});
writeTasks(testData);
runGenerate();
// Remove task 3 and regenerate
const reducedData = createTasksFile({
tasks: [
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
createTask({ id: 2, title: 'Task 2', status: 'pending' })
]
});
writeTasks(reducedData);
const { output, exitCode } = runGenerate();
expect(exitCode).toBe(0);
expect(output.toLowerCase()).toContain('orphan');
});
it('should support --format json flag', () => {
const testData = createTasksFile({
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
});
writeTasks(testData);
const { output, exitCode } = runGenerate('--format json');
expect(exitCode).toBe(0);
// JSON output should be parseable and contain expected fields
// Note: output may have leading/trailing whitespace or newlines
const jsonMatch = output.match(/\{[\s\S]*\}/);
expect(jsonMatch).not.toBeNull();
const parsed = JSON.parse(jsonMatch![0]);
expect(parsed.success).toBe(true);
expect(parsed.count).toBe(1);
});
it('should support --output flag for custom directory', () => {
const testData = createTasksFile({
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
});
writeTasks(testData);
const customDir = path.join(testDir, 'custom-tasks');
const { exitCode } = runGenerate(`--output "${customDir}"`);
expect(exitCode).toBe(0);
// Verify file was created in custom directory
expect(fs.existsSync(path.join(customDir, 'task_001.md'))).toBe(true);
});
it('should support --tag flag', () => {
// Create tasks under a custom tag (not master)
const testData = {
master: { tasks: [], metadata: {} },
'feature-branch': {
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })],
metadata: {}
}
};
writeTasks(testData);
const { exitCode } = runGenerate('--tag feature-branch');
expect(exitCode).toBe(0);
// Should create tag-specific file
const outputDir = path.join(testDir, '.taskmaster', 'tasks');
expect(
fs.existsSync(path.join(outputDir, 'task_001_feature-branch.md'))
).toBe(true);
});
it('should show output directory in success message', () => {
const testData = createTasksFile({
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
});
writeTasks(testData);
const { output, exitCode } = runGenerate();
expect(exitCode).toBe(0);
expect(output).toContain('.taskmaster/tasks');
});
it('should exit with non-zero code for invalid --format value', () => {
const testData = createTasksFile({
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
});
writeTasks(testData);
const { output, exitCode } = runGenerate('--format invalid');
expect(exitCode).not.toBe(0);
expect(output.toLowerCase()).toMatch(/invalid|error|format/);
});
});

View File

@@ -11,11 +11,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createSubtask,
createTask,
createTasksFile
} from '../../fixtures/task-fixtures';
import { createSubtask, createTask, createTasksFile } from '@tm/core/testing';
import { getCliBinPath } from '../../helpers/test-utils';
// Capture initial working directory at module load time

View File

@@ -11,7 +11,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createTask, createTasksFile } from '../../fixtures/task-fixtures';
import { createTask, createTasksFile } from '@tm/core/testing';
import { getCliBinPath } from '../../helpers/test-utils';
// Capture initial working directory at module load time

View File

@@ -12,7 +12,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createTask, createTasksFile } from '../../fixtures/task-fixtures';
import { createTask, createTasksFile } from '@tm/core/testing';
import { getCliBinPath } from '../../helpers/test-utils';
// Capture initial working directory at module load time

View File

@@ -11,11 +11,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createTask,
createTasksFile,
createSubtask
} from '../../fixtures/task-fixtures';
import { createTask, createTasksFile, createSubtask } from '@tm/core/testing';
import { getCliBinPath } from '../../helpers/test-utils';
// Capture initial working directory at module load time

View File

@@ -0,0 +1,106 @@
/**
* @fileoverview generate MCP tool
* Generates individual task files from tasks.json
*/
import path from 'node:path';
import { z } from 'zod';
import { handleApiResult, withToolContext } from '../../shared/utils.js';
import type { ToolContext } from '../../shared/types.js';
import type { FastMCP } from 'fastmcp';
const GenerateSchema = z.object({
output: z
.string()
.optional()
.describe(
'Output directory for generated files (default: same directory as tasks file)'
),
projectRoot: z
.string()
.describe('The directory of the project. Must be an absolute path.'),
tag: z.string().optional().describe('Tag context to operate on')
});
type GenerateArgs = z.infer<typeof GenerateSchema>;
/**
* Register the generate tool with the MCP server
*/
export function registerGenerateTool(server: FastMCP) {
server.addTool({
name: 'generate',
description:
'Generates individual task files in tasks/ directory based on tasks.json. Only works with local file storage.',
parameters: GenerateSchema,
execute: withToolContext(
'generate',
async (args: GenerateArgs, { log, tmCore }: ToolContext) => {
const { projectRoot, tag, output } = args;
try {
log.info(`Generating task files with args: ${JSON.stringify(args)}`);
// Resolve output directory
const outputDir = output
? path.resolve(projectRoot, output)
: undefined;
// Call tm-core to generate task files
const result = await tmCore.tasks.generateTaskFiles({
tag,
outputDir
});
if (result.success) {
log.info(
`Successfully generated ${result.count} task files in ${result.directory}`
);
if (result.orphanedFilesRemoved > 0) {
log.info(
`Removed ${result.orphanedFilesRemoved} orphaned task files`
);
}
} else {
log.error(
`Failed to generate task files: ${result.error || 'Unknown error'}`
);
}
return handleApiResult({
result: {
success: result.success,
data: result.success
? {
message: `Successfully generated ${result.count} task file(s)`,
count: result.count,
directory: result.directory,
orphanedFilesRemoved: result.orphanedFilesRemoved
}
: undefined,
error: result.success ? undefined : { message: result.error || 'Unknown error' }
},
log,
projectRoot,
tag
});
} catch (error: any) {
log.error(`Error in generate tool: ${error.message}`);
if (error.stack) {
log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to generate task files: ${error.message}`
}
},
log,
projectRoot
});
}
}
)
});
}

View File

@@ -5,3 +5,4 @@
export { registerGetTasksTool } from './get-tasks.tool.js';
export { registerGetTaskTool } from './get-task.tool.js';
export { registerGenerateTool } from './generate.tool.js';

View File

@@ -1 +0,0 @@
../../../cli/tests/fixtures/task-fixtures.ts

View File

@@ -0,0 +1,199 @@
/**
* @fileoverview Integration tests for generate MCP tool
*
* Tests MCP-specific behavior: tool response format, parameter handling.
* Core file generation logic is tested in tm-core's task-file-generator.service.spec.ts.
*
* @integration
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createTask, createTasksFile } from '@tm/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe('generate MCP tool', () => {
let testDir: string;
let tasksPath: string;
let cliPath: string;
let mcpServerPath: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-mcp-generate-'));
process.chdir(testDir);
cliPath = path.resolve(__dirname, '../../../../../dist/task-master.js');
mcpServerPath = path.resolve(
__dirname,
'../../../../../dist/mcp-server.js'
);
// Initialize Task Master in test directory
execSync(`node "${cliPath}" init --yes`, {
stdio: 'pipe',
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
});
tasksPath = path.join(testDir, '.taskmaster', 'tasks', 'tasks.json');
// Create initial empty tasks file using fixtures
const initialTasks = createTasksFile();
fs.writeFileSync(tasksPath, JSON.stringify(initialTasks, null, 2));
});
afterEach(() => {
// Change back to original directory and cleanup
try {
const originalDir = path.resolve(__dirname, '../../../../..');
process.chdir(originalDir);
} catch {
process.chdir(os.homedir());
}
if (testDir && fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
const writeTasks = (tasksData: any) => {
fs.writeFileSync(tasksPath, JSON.stringify(tasksData, null, 2));
};
/**
* Call an MCP tool using the inspector CLI
* The inspector returns MCP protocol format: { content: [{ type: "text", text: "<json>" }] }
*/
const callMCPTool = (toolName: string, args: Record<string, any>): any => {
const toolArgs = Object.entries(args)
.map(([key, value]) => `--tool-arg ${key}=${value}`)
.join(' ');
const output = execSync(
`npx @modelcontextprotocol/inspector --cli node "${mcpServerPath}" --method tools/call --tool-name ${toolName} ${toolArgs}`,
{ encoding: 'utf-8', stdio: 'pipe' }
);
// Parse the MCP protocol response: { content: [{ type: "text", text: "<json>" }] }
const mcpResponse = JSON.parse(output);
const resultText = mcpResponse.content[0].text;
return JSON.parse(resultText);
};
// ========== MCP-specific tests ==========
it('should return MCP response with data object on success', () => {
const testData = createTasksFile({
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
});
writeTasks(testData);
const response = callMCPTool('generate', { projectRoot: testDir });
// Verify MCP response structure
expect(response).toHaveProperty('data');
expect(response.data).toHaveProperty('count');
expect(response.data).toHaveProperty('directory');
expect(response.data).toHaveProperty('message');
}, 15000);
it('should include tag in response', () => {
const testData = createTasksFile({
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
});
writeTasks(testData);
const response = callMCPTool('generate', { projectRoot: testDir });
expect(response.tag).toBe('master');
}, 15000);
it('should return count of generated files', () => {
const testData = createTasksFile({
tasks: [
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
createTask({ id: 2, title: 'Task 2', status: 'pending' }),
createTask({ id: 3, title: 'Task 3', status: 'pending' })
]
});
writeTasks(testData);
const response = callMCPTool('generate', { projectRoot: testDir });
expect(response.data.count).toBe(3);
}, 15000);
it('should return zero count when no tasks exist', () => {
const response = callMCPTool('generate', { projectRoot: testDir });
expect(response.data.count).toBe(0);
}, 15000);
it('should return orphanedFilesRemoved count', () => {
// Create tasks and generate files
const testData = createTasksFile({
tasks: [
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
createTask({ id: 2, title: 'Task 2', status: 'pending' }),
createTask({ id: 3, title: 'Task 3', status: 'pending' })
]
});
writeTasks(testData);
callMCPTool('generate', { projectRoot: testDir });
// Remove task 3 and regenerate
const reducedData = createTasksFile({
tasks: [
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
createTask({ id: 2, title: 'Task 2', status: 'pending' })
]
});
writeTasks(reducedData);
const response = callMCPTool('generate', { projectRoot: testDir });
expect(response.data.orphanedFilesRemoved).toBe(1);
}, 15000);
it('should accept output parameter for custom directory', () => {
const testData = createTasksFile({
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
});
writeTasks(testData);
const customDir = path.join(testDir, 'custom-tasks');
const response = callMCPTool('generate', {
projectRoot: testDir,
output: customDir
});
expect(response.data.directory).toBe(customDir);
}, 15000);
it('should accept tag parameter', () => {
// Create tasks under a custom tag (not master)
const testData = {
master: { tasks: [], metadata: {} },
'feature-branch': {
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })],
metadata: {}
}
};
writeTasks(testData);
const response = callMCPTool('generate', {
projectRoot: testDir,
tag: 'feature-branch'
});
expect(response.data.count).toBe(1);
// Verify tag-specific file was created
const outputDir = path.join(testDir, '.taskmaster', 'tasks');
expect(
fs.existsSync(path.join(outputDir, 'task_001_feature-branch.md'))
).toBe(true);
}, 15000);
});

View File

@@ -12,7 +12,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createTask, createTasksFile } from '../../fixtures/task-fixtures';
import { createTask, createTasksFile } from '@tm/core/testing';
describe('get_tasks MCP tool', () => {
let testDir: string;

View File

@@ -48,11 +48,12 @@ import {
registerAutopilotFinalizeTool,
registerAutopilotAbortTool,
registerGetTasksTool,
registerGetTaskTool
registerGetTaskTool,
registerGenerateTool
} from '@tm/mcp';
/**
* Comprehensive tool registry mapping all 44 tool names to their registration functions
* Comprehensive tool registry mapping tool names to their registration functions
* Used for dynamic tool registration and validation
*/
export const toolRegistry = {
@@ -98,7 +99,8 @@ export const toolRegistry = {
autopilot_complete: registerAutopilotCompleteTool,
autopilot_commit: registerAutopilotCommitTool,
autopilot_finalize: registerAutopilotFinalizeTool,
autopilot_abort: registerAutopilotAbortTool
autopilot_abort: registerAutopilotAbortTool,
generate: registerGenerateTool
};
/**

View File

@@ -7,6 +7,7 @@
"main": "./dist/index.js",
"exports": {
".": "./src/index.ts",
"./testing": "./src/testing/index.ts",
"./auth": "./src/auth/index.ts",
"./storage": "./src/storage/index.ts",
"./config": "./src/config/index.ts",

View File

@@ -146,6 +146,22 @@ export { CommitMessageGenerator } from './modules/git/services/commit-message-ge
// Tasks - Advanced
export { PreflightChecker } from './modules/tasks/services/preflight-checker.service.js';
export { TaskLoaderService } from './modules/tasks/services/task-loader.service.js';
export {
TaskFileGeneratorService,
type GenerateTaskFilesOptions,
type GenerateTaskFilesResult
} from './modules/tasks/services/task-file-generator.service.js';
// Integration - Advanced
export { ExportService } from './modules/integration/services/export.service.js';
// ========== Testing Utilities ==========
// Test fixtures for integration tests
export {
createTask,
createSubtask,
createTasksFile,
TaskScenarios,
type TasksFile
} from './testing/index.js';

View File

@@ -12,7 +12,8 @@ export const LOCAL_ONLY_COMMANDS = [
'validate-dependencies',
'fix-dependencies',
'clear-subtasks',
'models'
'models',
'generate'
] as const;
export type LocalOnlyCommand = (typeof LOCAL_ONLY_COMMANDS)[number];

View File

@@ -0,0 +1,329 @@
/**
* @fileoverview Unit tests for TaskFileGeneratorService
* Tests task file generation from storage
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TaskFileGeneratorService } from './task-file-generator.service.js';
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
import type { Task } from '../../../common/types/index.js';
import type { ConfigManager } from '../../config/managers/config-manager.js';
// Mock storage implementation for testing
function createMockStorage(tasks: Task[] = []): IStorage {
return {
loadTasks: vi.fn().mockResolvedValue(tasks),
loadTask: vi.fn(),
saveTasks: vi.fn(),
appendTasks: vi.fn(),
updateTask: vi.fn(),
updateTaskWithPrompt: vi.fn(),
expandTaskWithPrompt: vi.fn(),
updateTaskStatus: vi.fn(),
deleteTask: vi.fn(),
exists: vi.fn().mockResolvedValue(true),
loadMetadata: vi.fn(),
saveMetadata: vi.fn(),
getAllTags: vi.fn().mockResolvedValue(['master']),
createTag: vi.fn(),
deleteTag: vi.fn(),
renameTag: vi.fn(),
copyTag: vi.fn(),
initialize: vi.fn(),
close: vi.fn(),
getStats: vi.fn(),
getStorageType: vi.fn().mockReturnValue('file'),
getCurrentBriefName: vi.fn().mockReturnValue(null),
getTagsWithStats: vi.fn()
};
}
function createSampleTasks(): Task[] {
return [
{
id: '1',
title: 'First Task',
description: 'Description for first task',
status: 'pending',
priority: 'high',
dependencies: [],
details: 'Implementation details for task 1',
testStrategy: 'Unit tests for task 1',
subtasks: []
},
{
id: '2',
title: 'Second Task',
description: 'Description for second task',
status: 'in-progress',
priority: 'medium',
dependencies: ['1'],
details: 'Implementation details for task 2',
testStrategy: 'Integration tests for task 2',
subtasks: [
{
id: 1,
parentId: '2',
title: 'Subtask 1',
description: 'First subtask description',
status: 'done',
priority: 'medium',
dependencies: [],
details: 'Subtask details',
testStrategy: ''
},
{
id: 2,
parentId: '2',
title: 'Subtask 2',
description: 'Second subtask description',
status: 'pending',
priority: 'medium',
dependencies: ['1'],
details: '',
testStrategy: ''
}
]
},
{
id: '3',
title: 'Third Task',
description: 'Description for third task',
status: 'done',
priority: 'low',
dependencies: ['1', '2'],
details: '',
testStrategy: '',
subtasks: []
}
];
}
// Mock ConfigManager for testing
function createMockConfigManager(activeTag = 'master'): ConfigManager {
return {
getActiveTag: vi.fn().mockReturnValue(activeTag),
getProjectRoot: vi.fn().mockReturnValue('/tmp/test-project')
} as unknown as ConfigManager;
}
describe('TaskFileGeneratorService', () => {
let tempDir: string;
let service: TaskFileGeneratorService;
let mockStorage: IStorage;
let mockConfigManager: ConfigManager;
beforeEach(() => {
// Create a temporary directory for test outputs
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-test-'));
mockStorage = createMockStorage(createSampleTasks());
mockConfigManager = createMockConfigManager();
service = new TaskFileGeneratorService(mockStorage, tempDir, mockConfigManager);
});
afterEach(() => {
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
});
describe('generateTaskFiles', () => {
it('should generate task files for all tasks', async () => {
const result = await service.generateTaskFiles();
expect(result.success).toBe(true);
expect(result.count).toBe(3);
expect(result.directory).toContain('.taskmaster/tasks');
// Verify files were created
const outputDir = result.directory;
expect(fs.existsSync(path.join(outputDir, 'task_001.md'))).toBe(true);
expect(fs.existsSync(path.join(outputDir, 'task_002.md'))).toBe(true);
expect(fs.existsSync(path.join(outputDir, 'task_003.md'))).toBe(true);
});
it('should generate task files with correct content', async () => {
const result = await service.generateTaskFiles();
const outputDir = result.directory;
// Read and verify content of first task file
const task1Content = fs.readFileSync(
path.join(outputDir, 'task_001.md'),
'utf-8'
);
expect(task1Content).toContain('# Task ID: 1');
expect(task1Content).toContain('**Title:** First Task');
expect(task1Content).toContain('**Status:** pending');
expect(task1Content).toContain('**Priority:** high');
expect(task1Content).toContain('**Description:** Description for first task');
expect(task1Content).toContain('**Details:**');
expect(task1Content).toContain('Implementation details for task 1');
expect(task1Content).toContain('**Test Strategy:**');
expect(task1Content).toContain('Unit tests for task 1');
});
it('should include subtasks in task files', async () => {
const result = await service.generateTaskFiles();
const outputDir = result.directory;
// Read task 2 which has subtasks
const task2Content = fs.readFileSync(
path.join(outputDir, 'task_002.md'),
'utf-8'
);
expect(task2Content).toContain('## Subtasks');
expect(task2Content).toContain('### 2.1. Subtask 1');
expect(task2Content).toContain('**Status:** done');
expect(task2Content).toContain('### 2.2. Subtask 2');
expect(task2Content).toContain('**Status:** pending');
expect(task2Content).toContain('**Dependencies:** 2.1');
});
it('should format dependencies with status symbols', async () => {
const result = await service.generateTaskFiles();
const outputDir = result.directory;
// Task 3 depends on task 1 (pending) and task 2 (in-progress)
const task3Content = fs.readFileSync(
path.join(outputDir, 'task_003.md'),
'utf-8'
);
// Check that dependencies include status symbols
expect(task3Content).toContain('**Dependencies:**');
expect(task3Content).toMatch(/1.*,.*2/); // Both dependencies listed
});
it('should use tag-specific filenames for non-master tags', async () => {
const result = await service.generateTaskFiles({ tag: 'feature-branch' });
expect(result.success).toBe(true);
const outputDir = result.directory;
expect(
fs.existsSync(path.join(outputDir, 'task_001_feature-branch.md'))
).toBe(true);
expect(
fs.existsSync(path.join(outputDir, 'task_002_feature-branch.md'))
).toBe(true);
expect(
fs.existsSync(path.join(outputDir, 'task_003_feature-branch.md'))
).toBe(true);
});
it('should use custom output directory when provided', async () => {
const customDir = path.join(tempDir, 'custom-output');
const result = await service.generateTaskFiles({ outputDir: customDir });
expect(result.success).toBe(true);
expect(result.directory).toBe(customDir);
expect(fs.existsSync(path.join(customDir, 'task_001.md'))).toBe(true);
});
it('should handle empty task list', async () => {
const emptyStorage = createMockStorage([]);
const emptyService = new TaskFileGeneratorService(emptyStorage, tempDir, mockConfigManager);
const result = await emptyService.generateTaskFiles();
expect(result.success).toBe(true);
expect(result.count).toBe(0);
});
it('should clean up orphaned task files', async () => {
// First generate files for 3 tasks
await service.generateTaskFiles();
// Now mock storage to return only 2 tasks (task 3 removed)
const reducedTasks = createSampleTasks().slice(0, 2);
const reducedStorage = createMockStorage(reducedTasks);
const reducedService = new TaskFileGeneratorService(
reducedStorage,
tempDir,
mockConfigManager
);
const result = await reducedService.generateTaskFiles();
expect(result.success).toBe(true);
expect(result.count).toBe(2);
expect(result.orphanedFilesRemoved).toBe(1);
// Verify task_003.md was removed
const outputDir = result.directory;
expect(fs.existsSync(path.join(outputDir, 'task_001.md'))).toBe(true);
expect(fs.existsSync(path.join(outputDir, 'task_002.md'))).toBe(true);
expect(fs.existsSync(path.join(outputDir, 'task_003.md'))).toBe(false);
});
it('should handle storage errors gracefully', async () => {
const errorStorage = createMockStorage();
(errorStorage.loadTasks as any).mockRejectedValue(
new Error('Storage error')
);
const errorService = new TaskFileGeneratorService(errorStorage, tempDir, mockConfigManager);
const result = await errorService.generateTaskFiles();
expect(result.success).toBe(false);
expect(result.error).toContain('Storage error');
});
it('should use active tag from config when tag is not provided', async () => {
// Create service with non-master active tag
const featureConfigManager = createMockConfigManager('feature-x');
const featureService = new TaskFileGeneratorService(
mockStorage,
tempDir,
featureConfigManager
);
const result = await featureService.generateTaskFiles();
expect(result.success).toBe(true);
// Verify files were created with the active tag suffix
const outputDir = result.directory;
expect(
fs.existsSync(path.join(outputDir, 'task_001_feature-x.md'))
).toBe(true);
expect(
fs.existsSync(path.join(outputDir, 'task_002_feature-x.md'))
).toBe(true);
expect(
fs.existsSync(path.join(outputDir, 'task_003_feature-x.md'))
).toBe(true);
});
it('should override active tag when tag is explicitly provided', async () => {
// Create service with master active tag
const masterConfigManager = createMockConfigManager('master');
const masterService = new TaskFileGeneratorService(
mockStorage,
tempDir,
masterConfigManager
);
// Explicitly provide a different tag
const result = await masterService.generateTaskFiles({ tag: 'override-tag' });
expect(result.success).toBe(true);
// Verify files were created with the override tag, not active tag
const outputDir = result.directory;
expect(
fs.existsSync(path.join(outputDir, 'task_001_override-tag.md'))
).toBe(true);
expect(
fs.existsSync(path.join(outputDir, 'task_002_override-tag.md'))
).toBe(true);
});
});
});

View File

@@ -0,0 +1,341 @@
/**
* @fileoverview Task file generator service
* Generates individual markdown task files from tasks.json
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import type { Task, Subtask, TaskStatus } from '../../../common/types/index.js';
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
import type { ConfigManager } from '../../config/managers/config-manager.js';
/**
* Options for generating task files
*/
export interface GenerateTaskFilesOptions {
/** Tag context for the tasks */
tag?: string;
/** Output directory for generated files (defaults to tasks directory) */
outputDir?: string;
}
/**
* Result of task file generation
*/
export interface GenerateTaskFilesResult {
success: boolean;
/** Number of task files successfully generated */
count: number;
/** Output directory where files were written */
directory: string;
/** Number of orphaned files cleaned up */
orphanedFilesRemoved: number;
/** Error message if generation failed completely */
error?: string;
/** Individual file write errors (task ID -> error message) */
fileErrors?: Record<string, string>;
}
/**
* Service for generating individual task markdown files from tasks.json
*/
export class TaskFileGeneratorService {
constructor(
private storage: IStorage,
private projectPath: string,
private configManager: ConfigManager
) {}
/**
* Generate individual task files from storage
* - Reads tasks from storage
* - Cleans up orphaned task files
* - Writes individual markdown files for each task
*/
async generateTaskFiles(
options: GenerateTaskFilesOptions = {}
): Promise<GenerateTaskFilesResult> {
const tag = options.tag || this.configManager.getActiveTag();
const outputDir =
options.outputDir ||
path.join(this.projectPath, '.taskmaster', 'tasks');
try {
// Ensure output directory exists
await fs.mkdir(outputDir, { recursive: true });
// Load tasks from storage
const tasks = await this.storage.loadTasks(tag);
if (tasks.length === 0) {
return {
success: true,
count: 0,
directory: outputDir,
orphanedFilesRemoved: 0
};
}
// Clean up orphaned task files
const orphanedCount = await this.cleanupOrphanedFiles(
outputDir,
tasks,
tag
);
// Generate task files in parallel with individual error handling
// This allows partial success - some files can be written even if others fail
const fileErrors: Record<string, string> = {};
const results = await Promise.allSettled(
tasks.map(async (task) => {
const content = this.formatTaskContent(task, tasks);
const fileName = this.getTaskFileName(task.id, tag);
const filePath = path.join(outputDir, fileName);
await fs.writeFile(filePath, content, 'utf-8');
return task.id;
})
);
// Count successes and collect errors
let successCount = 0;
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'fulfilled') {
successCount++;
} else {
const taskId = String(tasks[i].id);
fileErrors[taskId] = result.reason?.message || 'Unknown error';
}
}
return {
success: Object.keys(fileErrors).length === 0,
count: successCount,
directory: outputDir,
orphanedFilesRemoved: orphanedCount,
...(Object.keys(fileErrors).length > 0 && { fileErrors })
};
} catch (error: any) {
return {
success: false,
count: 0,
directory: outputDir,
orphanedFilesRemoved: 0,
error: error.message
};
}
}
/**
* Get the filename for a task file
* Master tag: task_001.md
* Other tags: task_001_tagname.md
*/
private getTaskFileName(taskId: string | number, tag: string): string {
const paddedId = String(taskId).padStart(3, '0');
return tag === 'master'
? `task_${paddedId}.md`
: `task_${paddedId}_${tag}.md`;
}
/**
* Escape special regex characters in a string
*/
private escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Clean up orphaned task files (files for tasks that no longer exist)
* @returns Number of files removed
*/
private async cleanupOrphanedFiles(
outputDir: string,
tasks: Task[],
tag: string
): Promise<number> {
let removedCount = 0;
try {
const files = await fs.readdir(outputDir);
const validTaskIds = tasks.map((task) => String(task.id));
// Tag-aware file patterns
const masterFilePattern = /^task_(\d+)\.md$/;
const taggedFilePattern = new RegExp(`^task_(\\d+)_${this.escapeRegExp(tag)}\\.md$`);
// Collect files to remove
const filesToRemove: string[] = [];
for (const file of files) {
let match = null;
let fileTaskId: string | null = null;
// Check if file belongs to current tag
if (tag === 'master') {
match = file.match(masterFilePattern);
if (match) {
fileTaskId = match[1];
}
} else {
match = file.match(taggedFilePattern);
if (match) {
fileTaskId = match[1];
}
}
// If this is a task file for the current tag and the task no longer exists
if (fileTaskId !== null) {
// Convert to integer for comparison (removes leading zeros)
const normalizedId = String(parseInt(fileTaskId, 10));
if (!validTaskIds.includes(normalizedId)) {
filesToRemove.push(file);
}
}
}
// Remove files in parallel
await Promise.all(
filesToRemove.map(async (file) => {
const filePath = path.join(outputDir, file);
await fs.unlink(filePath);
})
);
removedCount = filesToRemove.length;
} catch (error) {
// Ignore errors during cleanup - non-critical operation
}
return removedCount;
}
/**
* Format a task into markdown content for the task file
* Uses single H1 title with metadata as key-value pairs
*/
private formatTaskContent(task: Task, allTasks: Task[]): string {
// Single H1 title with task ID
let content = `# Task ID: ${task.id}\n\n`;
// Metadata as key-value pairs (using bold for keys)
content += `**Title:** ${task.title}\n\n`;
content += `**Status:** ${task.status || 'pending'}\n\n`;
// Format dependencies with status
if (task.dependencies && task.dependencies.length > 0) {
const depsWithStatus = this.formatDependenciesWithStatus(
task.dependencies,
allTasks
);
content += `**Dependencies:** ${depsWithStatus}\n\n`;
} else {
content += '**Dependencies:** None\n\n';
}
content += `**Priority:** ${task.priority || 'medium'}\n\n`;
content += `**Description:** ${task.description || ''}\n\n`;
// Details section
content += '**Details:**\n\n';
content += `${task.details || 'No details provided.'}\n\n`;
// Test Strategy section
content += '**Test Strategy:**\n\n';
content += `${task.testStrategy || 'No test strategy provided.'}\n`;
// Add subtasks if present
if (task.subtasks && task.subtasks.length > 0) {
content += '\n## Subtasks\n';
for (const subtask of task.subtasks) {
content += this.formatSubtaskContent(subtask, task);
}
}
// Normalize multiple blank lines to single blank lines
return content.replace(/\n{3,}/g, '\n\n');
}
/**
* Format a subtask into markdown content
*/
private formatSubtaskContent(subtask: Subtask, parentTask: Task): string {
// H3 for subtask title since H2 is used for sections
let content = `\n### ${parentTask.id}.${subtask.id}. ${subtask.title}\n\n`;
// Metadata using bold labels
content += `**Status:** ${subtask.status || 'pending'} \n`;
// Format subtask dependencies
if (subtask.dependencies && subtask.dependencies.length > 0) {
const subtaskDeps = subtask.dependencies
.map((depId) => {
const depStr = String(depId);
// Check if it's already a full ID (contains a dot) or is a simple number reference
// Simple numbers (e.g., 1, '1') are internal subtask refs and need parent prefix
// Full IDs (e.g., '1.2', '3') with dots are already complete
if (depStr.includes('.')) {
return depStr; // Already a full subtask ID like "2.1"
}
// Simple number - prefix with parent task ID
return `${parentTask.id}.${depStr}`;
})
.join(', ');
content += `**Dependencies:** ${subtaskDeps} \n`;
} else {
content += '**Dependencies:** None \n';
}
content += '\n';
if (subtask.description) {
content += `${subtask.description}\n`;
}
if (subtask.details) {
content += `\n**Details:**\n\n${subtask.details}\n`;
}
return content;
}
/**
* Format dependencies with their current status
*/
private formatDependenciesWithStatus(
dependencies: (string | number)[],
allTasks: Task[]
): string {
return dependencies
.map((depId) => {
const depTask = allTasks.find(
(t) => String(t.id) === String(depId)
);
if (depTask) {
const statusSymbol = this.getStatusSymbol(depTask.status);
return `${depId}${statusSymbol}`;
}
return String(depId);
})
.join(', ');
}
/**
* Get status symbol for display
*/
private getStatusSymbol(status: TaskStatus | undefined): string {
switch (status) {
case 'done':
return ' ✓';
case 'in-progress':
return ' ⧖';
case 'blocked':
return ' ⛔';
case 'cancelled':
return ' ✗';
case 'deferred':
return ' ⏸';
default:
return '';
}
}
}

View File

@@ -792,4 +792,15 @@ export class TaskService {
);
}
}
/**
* Close and cleanup resources
* Releases file locks and other storage resources
*/
async close(): Promise<void> {
if (this.storage) {
await this.storage.close();
}
this.initialized = false;
}
}

View File

@@ -11,6 +11,11 @@ import { TaskExecutionService } from './services/task-execution-service.js';
import { TaskLoaderService } from './services/task-loader.service.js';
import { PreflightChecker } from './services/preflight-checker.service.js';
import { TagService } from './services/tag.service.js';
import {
TaskFileGeneratorService,
type GenerateTaskFilesOptions,
type GenerateTaskFilesResult
} from './services/task-file-generator.service.js';
import type {
CreateTagOptions,
DeleteTagOptions,
@@ -42,12 +47,17 @@ export class TasksDomain {
private preflightChecker: PreflightChecker;
private briefsDomain: BriefsDomain;
private tagService!: TagService;
private taskFileGenerator!: TaskFileGeneratorService;
private projectRoot: string;
private configManager: ConfigManager;
constructor(configManager: ConfigManager, _authDomain?: AuthDomain) {
this.projectRoot = configManager.getProjectRoot();
this.configManager = configManager;
this.taskService = new TaskService(configManager);
this.executionService = new TaskExecutionService(this.taskService);
this.loaderService = new TaskLoaderService(this.taskService);
this.preflightChecker = new PreflightChecker(configManager.getProjectRoot());
this.preflightChecker = new PreflightChecker(this.projectRoot);
this.briefsDomain = new BriefsDomain();
}
@@ -56,6 +66,13 @@ export class TasksDomain {
// TagService needs storage - get it from TaskService AFTER initialization
this.tagService = new TagService(this.taskService.getStorage());
// TaskFileGeneratorService needs storage and config for active tag
this.taskFileGenerator = new TaskFileGeneratorService(
this.taskService.getStorage(),
this.projectRoot,
this.configManager
);
}
// ========== Task Retrieval ==========
@@ -371,4 +388,43 @@ export class TasksDomain {
getStorageType(): 'file' | 'api' {
return this.taskService.getStorageType();
}
// ========== Task File Generation ==========
/**
* Generate individual task markdown files from tasks.json
* This writes .md files for each task in the tasks directory.
*
* Note: Only applicable for file storage. API storage throws an error.
*
* @param options - Generation options (tag, outputDir)
* @returns Result with count of generated files and cleanup info
*/
async generateTaskFiles(
options?: GenerateTaskFilesOptions
): Promise<GenerateTaskFilesResult> {
// Only file storage supports task file generation
if (this.getStorageType() === 'api') {
return {
success: false,
count: 0,
directory: '',
orphanedFilesRemoved: 0,
error:
'Task file generation is only available for local file storage. API storage manages tasks in the cloud.'
};
}
return this.taskFileGenerator.generateTaskFiles(options);
}
// ========== Cleanup ==========
/**
* Close and cleanup resources
* Releases file locks and other storage resources
*/
async close(): Promise<void> {
await this.taskService.close();
}
}

View File

@@ -0,0 +1,22 @@
/**
* @fileoverview Testing utilities for @tm/core
*
* This module provides test fixtures and utilities for testing applications
* that use @tm/core. Import from '@tm/core/testing' or '@tm/core'.
*
* @example
* ```ts
* import { createTask, createTasksFile, TaskScenarios } from '@tm/core/testing';
*
* const task = createTask({ id: 1, title: 'Test Task' });
* const tasksFile = TaskScenarios.linearDependencyChain();
* ```
*/
export {
createTask,
createSubtask,
createTasksFile,
TaskScenarios,
type TasksFile
} from './task-fixtures.js';

View File

@@ -9,7 +9,7 @@
*
* USAGE:
* ```ts
* import { createTask, createTasksFile } from '../fixtures/task-fixtures';
* import { createTask, createTasksFile } from '@tm/core/testing';
*
* // Create a single task with defaults
* const task = createTask({ id: 1, title: 'My Task', status: 'pending' });
@@ -24,7 +24,7 @@
* ```
*/
import type { Task, Subtask, TaskMetadata } from '@tm/core';
import type { Task, Subtask, TaskMetadata } from '../common/types/index.js';
/**
* File structure for tasks.json

View File

@@ -203,6 +203,17 @@ export class TmCore {
get projectPath(): string {
return this._projectPath;
}
/**
* Close and cleanup resources
* Releases file locks and other storage resources
* Should be called when done using TmCore, especially in tests
*/
async close(): Promise<void> {
if (this._tasks) {
await this._tasks.close();
}
}
}
/**

View File

@@ -15,7 +15,7 @@ import {
export const EXPECTED_TOOL_COUNTS = {
core: 7,
standard: 14,
total: 43
total: 44
};
/**