From 2316e94b288915bb906e1a61a87f59e291594fef Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:32:50 +0100 Subject: [PATCH] feat: reintroduce `task-master generate` command for task file generation (#1446) --- .changeset/pretty-suits-marry.md | 11 + apps/cli/src/command-registry.ts | 7 + apps/cli/src/commands/generate.command.ts | 251 +++++++++++++ .../commands/generate.command.test.ts | 224 ++++++++++++ .../integration/commands/list.command.test.ts | 6 +- .../integration/commands/next.command.test.ts | 2 +- .../commands/set-status.command.test.ts | 2 +- .../integration/commands/show.command.test.ts | 6 +- apps/mcp/src/tools/tasks/generate.tool.ts | 106 ++++++ apps/mcp/src/tools/tasks/index.ts | 1 + apps/mcp/tests/fixtures/task-fixtures.ts | 1 - .../integration/tools/generate.tool.test.ts | 199 ++++++++++ .../integration/tools/get-tasks.tool.test.ts | 2 +- mcp-server/src/tools/tool-registry.js | 8 +- packages/tm-core/package.json | 1 + packages/tm-core/src/index.ts | 16 + .../tm-core/src/modules/auth/constants.ts | 3 +- .../task-file-generator.service.spec.ts | 329 +++++++++++++++++ .../services/task-file-generator.service.ts | 341 ++++++++++++++++++ .../modules/tasks/services/task-service.ts | 11 + .../tm-core/src/modules/tasks/tasks-domain.ts | 58 ++- packages/tm-core/src/testing/index.ts | 22 ++ .../tm-core/src/testing}/task-fixtures.ts | 4 +- packages/tm-core/src/tm-core.ts | 11 + tests/helpers/tool-counts.js | 2 +- 25 files changed, 1602 insertions(+), 22 deletions(-) create mode 100644 .changeset/pretty-suits-marry.md create mode 100644 apps/cli/src/commands/generate.command.ts create mode 100644 apps/cli/tests/integration/commands/generate.command.test.ts create mode 100644 apps/mcp/src/tools/tasks/generate.tool.ts delete mode 120000 apps/mcp/tests/fixtures/task-fixtures.ts create mode 100644 apps/mcp/tests/integration/tools/generate.tool.test.ts create mode 100644 packages/tm-core/src/modules/tasks/services/task-file-generator.service.spec.ts create mode 100644 packages/tm-core/src/modules/tasks/services/task-file-generator.service.ts create mode 100644 packages/tm-core/src/testing/index.ts rename {apps/cli/tests/fixtures => packages/tm-core/src/testing}/task-fixtures.ts (98%) diff --git a/.changeset/pretty-suits-marry.md b/.changeset/pretty-suits-marry.md new file mode 100644 index 00000000..a13f1610 --- /dev/null +++ b/.changeset/pretty-suits-marry.md @@ -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. diff --git a/apps/cli/src/command-registry.ts b/apps/cli/src/command-registry.ts index d1519545..ecdebf89 100644 --- a/apps/cli/src/command-registry.ts +++ b/apps/cli/src/command-registry.ts @@ -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' } ]; diff --git a/apps/cli/src/commands/generate.command.ts b/apps/cli/src/commands/generate.command.ts new file mode 100644 index 00000000..36d78c37 --- /dev/null +++ b/apps/cli/src/commands/generate.command.ts @@ -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 context for task operations') + .option( + '-o, --output ', + 'Output directory for generated files (defaults to .taskmaster/tasks)' + ) + .option( + '-p, --project ', + 'Project root directory (auto-detected if not provided)' + ) + .option('-f, --format ', 'Output format (text, json)', 'text') + .action(async (options: GenerateCommandOptions) => { + await this.executeCommand(options); + }); + } + + /** + * Execute the generate command + */ + private async executeCommand(options: GenerateCommandOptions): Promise { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/cli/tests/integration/commands/generate.command.test.ts b/apps/cli/tests/integration/commands/generate.command.test.ts new file mode 100644 index 00000000..403c8e66 --- /dev/null +++ b/apps/cli/tests/integration/commands/generate.command.test.ts @@ -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/); + }); +}); diff --git a/apps/cli/tests/integration/commands/list.command.test.ts b/apps/cli/tests/integration/commands/list.command.test.ts index 7b713fa6..da7516bb 100644 --- a/apps/cli/tests/integration/commands/list.command.test.ts +++ b/apps/cli/tests/integration/commands/list.command.test.ts @@ -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 diff --git a/apps/cli/tests/integration/commands/next.command.test.ts b/apps/cli/tests/integration/commands/next.command.test.ts index 7fbfa90f..00d98783 100644 --- a/apps/cli/tests/integration/commands/next.command.test.ts +++ b/apps/cli/tests/integration/commands/next.command.test.ts @@ -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 diff --git a/apps/cli/tests/integration/commands/set-status.command.test.ts b/apps/cli/tests/integration/commands/set-status.command.test.ts index 43771c8d..4366a950 100644 --- a/apps/cli/tests/integration/commands/set-status.command.test.ts +++ b/apps/cli/tests/integration/commands/set-status.command.test.ts @@ -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 diff --git a/apps/cli/tests/integration/commands/show.command.test.ts b/apps/cli/tests/integration/commands/show.command.test.ts index e30358da..739c91bc 100644 --- a/apps/cli/tests/integration/commands/show.command.test.ts +++ b/apps/cli/tests/integration/commands/show.command.test.ts @@ -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 diff --git a/apps/mcp/src/tools/tasks/generate.tool.ts b/apps/mcp/src/tools/tasks/generate.tool.ts new file mode 100644 index 00000000..8a014184 --- /dev/null +++ b/apps/mcp/src/tools/tasks/generate.tool.ts @@ -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; + +/** + * 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 + }); + } + } + ) + }); +} diff --git a/apps/mcp/src/tools/tasks/index.ts b/apps/mcp/src/tools/tasks/index.ts index c3105ac2..aa919a50 100644 --- a/apps/mcp/src/tools/tasks/index.ts +++ b/apps/mcp/src/tools/tasks/index.ts @@ -5,3 +5,4 @@ export { registerGetTasksTool } from './get-tasks.tool.js'; export { registerGetTaskTool } from './get-task.tool.js'; +export { registerGenerateTool } from './generate.tool.js'; diff --git a/apps/mcp/tests/fixtures/task-fixtures.ts b/apps/mcp/tests/fixtures/task-fixtures.ts deleted file mode 120000 index 57b25332..00000000 --- a/apps/mcp/tests/fixtures/task-fixtures.ts +++ /dev/null @@ -1 +0,0 @@ -../../../cli/tests/fixtures/task-fixtures.ts \ No newline at end of file diff --git a/apps/mcp/tests/integration/tools/generate.tool.test.ts b/apps/mcp/tests/integration/tools/generate.tool.test.ts new file mode 100644 index 00000000..46a2afcb --- /dev/null +++ b/apps/mcp/tests/integration/tools/generate.tool.test.ts @@ -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: "" }] } + */ + const callMCPTool = (toolName: string, args: Record): 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: "" }] } + 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); +}); diff --git a/apps/mcp/tests/integration/tools/get-tasks.tool.test.ts b/apps/mcp/tests/integration/tools/get-tasks.tool.test.ts index 6e92c796..aac5fc4e 100644 --- a/apps/mcp/tests/integration/tools/get-tasks.tool.test.ts +++ b/apps/mcp/tests/integration/tools/get-tasks.tool.test.ts @@ -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; diff --git a/mcp-server/src/tools/tool-registry.js b/mcp-server/src/tools/tool-registry.js index 971be22d..b56ad7a3 100644 --- a/mcp-server/src/tools/tool-registry.js +++ b/mcp-server/src/tools/tool-registry.js @@ -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 }; /** diff --git a/packages/tm-core/package.json b/packages/tm-core/package.json index 4385824d..4724649e 100644 --- a/packages/tm-core/package.json +++ b/packages/tm-core/package.json @@ -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", diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index fbeb5bf6..171c3a5c 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -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'; diff --git a/packages/tm-core/src/modules/auth/constants.ts b/packages/tm-core/src/modules/auth/constants.ts index 81171184..1eb00814 100644 --- a/packages/tm-core/src/modules/auth/constants.ts +++ b/packages/tm-core/src/modules/auth/constants.ts @@ -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]; diff --git a/packages/tm-core/src/modules/tasks/services/task-file-generator.service.spec.ts b/packages/tm-core/src/modules/tasks/services/task-file-generator.service.spec.ts new file mode 100644 index 00000000..3d489006 --- /dev/null +++ b/packages/tm-core/src/modules/tasks/services/task-file-generator.service.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/tm-core/src/modules/tasks/services/task-file-generator.service.ts b/packages/tm-core/src/modules/tasks/services/task-file-generator.service.ts new file mode 100644 index 00000000..f6494237 --- /dev/null +++ b/packages/tm-core/src/modules/tasks/services/task-file-generator.service.ts @@ -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; +} + +/** + * 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 { + 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 = {}; + 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 { + 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 ''; + } + } +} diff --git a/packages/tm-core/src/modules/tasks/services/task-service.ts b/packages/tm-core/src/modules/tasks/services/task-service.ts index 84350857..ea2a0620 100644 --- a/packages/tm-core/src/modules/tasks/services/task-service.ts +++ b/packages/tm-core/src/modules/tasks/services/task-service.ts @@ -792,4 +792,15 @@ export class TaskService { ); } } + + /** + * Close and cleanup resources + * Releases file locks and other storage resources + */ + async close(): Promise { + if (this.storage) { + await this.storage.close(); + } + this.initialized = false; + } } diff --git a/packages/tm-core/src/modules/tasks/tasks-domain.ts b/packages/tm-core/src/modules/tasks/tasks-domain.ts index 027db554..6ccb5645 100644 --- a/packages/tm-core/src/modules/tasks/tasks-domain.ts +++ b/packages/tm-core/src/modules/tasks/tasks-domain.ts @@ -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 { + // 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 { + await this.taskService.close(); + } } diff --git a/packages/tm-core/src/testing/index.ts b/packages/tm-core/src/testing/index.ts new file mode 100644 index 00000000..72e5b95c --- /dev/null +++ b/packages/tm-core/src/testing/index.ts @@ -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'; diff --git a/apps/cli/tests/fixtures/task-fixtures.ts b/packages/tm-core/src/testing/task-fixtures.ts similarity index 98% rename from apps/cli/tests/fixtures/task-fixtures.ts rename to packages/tm-core/src/testing/task-fixtures.ts index bbd45983..e39e1947 100644 --- a/apps/cli/tests/fixtures/task-fixtures.ts +++ b/packages/tm-core/src/testing/task-fixtures.ts @@ -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 diff --git a/packages/tm-core/src/tm-core.ts b/packages/tm-core/src/tm-core.ts index 65362805..4511c1c5 100644 --- a/packages/tm-core/src/tm-core.ts +++ b/packages/tm-core/src/tm-core.ts @@ -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 { + if (this._tasks) { + await this._tasks.close(); + } + } } /** diff --git a/tests/helpers/tool-counts.js b/tests/helpers/tool-counts.js index b9f34c6c..9da0fa06 100644 --- a/tests/helpers/tool-counts.js +++ b/tests/helpers/tool-counts.js @@ -15,7 +15,7 @@ import { export const EXPECTED_TOOL_COUNTS = { core: 7, standard: 14, - total: 43 + total: 44 }; /**