feat: implement tdd workflow (#1309)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Ralph Khreish
2025-10-18 16:29:03 +02:00
committed by GitHub
parent b8830d9508
commit ccb87a516a
106 changed files with 25097 additions and 1541 deletions

View File

@@ -14,6 +14,7 @@ import { ContextCommand } from './commands/context.command.js';
import { StartCommand } from './commands/start.command.js';
import { SetStatusCommand } from './commands/set-status.command.js';
import { ExportCommand } from './commands/export.command.js';
import { AutopilotCommand } from './commands/autopilot/index.js';
/**
* Command metadata for registration
@@ -70,6 +71,13 @@ export class CommandRegistry {
commandClass: ExportCommand as any,
category: 'task'
},
{
name: 'autopilot',
description:
'AI agent orchestration for TDD workflow (start, resume, next, complete, commit, status, abort)',
commandClass: AutopilotCommand as any,
category: 'development'
},
// Authentication & Context Commands
{

View File

@@ -0,0 +1,515 @@
/**
* @fileoverview AutopilotCommand using Commander's native class pattern
* Extends Commander.Command for better integration with the framework
* This is a thin presentation layer over @tm/core's autopilot functionality
*/
import { Command } from 'commander';
import chalk from 'chalk';
import boxen from 'boxen';
import ora, { type Ora } from 'ora';
import {
createTaskMasterCore,
type TaskMasterCore,
type Task,
type Subtask
} from '@tm/core';
import * as ui from '../utils/ui.js';
/**
* CLI-specific options interface for the autopilot command
*/
export interface AutopilotCommandOptions {
format?: 'text' | 'json';
project?: string;
dryRun?: boolean;
}
/**
* Preflight check result for a single check
*/
export interface PreflightCheckResult {
success: boolean;
message?: string;
}
/**
* Overall preflight check results
*/
export interface PreflightResult {
success: boolean;
testCommand: PreflightCheckResult;
gitWorkingTree: PreflightCheckResult;
requiredTools: PreflightCheckResult;
defaultBranch: PreflightCheckResult;
}
/**
* CLI-specific result type from autopilot command
*/
export interface AutopilotCommandResult {
success: boolean;
taskId: string;
task?: Task;
error?: string;
message?: string;
}
/**
* AutopilotCommand extending Commander's Command class
* This is a thin presentation layer over @tm/core's autopilot functionality
*/
export class AutopilotCommand extends Command {
private tmCore?: TaskMasterCore;
private lastResult?: AutopilotCommandResult;
constructor(name?: string) {
super(name || 'autopilot');
// Configure the command
this.description(
'Execute a task autonomously using TDD workflow with git integration'
)
.argument('<taskId>', 'Task ID to execute autonomously')
.option('-f, --format <format>', 'Output format (text, json)', 'text')
.option('-p, --project <path>', 'Project root directory', process.cwd())
.option(
'--dry-run',
'Show what would be executed without performing actions'
)
.action(async (taskId: string, options: AutopilotCommandOptions) => {
await this.executeCommand(taskId, options);
});
}
/**
* Execute the autopilot command
*/
private async executeCommand(
taskId: string,
options: AutopilotCommandOptions
): Promise<void> {
let spinner: Ora | null = null;
try {
// Validate options
if (!this.validateOptions(options)) {
process.exit(1);
}
// Validate task ID format
if (!this.validateTaskId(taskId)) {
ui.displayError(`Invalid task ID format: ${taskId}`);
process.exit(1);
}
// Initialize tm-core with spinner
spinner = ora('Initializing Task Master...').start();
await this.initializeCore(options.project || process.cwd());
spinner.succeed('Task Master initialized');
// Load and validate task existence
spinner = ora(`Loading task ${taskId}...`).start();
const task = await this.loadTask(taskId);
if (!task) {
spinner.fail(`Task ${taskId} not found`);
ui.displayError(`Task with ID ${taskId} does not exist`);
process.exit(1);
}
spinner.succeed(`Task ${taskId} loaded`);
// Display task information
this.displayTaskInfo(task, options.dryRun || false);
// Execute autopilot logic (placeholder for now)
const result = await this.performAutopilot(taskId, task, options);
// Store result for programmatic access
this.setLastResult(result);
// Display results
this.displayResults(result, options);
} catch (error: unknown) {
if (spinner) {
spinner.fail('Operation failed');
}
this.handleError(error);
process.exit(1);
}
}
/**
* Validate command options
*/
private validateOptions(options: AutopilotCommandOptions): boolean {
// Validate format
if (options.format && !['text', 'json'].includes(options.format)) {
console.error(chalk.red(`Invalid format: ${options.format}`));
console.error(chalk.gray(`Valid formats: text, json`));
return false;
}
return true;
}
/**
* Validate task ID format
*/
private validateTaskId(taskId: string): boolean {
// Task ID should be a number or number.number format (e.g., "1" or "1.2")
const taskIdPattern = /^\d+(\.\d+)*$/;
return taskIdPattern.test(taskId);
}
/**
* Initialize TaskMasterCore
*/
private async initializeCore(projectRoot: string): Promise<void> {
if (!this.tmCore) {
this.tmCore = await createTaskMasterCore({ projectPath: projectRoot });
}
}
/**
* Load task from tm-core
*/
private async loadTask(taskId: string): Promise<Task | null> {
if (!this.tmCore) {
throw new Error('TaskMasterCore not initialized');
}
try {
const { task } = await this.tmCore.getTaskWithSubtask(taskId);
return task;
} catch (error) {
return null;
}
}
/**
* Display task information before execution
*/
private displayTaskInfo(task: Task, isDryRun: boolean): void {
const prefix = isDryRun ? '[DRY RUN] ' : '';
console.log();
console.log(
boxen(
chalk.cyan.bold(`${prefix}Autopilot Task Execution`) +
'\n\n' +
chalk.white(`Task ID: ${task.id}`) +
'\n' +
chalk.white(`Title: ${task.title}`) +
'\n' +
chalk.white(`Status: ${task.status}`) +
(task.description ? '\n\n' + chalk.gray(task.description) : ''),
{
padding: 1,
borderStyle: 'round',
borderColor: 'cyan',
width: process.stdout.columns ? process.stdout.columns * 0.95 : 100
}
)
);
console.log();
}
/**
* Perform autopilot execution using PreflightChecker and TaskLoader
*/
private async performAutopilot(
taskId: string,
task: Task,
options: AutopilotCommandOptions
): Promise<AutopilotCommandResult> {
// Run preflight checks
const preflightResult = await this.runPreflightChecks(options);
if (!preflightResult.success) {
return {
success: false,
taskId,
task,
error: 'Preflight checks failed',
message: 'Please resolve the issues above before running autopilot'
};
}
// Validate task structure and get execution order
const validationResult = await this.validateTaskStructure(
taskId,
task,
options
);
if (!validationResult.success) {
return validationResult;
}
// Display execution plan
this.displayExecutionPlan(
validationResult.task!,
validationResult.orderedSubtasks!,
options
);
return {
success: true,
taskId,
task: validationResult.task,
message: options.dryRun
? 'Dry run completed successfully'
: 'Autopilot execution ready (actual execution not yet implemented)'
};
}
/**
* Run preflight checks and display results
*/
private async runPreflightChecks(
options: AutopilotCommandOptions
): Promise<PreflightResult> {
const { PreflightChecker } = await import('@tm/core');
console.log();
console.log(chalk.cyan.bold('Running preflight checks...'));
const preflightChecker = new PreflightChecker(
options.project || process.cwd()
);
const result = await preflightChecker.runAllChecks();
this.displayPreflightResults(result);
return result;
}
/**
* Validate task structure and get execution order
*/
private async validateTaskStructure(
taskId: string,
task: Task,
options: AutopilotCommandOptions
): Promise<AutopilotCommandResult & { orderedSubtasks?: Subtask[] }> {
const { TaskLoaderService } = await import('@tm/core');
console.log();
console.log(chalk.cyan.bold('Validating task structure...'));
const taskLoader = new TaskLoaderService(options.project || process.cwd());
const validationResult = await taskLoader.loadAndValidateTask(taskId);
if (!validationResult.success) {
await taskLoader.cleanup();
return {
success: false,
taskId,
task,
error: validationResult.errorMessage,
message: validationResult.suggestion
};
}
const orderedSubtasks = taskLoader.getExecutionOrder(
validationResult.task!
);
await taskLoader.cleanup();
return {
success: true,
taskId,
task: validationResult.task,
orderedSubtasks
};
}
/**
* Display execution plan with subtasks and TDD workflow
*/
private displayExecutionPlan(
task: Task,
orderedSubtasks: Subtask[],
options: AutopilotCommandOptions
): void {
console.log();
console.log(chalk.green.bold('✓ All checks passed!'));
console.log();
console.log(chalk.cyan.bold('Execution Plan:'));
console.log(chalk.white(`Task: ${task.title}`));
console.log(
chalk.gray(
`${orderedSubtasks.length} subtasks will be executed in dependency order`
)
);
console.log();
// Display subtasks
orderedSubtasks.forEach((subtask: Subtask, index: number) => {
console.log(
chalk.yellow(`${index + 1}. ${task.id}.${subtask.id}: ${subtask.title}`)
);
if (subtask.dependencies && subtask.dependencies.length > 0) {
console.log(
chalk.gray(` Dependencies: ${subtask.dependencies.join(', ')}`)
);
}
});
console.log();
console.log(
chalk.cyan('Autopilot would execute each subtask using TDD workflow:')
);
console.log(chalk.gray(' 1. RED phase: Write failing test'));
console.log(chalk.gray(' 2. GREEN phase: Implement code to pass test'));
console.log(chalk.gray(' 3. COMMIT phase: Commit changes'));
console.log();
if (options.dryRun) {
console.log(
chalk.yellow('This was a dry run. Use without --dry-run to execute.')
);
}
}
/**
* Display preflight check results
*/
private displayPreflightResults(result: PreflightResult): void {
const checks = [
{ name: 'Test command', result: result.testCommand },
{ name: 'Git working tree', result: result.gitWorkingTree },
{ name: 'Required tools', result: result.requiredTools },
{ name: 'Default branch', result: result.defaultBranch }
];
checks.forEach((check) => {
const icon = check.result.success ? chalk.green('✓') : chalk.red('✗');
const status = check.result.success
? chalk.green('PASS')
: chalk.red('FAIL');
console.log(`${icon} ${chalk.white(check.name)}: ${status}`);
if (check.result.message) {
console.log(chalk.gray(` ${check.result.message}`));
}
});
}
/**
* Display results based on format
*/
private displayResults(
result: AutopilotCommandResult,
options: AutopilotCommandOptions
): void {
const format = options.format || 'text';
switch (format) {
case 'json':
this.displayJson(result);
break;
case 'text':
default:
this.displayTextResult(result);
break;
}
}
/**
* Display in JSON format
*/
private displayJson(result: AutopilotCommandResult): void {
console.log(JSON.stringify(result, null, 2));
}
/**
* Display result in text format
*/
private displayTextResult(result: AutopilotCommandResult): void {
if (result.success) {
console.log(
boxen(
chalk.green.bold('✓ Autopilot Command Completed') +
'\n\n' +
chalk.white(result.message || 'Execution complete'),
{
padding: 1,
borderStyle: 'round',
borderColor: 'green',
margin: { top: 1 }
}
)
);
} else {
console.log(
boxen(
chalk.red.bold('✗ Autopilot Command Failed') +
'\n\n' +
chalk.white(result.error || 'Unknown error'),
{
padding: 1,
borderStyle: 'round',
borderColor: 'red',
margin: { top: 1 }
}
)
);
}
}
/**
* Handle general errors
*/
private handleError(error: unknown): void {
const errorObj = error as {
getSanitizedDetails?: () => { message: string };
message?: string;
stack?: string;
};
const msg = errorObj?.getSanitizedDetails?.() ?? {
message: errorObj?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
// Show stack trace in development mode or when DEBUG is set
const isDevelopment = process.env.NODE_ENV !== 'production';
if ((isDevelopment || process.env.DEBUG) && errorObj.stack) {
console.error(chalk.gray(errorObj.stack));
}
}
/**
* Set the last result for programmatic access
*/
private setLastResult(result: AutopilotCommandResult): void {
this.lastResult = result;
}
/**
* Get the last result (for programmatic usage)
*/
getLastResult(): AutopilotCommandResult | undefined {
return this.lastResult;
}
/**
* Clean up resources
*/
async cleanup(): Promise<void> {
if (this.tmCore) {
await this.tmCore.close();
this.tmCore = undefined;
}
}
/**
* Register this command on an existing program
*/
static register(program: Command, name?: string): AutopilotCommand {
const autopilotCommand = new AutopilotCommand(name);
program.addCommand(autopilotCommand);
return autopilotCommand;
}
}

View File

@@ -0,0 +1,119 @@
/**
* @fileoverview Abort Command - Safely terminate workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
deleteWorkflowState,
OutputFormatter
} from './shared.js';
import inquirer from 'inquirer';
interface AbortOptions extends AutopilotBaseOptions {
force?: boolean;
}
/**
* Abort Command - Safely terminate workflow and clean up state
*/
export class AbortCommand extends Command {
constructor() {
super('abort');
this.description('Abort the current TDD workflow and clean up state')
.option('-f, --force', 'Force abort without confirmation')
.action(async (options: AbortOptions) => {
await this.execute(options);
});
}
private async execute(options: AbortOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: AbortOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.warning('No active workflow to abort');
return;
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Restore orchestrator
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
// Get progress before abort
const progress = orchestrator.getProgress();
const currentSubtask = orchestrator.getCurrentSubtask();
// Confirm abort if not forced or in JSON mode
if (!mergedOptions.force && !mergedOptions.json) {
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message:
`This will abort the workflow for task ${state.context.taskId}. ` +
`Progress: ${progress.completed}/${progress.total} subtasks completed. ` +
`Continue?`,
default: false
}
]);
if (!confirmed) {
formatter.info('Abort cancelled');
return;
}
}
// Trigger abort in orchestrator
orchestrator.transition({ type: 'ABORT' });
// Delete workflow state
await deleteWorkflowState(mergedOptions.projectRoot!);
// Output result
formatter.success('Workflow aborted', {
taskId: state.context.taskId,
branchName: state.context.branchName,
progress: {
completed: progress.completed,
total: progress.total
},
lastSubtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title
}
: null,
note: 'Branch and commits remain. Clean up manually if needed.'
});
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,169 @@
/**
* @fileoverview Commit Command - Create commit with enhanced message generation
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
createGitAdapter,
createCommitMessageGenerator,
OutputFormatter,
saveWorkflowState
} from './shared.js';
type CommitOptions = AutopilotBaseOptions;
/**
* Commit Command - Create commit using enhanced message generator
*/
export class CommitCommand extends Command {
constructor() {
super('commit');
this.description('Create a commit for the completed GREEN phase').action(
async (options: CommitOptions) => {
await this.execute(options);
}
);
}
private async execute(options: CommitOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: CommitOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
orchestrator.enableAutoPersist(async (newState) => {
await saveWorkflowState(mergedOptions.projectRoot!, newState);
});
// Verify in COMMIT phase
const tddPhase = orchestrator.getCurrentTDDPhase();
if (tddPhase !== 'COMMIT') {
formatter.error('Not in COMMIT phase', {
currentPhase: tddPhase || orchestrator.getCurrentPhase(),
suggestion: 'Complete RED and GREEN phases first'
});
process.exit(1);
}
// Get current subtask
const currentSubtask = orchestrator.getCurrentSubtask();
if (!currentSubtask) {
formatter.error('No current subtask');
process.exit(1);
}
// Initialize git adapter
const gitAdapter = createGitAdapter(mergedOptions.projectRoot!);
await gitAdapter.ensureGitRepository();
// Check for staged changes
const hasStagedChanges = await gitAdapter.hasStagedChanges();
if (!hasStagedChanges) {
// Stage all changes
formatter.info('No staged changes, staging all changes...');
await gitAdapter.stageFiles(['.']);
}
// Get changed files for scope detection
const status = await gitAdapter.getStatus();
const changedFiles = [...status.staged, ...status.modified];
// Generate commit message
const messageGenerator = createCommitMessageGenerator();
const testResults = state.context.lastTestResults;
const commitMessage = messageGenerator.generateMessage({
type: 'feat',
description: currentSubtask.title,
changedFiles,
taskId: state.context.taskId,
phase: 'TDD',
tag: (state.context.metadata.tag as string) || undefined,
testsPassing: testResults?.passed,
testsFailing: testResults?.failed,
coveragePercent: undefined // Could be added if available
});
// Create commit with metadata
await gitAdapter.createCommit(commitMessage, {
metadata: {
taskId: state.context.taskId,
subtaskId: currentSubtask.id,
phase: 'COMMIT',
tddCycle: 'complete'
}
});
// Get commit info
const lastCommit = await gitAdapter.getLastCommit();
// Complete COMMIT phase (this marks subtask as completed)
orchestrator.transition({ type: 'COMMIT_COMPLETE' });
// Check if should advance to next subtask
const progress = orchestrator.getProgress();
if (progress.current < progress.total) {
orchestrator.transition({ type: 'SUBTASK_COMPLETE' });
} else {
// All subtasks complete
orchestrator.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
}
// Output success
formatter.success('Commit created', {
commitHash: lastCommit.hash.substring(0, 7),
message: commitMessage.split('\n')[0], // First line only
subtask: {
id: currentSubtask.id,
title: currentSubtask.title,
status: currentSubtask.status
},
progress: {
completed: progress.completed,
total: progress.total,
percentage: progress.percentage
},
nextAction:
progress.completed < progress.total
? 'Start next subtask with RED phase'
: 'All subtasks complete. Run: autopilot status'
});
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,172 @@
/**
* @fileoverview Complete Command - Complete current TDD phase with validation
*/
import { Command } from 'commander';
import { WorkflowOrchestrator, TestResult } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
} from './shared.js';
interface CompleteOptions extends AutopilotBaseOptions {
results?: string;
coverage?: string;
}
/**
* Complete Command - Mark current phase as complete with validation
*/
export class CompleteCommand extends Command {
constructor() {
super('complete');
this.description('Complete the current TDD phase with result validation')
.option(
'-r, --results <json>',
'Test results JSON (with total, passed, failed, skipped)'
)
.option('-c, --coverage <percent>', 'Coverage percentage')
.action(async (options: CompleteOptions) => {
await this.execute(options);
});
}
private async execute(options: CompleteOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: CompleteOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Restore orchestrator with persistence
const { saveWorkflowState } = await import('./shared.js');
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
orchestrator.enableAutoPersist(async (newState) => {
await saveWorkflowState(mergedOptions.projectRoot!, newState);
});
// Get current phase
const tddPhase = orchestrator.getCurrentTDDPhase();
const currentSubtask = orchestrator.getCurrentSubtask();
if (!tddPhase) {
formatter.error('Not in a TDD phase', {
phase: orchestrator.getCurrentPhase()
});
process.exit(1);
}
// Validate based on phase
if (tddPhase === 'RED' || tddPhase === 'GREEN') {
if (!mergedOptions.results) {
formatter.error('Test results required for RED/GREEN phase', {
usage:
'--results \'{"total":10,"passed":9,"failed":1,"skipped":0}\''
});
process.exit(1);
}
// Parse test results
let testResults: TestResult;
try {
const parsed = JSON.parse(mergedOptions.results);
testResults = {
total: parsed.total || 0,
passed: parsed.passed || 0,
failed: parsed.failed || 0,
skipped: parsed.skipped || 0,
phase: tddPhase
};
} catch (error) {
formatter.error('Invalid test results JSON', {
error: (error as Error).message
});
process.exit(1);
}
// Validate RED phase requirements
if (tddPhase === 'RED' && testResults.failed === 0) {
formatter.error('RED phase validation failed', {
reason: 'At least one test must be failing',
actual: {
passed: testResults.passed,
failed: testResults.failed
}
});
process.exit(1);
}
// Validate GREEN phase requirements
if (tddPhase === 'GREEN' && testResults.failed !== 0) {
formatter.error('GREEN phase validation failed', {
reason: 'All tests must pass',
actual: {
passed: testResults.passed,
failed: testResults.failed
}
});
process.exit(1);
}
// Complete phase with test results
if (tddPhase === 'RED') {
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults
});
formatter.success('RED phase completed', {
nextPhase: 'GREEN',
testResults,
subtask: currentSubtask?.title
});
} else {
orchestrator.transition({
type: 'GREEN_PHASE_COMPLETE',
testResults
});
formatter.success('GREEN phase completed', {
nextPhase: 'COMMIT',
testResults,
subtask: currentSubtask?.title,
suggestion: 'Run: autopilot commit'
});
}
} else if (tddPhase === 'COMMIT') {
formatter.error('Use "autopilot commit" to complete COMMIT phase');
process.exit(1);
}
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,82 @@
/**
* @fileoverview Autopilot CLI Commands for AI Agent Orchestration
* Provides subcommands for starting, resuming, and advancing the TDD workflow
* with JSON output for machine parsing.
*/
import { Command } from 'commander';
import { StartCommand } from './start.command.js';
import { ResumeCommand } from './resume.command.js';
import { NextCommand } from './next.command.js';
import { CompleteCommand } from './complete.command.js';
import { CommitCommand } from './commit.command.js';
import { StatusCommand } from './status.command.js';
import { AbortCommand } from './abort.command.js';
/**
* Shared command options for all autopilot commands
*/
export interface AutopilotBaseOptions {
json?: boolean;
verbose?: boolean;
projectRoot?: string;
}
/**
* AutopilotCommand with subcommands for TDD workflow orchestration
*/
export class AutopilotCommand extends Command {
constructor() {
super('autopilot');
// Configure main command
this.description('AI agent orchestration for TDD workflow execution')
.alias('ap')
// Global options for all subcommands
.option('--json', 'Output in JSON format for machine parsing')
.option('-v, --verbose', 'Enable verbose output')
.option(
'-p, --project-root <path>',
'Project root directory',
process.cwd()
);
// Register subcommands
this.registerSubcommands();
}
/**
* Register all autopilot subcommands
*/
private registerSubcommands(): void {
// Start new TDD workflow
this.addCommand(new StartCommand());
// Resume existing workflow
this.addCommand(new ResumeCommand());
// Get next action
this.addCommand(new NextCommand());
// Complete current phase
this.addCommand(new CompleteCommand());
// Create commit
this.addCommand(new CommitCommand());
// Show status
this.addCommand(new StatusCommand());
// Abort workflow
this.addCommand(new AbortCommand());
}
/**
* Register this command on an existing program
*/
static register(program: Command): AutopilotCommand {
const autopilotCommand = new AutopilotCommand();
program.addCommand(autopilotCommand);
return autopilotCommand;
}
}

View File

@@ -0,0 +1,164 @@
/**
* @fileoverview Next Command - Get next action in TDD workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
} from './shared.js';
type NextOptions = AutopilotBaseOptions;
/**
* Next Command - Get next action details
*/
export class NextCommand extends Command {
constructor() {
super('next');
this.description(
'Get the next action to perform in the TDD workflow'
).action(async (options: NextOptions) => {
await this.execute(options);
});
}
private async execute(options: NextOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: NextOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Restore orchestrator
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
// Get current phase and subtask
const phase = orchestrator.getCurrentPhase();
const tddPhase = orchestrator.getCurrentTDDPhase();
const currentSubtask = orchestrator.getCurrentSubtask();
// Determine next action based on phase
let actionType: string;
let actionDescription: string;
let actionDetails: Record<string, unknown> = {};
if (phase === 'COMPLETE') {
formatter.success('Workflow complete', {
message: 'All subtasks have been completed',
taskId: state.context.taskId
});
return;
}
if (phase === 'SUBTASK_LOOP' && tddPhase) {
switch (tddPhase) {
case 'RED':
actionType = 'generate_test';
actionDescription = 'Write failing test for current subtask';
actionDetails = {
subtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null,
testCommand: 'npm test', // Could be customized based on config
expectedOutcome: 'Test should fail'
};
break;
case 'GREEN':
actionType = 'implement_code';
actionDescription = 'Implement code to pass the failing test';
actionDetails = {
subtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null,
testCommand: 'npm test',
expectedOutcome: 'All tests should pass',
lastTestResults: state.context.lastTestResults
};
break;
case 'COMMIT':
actionType = 'commit_changes';
actionDescription = 'Commit the changes';
actionDetails = {
subtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null,
suggestion: 'Use: autopilot commit'
};
break;
default:
actionType = 'unknown';
actionDescription = 'Unknown TDD phase';
}
} else {
actionType = 'workflow_phase';
actionDescription = `Currently in ${phase} phase`;
}
// Output next action
const output = {
action: actionType,
description: actionDescription,
phase,
tddPhase,
taskId: state.context.taskId,
branchName: state.context.branchName,
...actionDetails
};
if (mergedOptions.json) {
formatter.output(output);
} else {
formatter.success('Next action', output);
}
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,111 @@
/**
* @fileoverview Resume Command - Restore and resume TDD workflow
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
} from './shared.js';
type ResumeOptions = AutopilotBaseOptions;
/**
* Resume Command - Restore workflow from saved state
*/
export class ResumeCommand extends Command {
constructor() {
super('resume');
this.description('Resume a previously started TDD workflow').action(
async (options: ResumeOptions) => {
await this.execute(options);
}
);
}
private async execute(options: ResumeOptions): Promise<void> {
// Inherit parent options (autopilot command)
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: ResumeOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No workflow state found', {
suggestion: 'Start a new workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
formatter.info('Loading workflow state...');
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Validate state can be resumed
const orchestrator = new WorkflowOrchestrator(state.context);
if (!orchestrator.canResumeFromState(state)) {
formatter.error('Invalid workflow state', {
suggestion:
'State file may be corrupted. Consider starting a new workflow.'
});
process.exit(1);
}
// Restore state
orchestrator.restoreState(state);
// Re-enable auto-persistence
const { saveWorkflowState } = await import('./shared.js');
orchestrator.enableAutoPersist(async (newState) => {
await saveWorkflowState(mergedOptions.projectRoot!, newState);
});
// Get progress
const progress = orchestrator.getProgress();
const currentSubtask = orchestrator.getCurrentSubtask();
// Output success
formatter.success('Workflow resumed', {
taskId: state.context.taskId,
phase: orchestrator.getCurrentPhase(),
tddPhase: orchestrator.getCurrentTDDPhase(),
branchName: state.context.branchName,
progress: {
completed: progress.completed,
total: progress.total,
percentage: progress.percentage
},
currentSubtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null
});
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,262 @@
/**
* @fileoverview Shared utilities for autopilot commands
*/
import {
WorkflowOrchestrator,
WorkflowStateManager,
GitAdapter,
CommitMessageGenerator
} from '@tm/core';
import type { WorkflowState, WorkflowContext, SubtaskInfo } from '@tm/core';
import chalk from 'chalk';
/**
* Base options interface for all autopilot commands
*/
export interface AutopilotBaseOptions {
projectRoot?: string;
json?: boolean;
verbose?: boolean;
}
/**
* Load workflow state from disk using WorkflowStateManager
*/
export async function loadWorkflowState(
projectRoot: string
): Promise<WorkflowState | null> {
const stateManager = new WorkflowStateManager(projectRoot);
if (!(await stateManager.exists())) {
return null;
}
try {
return await stateManager.load();
} catch (error) {
throw new Error(
`Failed to load workflow state: ${(error as Error).message}`
);
}
}
/**
* Save workflow state to disk using WorkflowStateManager
*/
export async function saveWorkflowState(
projectRoot: string,
state: WorkflowState
): Promise<void> {
const stateManager = new WorkflowStateManager(projectRoot);
try {
await stateManager.save(state);
} catch (error) {
throw new Error(
`Failed to save workflow state: ${(error as Error).message}`
);
}
}
/**
* Delete workflow state from disk using WorkflowStateManager
*/
export async function deleteWorkflowState(projectRoot: string): Promise<void> {
const stateManager = new WorkflowStateManager(projectRoot);
await stateManager.delete();
}
/**
* Check if workflow state exists using WorkflowStateManager
*/
export async function hasWorkflowState(projectRoot: string): Promise<boolean> {
const stateManager = new WorkflowStateManager(projectRoot);
return await stateManager.exists();
}
/**
* Initialize WorkflowOrchestrator with persistence
*/
export function createOrchestrator(
context: WorkflowContext,
projectRoot: string
): WorkflowOrchestrator {
const orchestrator = new WorkflowOrchestrator(context);
const stateManager = new WorkflowStateManager(projectRoot);
// Enable auto-persistence
orchestrator.enableAutoPersist(async (state: WorkflowState) => {
await stateManager.save(state);
});
return orchestrator;
}
/**
* Initialize GitAdapter for project
*/
export function createGitAdapter(projectRoot: string): GitAdapter {
return new GitAdapter(projectRoot);
}
/**
* Initialize CommitMessageGenerator
*/
export function createCommitMessageGenerator(): CommitMessageGenerator {
return new CommitMessageGenerator();
}
/**
* Output formatter for JSON and text modes
*/
export class OutputFormatter {
constructor(private useJson: boolean) {}
/**
* Output data in appropriate format
*/
output(data: Record<string, unknown>): void {
if (this.useJson) {
console.log(JSON.stringify(data, null, 2));
} else {
this.outputText(data);
}
}
/**
* Output data in human-readable text format
*/
private outputText(data: Record<string, unknown>): void {
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'object' && value !== null) {
console.log(chalk.cyan(`${key}:`));
this.outputObject(value as Record<string, unknown>, ' ');
} else {
console.log(chalk.white(`${key}: ${value}`));
}
}
}
/**
* Output nested object with indentation
*/
private outputObject(obj: Record<string, unknown>, indent: string): void {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null) {
console.log(chalk.cyan(`${indent}${key}:`));
this.outputObject(value as Record<string, unknown>, indent + ' ');
} else {
console.log(chalk.gray(`${indent}${key}: ${value}`));
}
}
}
/**
* Output error message
*/
error(message: string, details?: Record<string, unknown>): void {
if (this.useJson) {
console.error(
JSON.stringify(
{
error: message,
...details
},
null,
2
)
);
} else {
console.error(chalk.red(`Error: ${message}`));
if (details) {
for (const [key, value] of Object.entries(details)) {
console.error(chalk.gray(` ${key}: ${value}`));
}
}
}
}
/**
* Output success message
*/
success(message: string, data?: Record<string, unknown>): void {
if (this.useJson) {
console.log(
JSON.stringify(
{
success: true,
message,
...data
},
null,
2
)
);
} else {
console.log(chalk.green(`${message}`));
if (data) {
this.output(data);
}
}
}
/**
* Output warning message
*/
warning(message: string): void {
if (this.useJson) {
console.warn(
JSON.stringify(
{
warning: message
},
null,
2
)
);
} else {
console.warn(chalk.yellow(`${message}`));
}
}
/**
* Output info message
*/
info(message: string): void {
if (this.useJson) {
// Don't output info messages in JSON mode
return;
}
console.log(chalk.blue(` ${message}`));
}
}
/**
* Validate task ID format
*/
export function validateTaskId(taskId: string): boolean {
// Task ID should be in format: number or number.number (e.g., "1" or "1.2")
const pattern = /^\d+(\.\d+)*$/;
return pattern.test(taskId);
}
/**
* Parse subtasks from task data
*/
export function parseSubtasks(
task: any,
maxAttempts: number = 3
): SubtaskInfo[] {
if (!task.subtasks || !Array.isArray(task.subtasks)) {
return [];
}
return task.subtasks.map((subtask: any) => ({
id: subtask.id,
title: subtask.title,
status: subtask.status === 'done' ? 'completed' : 'pending',
attempts: 0,
maxAttempts
}));
}

View File

@@ -0,0 +1,168 @@
/**
* @fileoverview Start Command - Initialize and start TDD workflow
*/
import { Command } from 'commander';
import { createTaskMasterCore, type WorkflowContext } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
createOrchestrator,
createGitAdapter,
OutputFormatter,
validateTaskId,
parseSubtasks
} from './shared.js';
interface StartOptions extends AutopilotBaseOptions {
force?: boolean;
maxAttempts?: string;
}
/**
* Start Command - Initialize new TDD workflow
*/
export class StartCommand extends Command {
constructor() {
super('start');
this.description('Initialize and start a new TDD workflow for a task')
.argument('<taskId>', 'Task ID to start workflow for')
.option('-f, --force', 'Force start even if workflow state exists')
.option('--max-attempts <number>', 'Maximum attempts per subtask', '3')
.action(async (taskId: string, options: StartOptions) => {
await this.execute(taskId, options);
});
}
private async execute(taskId: string, options: StartOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: StartOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Validate task ID
if (!validateTaskId(taskId)) {
formatter.error('Invalid task ID format', {
taskId,
expected: 'Format: number or number.number (e.g., "1" or "1.2")'
});
process.exit(1);
}
// Check for existing workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (hasState && !mergedOptions.force) {
formatter.error(
'Workflow state already exists. Use --force to overwrite or resume with "autopilot resume"'
);
process.exit(1);
}
// Initialize Task Master Core
const tmCore = await createTaskMasterCore({
projectPath: mergedOptions.projectRoot!
});
// Get current tag from ConfigManager
const currentTag = tmCore.getActiveTag();
// Load task
formatter.info(`Loading task ${taskId}...`);
const { task } = await tmCore.getTaskWithSubtask(taskId);
if (!task) {
formatter.error('Task not found', { taskId });
await tmCore.close();
process.exit(1);
}
// Validate task has subtasks
if (!task.subtasks || task.subtasks.length === 0) {
formatter.error('Task has no subtasks. Expand task first.', {
taskId,
suggestion: `Run: task-master expand --id=${taskId}`
});
await tmCore.close();
process.exit(1);
}
// Initialize Git adapter
const gitAdapter = createGitAdapter(mergedOptions.projectRoot!);
await gitAdapter.ensureGitRepository();
await gitAdapter.ensureCleanWorkingTree();
// Parse subtasks
const maxAttempts = parseInt(mergedOptions.maxAttempts || '3', 10);
const subtasks = parseSubtasks(task, maxAttempts);
// Create workflow context
const context: WorkflowContext = {
taskId: task.id,
subtasks,
currentSubtaskIndex: 0,
errors: [],
metadata: {
startedAt: new Date().toISOString(),
tags: task.tags || []
}
};
// Create orchestrator with persistence
const orchestrator = createOrchestrator(
context,
mergedOptions.projectRoot!
);
// Complete PREFLIGHT phase
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
// Generate descriptive branch name
const sanitizedTitle = task.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 50);
const formattedTaskId = taskId.replace(/\./g, '-');
const tagPrefix = currentTag ? `${currentTag}/` : '';
const branchName = `${tagPrefix}task-${formattedTaskId}-${sanitizedTitle}`;
// Create and checkout branch
formatter.info(`Creating branch: ${branchName}`);
await gitAdapter.createAndCheckoutBranch(branchName);
// Transition to SUBTASK_LOOP
orchestrator.transition({
type: 'BRANCH_CREATED',
branchName
});
// Output success
formatter.success('TDD workflow started', {
taskId: task.id,
title: task.title,
phase: orchestrator.getCurrentPhase(),
tddPhase: orchestrator.getCurrentTDDPhase(),
branchName,
subtasks: subtasks.length,
currentSubtask: subtasks[0]?.title
});
// Clean up
await tmCore.close();
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -0,0 +1,114 @@
/**
* @fileoverview Status Command - Show workflow progress
*/
import { Command } from 'commander';
import { WorkflowOrchestrator } from '@tm/core';
import {
AutopilotBaseOptions,
hasWorkflowState,
loadWorkflowState,
OutputFormatter
} from './shared.js';
type StatusOptions = AutopilotBaseOptions;
/**
* Status Command - Show current workflow status
*/
export class StatusCommand extends Command {
constructor() {
super('status');
this.description('Show current TDD workflow status and progress').action(
async (options: StatusOptions) => {
await this.execute(options);
}
);
}
private async execute(options: StatusOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: StatusOptions = {
...parentOpts,
...options,
projectRoot:
options.projectRoot || parentOpts?.projectRoot || process.cwd()
};
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
const state = await loadWorkflowState(mergedOptions.projectRoot!);
if (!state) {
formatter.error('Failed to load workflow state');
process.exit(1);
}
// Restore orchestrator
const orchestrator = new WorkflowOrchestrator(state.context);
orchestrator.restoreState(state);
// Get status information
const phase = orchestrator.getCurrentPhase();
const tddPhase = orchestrator.getCurrentTDDPhase();
const progress = orchestrator.getProgress();
const currentSubtask = orchestrator.getCurrentSubtask();
const errors = state.context.errors ?? [];
// Build status output
const status = {
taskId: state.context.taskId,
phase,
tddPhase,
branchName: state.context.branchName,
progress: {
completed: progress.completed,
total: progress.total,
current: progress.current,
percentage: progress.percentage
},
currentSubtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
status: currentSubtask.status,
attempts: currentSubtask.attempts,
maxAttempts: currentSubtask.maxAttempts
}
: null,
subtasks: state.context.subtasks.map((st) => ({
id: st.id,
title: st.title,
status: st.status,
attempts: st.attempts
})),
errors: errors.length > 0 ? errors : undefined,
metadata: state.context.metadata
};
if (mergedOptions.json) {
formatter.output(status);
} else {
formatter.success('Workflow status', status);
}
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -12,6 +12,7 @@ export { ContextCommand } from './commands/context.command.js';
export { StartCommand } from './commands/start.command.js';
export { SetStatusCommand } from './commands/set-status.command.js';
export { ExportCommand } from './commands/export.command.js';
export { AutopilotCommand } from './commands/autopilot.command.js';
// Command Registry
export {

View File

@@ -0,0 +1,540 @@
/**
* @fileoverview Integration tests for autopilot workflow commands
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { WorkflowState } from '@tm/core';
// Track file system state in memory - must be in vi.hoisted() for mock access
const {
mockFileSystem,
pathExistsFn,
readJSONFn,
writeJSONFn,
ensureDirFn,
removeFn
} = vi.hoisted(() => {
const mockFileSystem = new Map<string, string>();
return {
mockFileSystem,
pathExistsFn: vi.fn((path: string) =>
Promise.resolve(mockFileSystem.has(path))
),
readJSONFn: vi.fn((path: string) => {
const data = mockFileSystem.get(path);
return data
? Promise.resolve(JSON.parse(data))
: Promise.reject(new Error('File not found'));
}),
writeJSONFn: vi.fn((path: string, data: any) => {
mockFileSystem.set(path, JSON.stringify(data));
return Promise.resolve();
}),
ensureDirFn: vi.fn(() => Promise.resolve()),
removeFn: vi.fn((path: string) => {
mockFileSystem.delete(path);
return Promise.resolve();
})
};
});
// Mock fs-extra before any imports
vi.mock('fs-extra', () => ({
default: {
pathExists: pathExistsFn,
readJSON: readJSONFn,
writeJSON: writeJSONFn,
ensureDir: ensureDirFn,
remove: removeFn
}
}));
vi.mock('@tm/core', () => ({
WorkflowOrchestrator: vi.fn().mockImplementation((context) => ({
getCurrentPhase: vi.fn().mockReturnValue('SUBTASK_LOOP'),
getCurrentTDDPhase: vi.fn().mockReturnValue('RED'),
getContext: vi.fn().mockReturnValue(context),
transition: vi.fn(),
restoreState: vi.fn(),
getState: vi.fn().mockReturnValue({ phase: 'SUBTASK_LOOP', context }),
enableAutoPersist: vi.fn(),
canResumeFromState: vi.fn().mockReturnValue(true),
getCurrentSubtask: vi.fn().mockReturnValue({
id: '1',
title: 'Test Subtask',
status: 'pending',
attempts: 0
}),
getProgress: vi.fn().mockReturnValue({
completed: 0,
total: 3,
current: 1,
percentage: 0
}),
canProceed: vi.fn().mockReturnValue(false)
})),
GitAdapter: vi.fn().mockImplementation(() => ({
ensureGitRepository: vi.fn().mockResolvedValue(undefined),
ensureCleanWorkingTree: vi.fn().mockResolvedValue(undefined),
createAndCheckoutBranch: vi.fn().mockResolvedValue(undefined),
hasStagedChanges: vi.fn().mockResolvedValue(true),
getStatus: vi.fn().mockResolvedValue({
staged: ['file1.ts'],
modified: ['file2.ts']
}),
createCommit: vi.fn().mockResolvedValue(undefined),
getLastCommit: vi.fn().mockResolvedValue({
hash: 'abc123def456',
message: 'test commit'
}),
stageFiles: vi.fn().mockResolvedValue(undefined)
})),
CommitMessageGenerator: vi.fn().mockImplementation(() => ({
generateMessage: vi.fn().mockReturnValue('feat: test commit message')
})),
createTaskMasterCore: vi.fn().mockResolvedValue({
getTaskWithSubtask: vi.fn().mockResolvedValue({
task: {
id: '1',
title: 'Test Task',
subtasks: [
{ id: '1', title: 'Subtask 1', status: 'pending' },
{ id: '2', title: 'Subtask 2', status: 'pending' },
{ id: '3', title: 'Subtask 3', status: 'pending' }
],
tag: 'test'
}
}),
close: vi.fn().mockResolvedValue(undefined)
})
}));
// Import after mocks are set up
import { Command } from 'commander';
import { AutopilotCommand } from '../../../../src/commands/autopilot/index.js';
describe('Autopilot Workflow Integration Tests', () => {
const projectRoot = '/test/project';
let program: Command;
beforeEach(() => {
mockFileSystem.clear();
// Clear mock call history
pathExistsFn.mockClear();
readJSONFn.mockClear();
writeJSONFn.mockClear();
ensureDirFn.mockClear();
removeFn.mockClear();
program = new Command();
AutopilotCommand.register(program);
// Use exitOverride to handle Commander exits in tests
program.exitOverride();
});
afterEach(() => {
mockFileSystem.clear();
vi.restoreAllMocks();
});
describe('start command', () => {
it('should initialize workflow and create branch', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'start',
'1',
'--project-root',
projectRoot,
'--json'
]);
// Verify writeJSON was called with state
expect(writeJSONFn).toHaveBeenCalledWith(
expect.stringContaining('workflow-state.json'),
expect.objectContaining({
phase: expect.any(String),
context: expect.any(Object)
}),
expect.any(Object)
);
consoleLogSpy.mockRestore();
});
it('should reject invalid task ID', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
program.parseAsync([
'node',
'test',
'autopilot',
'start',
'invalid',
'--project-root',
projectRoot,
'--json'
])
).rejects.toMatchObject({ exitCode: 1 });
consoleErrorSpy.mockRestore();
});
it('should reject starting when workflow exists without force', async () => {
// Create existing state
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [],
currentSubtaskIndex: 0,
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
program.parseAsync([
'node',
'test',
'autopilot',
'start',
'1',
'--project-root',
projectRoot,
'--json'
])
).rejects.toMatchObject({ exitCode: 1 });
consoleErrorSpy.mockRestore();
});
});
describe('resume command', () => {
beforeEach(() => {
// Create saved state
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{
id: '1',
title: 'Test Subtask',
status: 'pending',
attempts: 0
}
],
currentSubtaskIndex: 0,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should restore workflow from saved state', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'resume',
'--project-root',
projectRoot,
'--json'
]);
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.success).toBe(true);
expect(output.taskId).toBe('1');
consoleLogSpy.mockRestore();
});
it('should error when no state exists', async () => {
mockFileSystem.clear();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
program.parseAsync([
'node',
'test',
'autopilot',
'resume',
'--project-root',
projectRoot,
'--json'
])
).rejects.toMatchObject({ exitCode: 1 });
consoleErrorSpy.mockRestore();
});
});
describe('next command', () => {
beforeEach(() => {
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{
id: '1',
title: 'Test Subtask',
status: 'pending',
attempts: 0
}
],
currentSubtaskIndex: 0,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should return next action in JSON format', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'next',
'--project-root',
projectRoot,
'--json'
]);
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.action).toBe('generate_test');
expect(output.phase).toBe('SUBTASK_LOOP');
expect(output.tddPhase).toBe('RED');
consoleLogSpy.mockRestore();
});
});
describe('status command', () => {
beforeEach(() => {
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{ id: '1', title: 'Subtask 1', status: 'completed', attempts: 1 },
{ id: '2', title: 'Subtask 2', status: 'pending', attempts: 0 },
{ id: '3', title: 'Subtask 3', status: 'pending', attempts: 0 }
],
currentSubtaskIndex: 1,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should display workflow progress', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'status',
'--project-root',
projectRoot,
'--json'
]);
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.taskId).toBe('1');
expect(output.phase).toBe('SUBTASK_LOOP');
expect(output.progress).toBeDefined();
expect(output.subtasks).toHaveLength(3);
consoleLogSpy.mockRestore();
});
});
describe('complete command', () => {
beforeEach(() => {
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{
id: '1',
title: 'Test Subtask',
status: 'in-progress',
attempts: 0
}
],
currentSubtaskIndex: 0,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should validate RED phase has failures', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
program.parseAsync([
'node',
'test',
'autopilot',
'complete',
'--project-root',
projectRoot,
'--results',
'{"total":10,"passed":10,"failed":0,"skipped":0}',
'--json'
])
).rejects.toMatchObject({ exitCode: 1 });
consoleErrorSpy.mockRestore();
});
it('should complete RED phase with failures', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'complete',
'--project-root',
projectRoot,
'--results',
'{"total":10,"passed":9,"failed":1,"skipped":0}',
'--json'
]);
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.success).toBe(true);
expect(output.nextPhase).toBe('GREEN');
consoleLogSpy.mockRestore();
});
});
describe('abort command', () => {
beforeEach(() => {
const mockState: WorkflowState = {
phase: 'SUBTASK_LOOP',
context: {
taskId: '1',
subtasks: [
{
id: '1',
title: 'Test Subtask',
status: 'pending',
attempts: 0
}
],
currentSubtaskIndex: 0,
currentTDDPhase: 'RED',
branchName: 'task-1',
errors: [],
metadata: {}
}
};
mockFileSystem.set(
`${projectRoot}/.taskmaster/workflow-state.json`,
JSON.stringify(mockState)
);
});
it('should abort workflow and delete state', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
await program.parseAsync([
'node',
'test',
'autopilot',
'abort',
'--project-root',
projectRoot,
'--force',
'--json'
]);
// Verify remove was called
expect(removeFn).toHaveBeenCalledWith(
expect.stringContaining('workflow-state.json')
);
consoleLogSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,202 @@
/**
* @fileoverview Unit tests for autopilot shared utilities
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
validateTaskId,
parseSubtasks,
OutputFormatter
} from '../../../../src/commands/autopilot/shared.js';
// Mock fs-extra
vi.mock('fs-extra', () => ({
default: {
pathExists: vi.fn(),
readJSON: vi.fn(),
writeJSON: vi.fn(),
ensureDir: vi.fn(),
remove: vi.fn()
},
pathExists: vi.fn(),
readJSON: vi.fn(),
writeJSON: vi.fn(),
ensureDir: vi.fn(),
remove: vi.fn()
}));
describe('Autopilot Shared Utilities', () => {
const projectRoot = '/test/project';
const statePath = `${projectRoot}/.taskmaster/workflow-state.json`;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('validateTaskId', () => {
it('should validate simple task IDs', () => {
expect(validateTaskId('1')).toBe(true);
expect(validateTaskId('10')).toBe(true);
expect(validateTaskId('999')).toBe(true);
});
it('should validate subtask IDs', () => {
expect(validateTaskId('1.1')).toBe(true);
expect(validateTaskId('1.2')).toBe(true);
expect(validateTaskId('10.5')).toBe(true);
});
it('should validate nested subtask IDs', () => {
expect(validateTaskId('1.1.1')).toBe(true);
expect(validateTaskId('1.2.3')).toBe(true);
});
it('should reject invalid formats', () => {
expect(validateTaskId('')).toBe(false);
expect(validateTaskId('abc')).toBe(false);
expect(validateTaskId('1.')).toBe(false);
expect(validateTaskId('.1')).toBe(false);
expect(validateTaskId('1..2')).toBe(false);
expect(validateTaskId('1.2.3.')).toBe(false);
});
});
describe('parseSubtasks', () => {
it('should parse subtasks from task data', () => {
const task = {
id: '1',
title: 'Test Task',
subtasks: [
{ id: '1', title: 'Subtask 1', status: 'pending' },
{ id: '2', title: 'Subtask 2', status: 'done' },
{ id: '3', title: 'Subtask 3', status: 'in-progress' }
]
};
const result = parseSubtasks(task, 5);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({
id: '1',
title: 'Subtask 1',
status: 'pending',
attempts: 0,
maxAttempts: 5
});
expect(result[1]).toEqual({
id: '2',
title: 'Subtask 2',
status: 'completed',
attempts: 0,
maxAttempts: 5
});
});
it('should return empty array for missing subtasks', () => {
const task = { id: '1', title: 'Test Task' };
expect(parseSubtasks(task)).toEqual([]);
});
it('should use default maxAttempts', () => {
const task = {
subtasks: [{ id: '1', title: 'Subtask 1', status: 'pending' }]
};
const result = parseSubtasks(task);
expect(result[0].maxAttempts).toBe(3);
});
});
// State persistence tests omitted - covered in integration tests
describe('OutputFormatter', () => {
let consoleLogSpy: any;
let consoleErrorSpy: any;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe('JSON mode', () => {
it('should output JSON for success', () => {
const formatter = new OutputFormatter(true);
formatter.success('Test message', { key: 'value' });
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.success).toBe(true);
expect(output.message).toBe('Test message');
expect(output.key).toBe('value');
});
it('should output JSON for error', () => {
const formatter = new OutputFormatter(true);
formatter.error('Error message', { code: 'ERR001' });
expect(consoleErrorSpy).toHaveBeenCalled();
const output = JSON.parse(consoleErrorSpy.mock.calls[0][0]);
expect(output.error).toBe('Error message');
expect(output.code).toBe('ERR001');
});
it('should output JSON for data', () => {
const formatter = new OutputFormatter(true);
formatter.output({ test: 'data' });
expect(consoleLogSpy).toHaveBeenCalled();
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
expect(output.test).toBe('data');
});
});
describe('Text mode', () => {
it('should output formatted text for success', () => {
const formatter = new OutputFormatter(false);
formatter.success('Test message');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('✓ Test message')
);
});
it('should output formatted text for error', () => {
const formatter = new OutputFormatter(false);
formatter.error('Error message');
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Error: Error message')
);
});
it('should output formatted text for warning', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {});
const formatter = new OutputFormatter(false);
formatter.warning('Warning message');
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('⚠ Warning message')
);
consoleWarnSpy.mockRestore();
});
it('should not output info in JSON mode', () => {
const formatter = new OutputFormatter(true);
formatter.info('Info message');
expect(consoleLogSpy).not.toHaveBeenCalled();
});
});
});
});

25
apps/cli/vitest.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: [
'node_modules/',
'dist/',
'tests/',
'**/*.test.ts',
'**/*.spec.ts',
'**/*.d.ts',
'**/mocks/**',
'**/fixtures/**',
'vitest.config.ts'
]
}
}
});

View File

@@ -0,0 +1,235 @@
---
title: "Task Master Commands"
description: "A comprehensive reference of all available Task Master commands"
---
<AccordionGroup>
<Accordion title="Parse PRD">
```bash
# Parse a PRD file and generate tasks
task-master parse-prd <prd-file.txt>
# Limit the number of tasks generated
task-master parse-prd <prd-file.txt> --num-tasks=10
```
</Accordion>
<Accordion title="List Tasks">
```bash
# List all tasks
task-master list
# List tasks with a specific status
task-master list --status=<status>
# List tasks with subtasks
task-master list --with-subtasks
# List tasks with a specific status and include subtasks
task-master list --status=<status> --with-subtasks
```
</Accordion>
<Accordion title="Show Next Task">
```bash
# Show the next task to work on based on dependencies and status
task-master next
```
</Accordion>
<Accordion title="Show Specific Task">
```bash
# Show details of a specific task
task-master show <id>
# or
task-master show --id=<id>
# View a specific subtask (e.g., subtask 2 of task 1)
task-master show 1.2
```
</Accordion>
<Accordion title="Update Tasks">
```bash
# Update tasks from a specific ID and provide context
task-master update --from=<id> --prompt="<prompt>"
```
</Accordion>
<Accordion title="Update a Specific Task">
```bash
# Update a single task by ID with new information
task-master update-task --id=<id> --prompt="<prompt>"
# Use research-backed updates with Perplexity AI
task-master update-task --id=<id> --prompt="<prompt>" --research
```
</Accordion>
<Accordion title="Update a Subtask">
```bash
# Append additional information to a specific subtask
task-master update-subtask --id=<parentId.subtaskId> --prompt="<prompt>"
# Example: Add details about API rate limiting to subtask 2 of task 5
task-master update-subtask --id=5.2 --prompt="Add rate limiting of 100 requests per minute"
# Use research-backed updates with Perplexity AI
task-master update-subtask --id=<parentId.subtaskId> --prompt="<prompt>" --research
```
Unlike the `update-task` command which replaces task information, the `update-subtask` command _appends_ new information to the existing subtask details, marking it with a timestamp. This is useful for iteratively enhancing subtasks while preserving the original content.
</Accordion>
<Accordion title="Generate Task Files">
```bash
# Generate individual task files from tasks.json
task-master generate
```
</Accordion>
<Accordion title="Set Task Status">
```bash
# Set status of a single task
task-master set-status --id=<id> --status=<status>
# Set status for multiple tasks
task-master set-status --id=1,2,3 --status=<status>
# Set status for subtasks
task-master set-status --id=1.1,1.2 --status=<status>
```
When marking a task as "done", all of its subtasks will automatically be marked as "done" as well.
</Accordion>
<Accordion title="Expand Tasks">
```bash
# Expand a specific task with subtasks
task-master expand --id=<id> --num=<number>
# Expand with additional context
task-master expand --id=<id> --prompt="<context>"
# Expand all pending tasks
task-master expand --all
# Force regeneration of subtasks for tasks that already have them
task-master expand --all --force
# Research-backed subtask generation for a specific task
task-master expand --id=<id> --research
# Research-backed generation for all tasks
task-master expand --all --research
```
</Accordion>
<Accordion title="Clear Subtasks">
```bash
# Clear subtasks from a specific task
task-master clear-subtasks --id=<id>
# Clear subtasks from multiple tasks
task-master clear-subtasks --id=1,2,3
# Clear subtasks from all tasks
task-master clear-subtasks --all
```
</Accordion>
<Accordion title="Analyze Task Complexity">
```bash
# Analyze complexity of all tasks
task-master analyze-complexity
# Save report to a custom location
task-master analyze-complexity --output=my-report.json
# Use a specific LLM model
task-master analyze-complexity --model=claude-3-opus-20240229
# Set a custom complexity threshold (1-10)
task-master analyze-complexity --threshold=6
# Use an alternative tasks file
task-master analyze-complexity --file=custom-tasks.json
# Use Perplexity AI for research-backed complexity analysis
task-master analyze-complexity --research
```
</Accordion>
<Accordion title="View Complexity Report">
```bash
# Display the task complexity analysis report
task-master complexity-report
# View a report at a custom location
task-master complexity-report --file=my-report.json
```
</Accordion>
<Accordion title="Managing Task Dependencies">
```bash
# Add a dependency to a task
task-master add-dependency --id=<id> --depends-on=<id>
# Remove a dependency from a task
task-master remove-dependency --id=<id> --depends-on=<id>
# Validate dependencies without fixing them
task-master validate-dependencies
# Find and fix invalid dependencies automatically
task-master fix-dependencies
```
</Accordion>
<Accordion title="Add a New Task">
```bash
# Add a new task using AI
task-master add-task --prompt="Description of the new task"
# Add a task with dependencies
task-master add-task --prompt="Description" --dependencies=1,2,3
# Add a task with priority
task-master add-task --prompt="Description" --priority=high
```
</Accordion>
<Accordion title="Initialize a Project">
```bash
# Initialize a new project with Task Master structure
task-master init
```
</Accordion>
<Accordion title="TDD Workflow (Autopilot)">
```bash
# Start autonomous TDD workflow for a task
task-master autopilot start <taskId>
# Get next action with context
task-master autopilot next
# Complete phase with test results
task-master autopilot complete --results '{"total":N,"passed":N,"failed":N}'
# Commit changes
task-master autopilot commit
# Check workflow status
task-master autopilot status
# Resume interrupted workflow
task-master autopilot resume
# Abort workflow
task-master autopilot abort
```
The TDD workflow enforces RED → GREEN → COMMIT cycles for each subtask. See [AI Agent Integration](/tdd-workflow/ai-agent-integration) for details.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,94 @@
---
title: "Configuration"
description: "Configure Task Master through environment variables in a .env file"
---
## Required Configuration
<Note>
Task Master requires an Anthropic API key to function. Add this to your `.env` file:
```bash
ANTHROPIC_API_KEY=sk-ant-api03-your-api-key
```
You can obtain an API key from the [Anthropic Console](https://console.anthropic.com/).
</Note>
## Optional Configuration
| Variable | Default Value | Description | Example |
| --- | --- | --- | --- |
| `MODEL` | `"claude-3-7-sonnet-20250219"` | Claude model to use | `MODEL=claude-3-opus-20240229` |
| `MAX_TOKENS` | `"4000"` | Maximum tokens for responses | `MAX_TOKENS=8000` |
| `TEMPERATURE` | `"0.7"` | Temperature for model responses | `TEMPERATURE=0.5` |
| `DEBUG` | `"false"` | Enable debug logging | `DEBUG=true` |
| `LOG_LEVEL` | `"info"` | Console output level | `LOG_LEVEL=debug` |
| `DEFAULT_SUBTASKS` | `"3"` | Default subtask count | `DEFAULT_SUBTASKS=5` |
| `DEFAULT_PRIORITY` | `"medium"` | Default priority | `DEFAULT_PRIORITY=high` |
| `PROJECT_NAME` | `"MCP SaaS MVP"` | Project name in metadata | `PROJECT_NAME=My Awesome Project` |
| `PROJECT_VERSION` | `"1.0.0"` | Version in metadata | `PROJECT_VERSION=2.1.0` |
| `PERPLEXITY_API_KEY` | - | For research-backed features | `PERPLEXITY_API_KEY=pplx-...` |
| `PERPLEXITY_MODEL` | `"sonar-medium-online"` | Perplexity model | `PERPLEXITY_MODEL=sonar-large-online` |
## TDD Workflow Configuration
Additional options for autonomous TDD workflow:
| Variable | Default | Description |
| --- | --- | --- |
| `TM_MAX_ATTEMPTS` | `3` | Max attempts per subtask before marking blocked |
| `TM_AUTO_COMMIT` | `true` | Auto-commit after GREEN phase |
| `TM_PROJECT_ROOT` | Current dir | Default project root |
## Example .env File
```
# Required
ANTHROPIC_API_KEY=sk-ant-api03-your-api-key
# Optional - Claude Configuration
MODEL=claude-3-7-sonnet-20250219
MAX_TOKENS=4000
TEMPERATURE=0.7
# Optional - Perplexity API for Research
PERPLEXITY_API_KEY=pplx-your-api-key
PERPLEXITY_MODEL=sonar-medium-online
# Optional - Project Info
PROJECT_NAME=My Project
PROJECT_VERSION=1.0.0
# Optional - Application Configuration
DEFAULT_SUBTASKS=3
DEFAULT_PRIORITY=medium
DEBUG=false
LOG_LEVEL=info
# TDD Workflow
TM_MAX_ATTEMPTS=3
TM_AUTO_COMMIT=true
```
## Troubleshooting
### If `task-master init` doesn't respond:
Try running it with Node directly:
```bash
node node_modules/claude-task-master/scripts/init.js
```
Or clone the repository and run:
```bash
git clone https://github.com/eyaltoledano/claude-task-master.git
cd claude-task-master
node scripts/init.js
```
<Note>
For advanced configuration options and detailed customization, see our [Advanced Configuration Guide] page.
</Note>

View File

@@ -52,6 +52,13 @@
"capabilities/cli-root-commands",
"capabilities/task-structure"
]
},
{
"group": "TDD Workflow (Autopilot)",
"pages": [
"tdd-workflow/quickstart",
"tdd-workflow/ai-agent-integration"
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
---
title: "TDD Workflow Quick Start"
description: "Get started with TaskMaster's autonomous TDD workflow in 5 minutes"
---
Get started with TaskMaster's autonomous TDD workflow in 5 minutes.
## Prerequisites
- TaskMaster initialized project (`tm init`)
- Tasks with subtasks created (`tm parse-prd` or `tm expand`)
- Git repository with clean working tree
- Test framework installed (vitest, jest, mocha, etc.)
## 1. Start a Workflow
```bash
tm autopilot start <taskId>
```
Example:
```bash
$ tm autopilot start 7
✓ Workflow started for task 7
✓ Created branch: task-7
✓ Current phase: RED
✓ Subtask 1/5: Implement start command
→ Next action: Write a failing test
```
## 2. The TDD Cycle
### RED Phase: Write Failing Test
```bash
# Check what to do next
$ tm autopilot next --json
{
"action": "generate_test",
"currentSubtask": {
"id": "1",
"title": "Implement start command"
}
}
```
Write a test that fails:
```typescript
// tests/start.test.ts
import { describe, it, expect } from 'vitest';
import { StartCommand } from '../src/commands/start';
describe('StartCommand', () => {
it('should initialize workflow', async () => {
const command = new StartCommand();
const result = await command.execute({ taskId: '7' });
expect(result.success).toBe(true);
});
});
```
Run tests:
```bash
$ npm test
# ✗ 1 test failed
```
Complete RED phase:
```bash
$ tm autopilot complete --results '{"total":1,"passed":0,"failed":1,"skipped":0}'
✓ RED phase complete
✓ Current phase: GREEN
→ Next action: Implement code to pass tests
```
### GREEN Phase: Implement Feature
Write minimal code to pass:
```typescript
// src/commands/start.ts
export class StartCommand {
async execute(options: { taskId: string }) {
return { success: true };
}
}
```
Run tests:
```bash
$ npm test
# ✓ 1 test passed
```
Complete GREEN phase:
```bash
$ tm autopilot complete --results '{"total":1,"passed":1,"failed":0,"skipped":0}'
✓ GREEN phase complete
✓ Current phase: COMMIT
→ Next action: Commit changes
```
### COMMIT Phase: Save Progress
```bash
$ tm autopilot commit
✓ Created commit: abc123
✓ Message: feat(autopilot): implement start command (Task 7.1)
✓ Advanced to subtask 2/5
✓ Current phase: RED
→ Next action: Write a failing test
```
## 3. Continue for All Subtasks
Repeat the RED-GREEN-COMMIT cycle for each subtask until complete.
```bash
# Check progress anytime
$ tm autopilot status --json
{
"taskId": "7",
"progress": {
"completed": 1,
"total": 5,
"percentage": 20
},
"currentSubtask": {
"id": "2",
"title": "Implement resume command"
}
}
```
## 4. Complete the Workflow
When all subtasks are done:
```bash
$ tm autopilot status --json
{
"phase": "COMPLETE",
"progress": {
"completed": 5,
"total": 5,
"percentage": 100
}
}
```
Your branch `task-7` is ready for review/merge!
## Common Patterns
### Parse Test Output
Your test runner outputs human-readable format - convert to JSON:
**Vitest:**
```
Tests 2 failed | 8 passed | 10 total
```
→ `{"total":10,"passed":8,"failed":2,"skipped":0}`
**Jest:**
```
Tests: 2 failed, 8 passed, 10 total
```
→ `{"total":10,"passed":8,"failed":2,"skipped":0}`
### Handle Errors
**Problem:** RED phase won't complete - "no test failures"
**Solution:** Your test isn't testing new behavior. Make sure it fails:
```typescript
// Bad - test passes immediately
it('should exist', () => {
expect(StartCommand).toBeDefined(); // Always passes
});
// Good - test fails until feature exists
it('should initialize workflow', async () => {
const result = await new StartCommand().execute({ taskId: '7' });
expect(result.success).toBe(true); // Fails until execute() is implemented
});
```
**Problem:** GREEN phase won't complete - "tests still failing"
**Solution:** Fix your implementation until all tests pass:
```bash
# Run tests to see what's failing
$ npm test
# Fix the issue
$ vim src/commands/start.ts
# Verify tests pass
$ npm test
# Try again
$ tm autopilot complete --results '{"total":1,"passed":1,"failed":0,"skipped":0}'
```
### Resume Interrupted Work
```bash
# If you interrupted the workflow
$ tm autopilot resume
✓ Workflow resumed
✓ Task 7 - subtask 3/5
✓ Current phase: GREEN
→ Continue from where you left off
```
## JSON Output Mode
All commands support `--json` for programmatic use:
```bash
$ tm autopilot start 7 --json | jq .
{
"success": true,
"taskId": "7",
"branchName": "task-7",
"phase": "SUBTASK_LOOP",
"tddPhase": "RED",
"progress": { ... },
"currentSubtask": { ... },
"nextAction": "generate_test"
}
```
Perfect for:
- CI/CD integration
- Custom tooling
- Automated workflows
- Progress monitoring
## MCP Integration
For AI agents (Claude Code, etc.), use MCP tools:
```typescript
// Start workflow
await mcp.call('autopilot_start', {
taskId: '7',
projectRoot: '/path/to/project'
});
// Get next action
const next = await mcp.call('autopilot_next', {
projectRoot: '/path/to/project'
});
// Complete phase
await mcp.call('autopilot_complete_phase', {
projectRoot: '/path/to/project',
testResults: { total: 1, passed: 0, failed: 1, skipped: 0 }
});
// Commit
await mcp.call('autopilot_commit', {
projectRoot: '/path/to/project'
});
```
See [AI Agent Integration Guide](./ai-agent-integration.mdx) for details.
## Cheat Sheet
```bash
# Start
tm autopilot start <taskId> # Initialize workflow
# Workflow Control
tm autopilot next # What's next?
tm autopilot status # Current state
tm autopilot resume # Continue interrupted work
tm autopilot abort # Cancel and cleanup
# TDD Cycle
tm autopilot complete --results '{...}' # Advance phase
tm autopilot commit # Save progress
# Options
--json # Machine-readable output
--project-root <path> # Specify project location
--force # Override safety checks
```
## Next Steps
- Read [AI Agent Integration Guide](./ai-agent-integration.mdx) for complete documentation
- Check [Command Reference](/command-reference) for all options
## Tips
1. **Always let tests fail first** - That's the RED phase
2. **Write minimal code** - Just enough to pass
3. **Commit frequently** - After each subtask
4. **Use --json** - Better for programmatic use
5. **Check status often** - Know where you are
6. **Trust the workflow** - It enforces TDD rules
---
**Ready to start?** Run `tm autopilot start <taskId>` and begin your TDD journey!

View File

@@ -275,7 +275,7 @@
"tailwindcss": "4.1.11",
"typescript": "^5.9.2",
"@tm/core": "*",
"task-master-ai": "*"
"task-master-ai": "0.30.0-rc.0"
},
"overrides": {
"glob@<8": "^10.4.5",

54
apps/mcp/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "@tm/mcp",
"description": "Task Master MCP Tools - TypeScript MCP server tools for AI agent integration",
"type": "module",
"private": true,
"version": "",
"main": "./dist/index.js",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./tools/autopilot": "./src/tools/autopilot/index.ts"
},
"files": ["dist", "README.md"],
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "biome check src",
"format": "biome format --write src",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:unit": "vitest run -t unit",
"test:integration": "vitest run -t integration",
"test:ci": "vitest run --coverage --reporter=dot"
},
"dependencies": {
"@tm/core": "*",
"zod": "^4.1.11",
"fastmcp": "^3.19.2"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/node": "^22.10.5",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"task-master",
"mcp",
"mcp-server",
"ai-agent",
"workflow",
"tdd"
],
"author": "",
"license": "MIT",
"typesVersions": {
"*": {
"*": ["src/*"]
}
}
}

8
apps/mcp/src/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* @fileoverview Main entry point for @tm/mcp package
* Exports all MCP tool registration functions
*/
export * from './tools/autopilot/index.js';
export * from './shared/utils.js';
export * from './shared/types.js';

View File

@@ -0,0 +1,36 @@
/**
* Shared types for MCP tools
*/
export interface MCPResponse<T = any> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
suggestion?: string;
details?: any;
};
version?: {
version: string;
name: string;
};
tag?: {
currentTag: string;
availableTags: string[];
};
}
export interface MCPContext {
log: {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
debug: (message: string) => void;
};
session: any;
}
export interface WithProjectRoot {
projectRoot: string;
}

View File

@@ -0,0 +1,257 @@
/**
* Shared utilities for MCP tools
*/
import type { ContentResult } from 'fastmcp';
import path from 'node:path';
import fs from 'node:fs';
import packageJson from '../../../../package.json' with { type: 'json' };
/**
* Get version information
*/
export function getVersionInfo() {
return {
version: packageJson.version || 'unknown',
name: packageJson.name || 'task-master-ai'
};
}
/**
* Get current tag for a project root
*/
export function getCurrentTag(projectRoot: string): string | null {
try {
// Try to read current tag from state.json
const stateJsonPath = path.join(projectRoot, '.taskmaster', 'state.json');
if (fs.existsSync(stateJsonPath)) {
const stateData = JSON.parse(fs.readFileSync(stateJsonPath, 'utf-8'));
return stateData.currentTag || 'master';
}
return 'master';
} catch {
return null;
}
}
/**
* Handle API result with standardized error handling and response formatting
* This provides a consistent response structure for all MCP tools
*/
export async function handleApiResult<T>(options: {
result: { success: boolean; data?: T; error?: { message: string } };
log?: any;
errorPrefix?: string;
projectRoot?: string;
}): Promise<ContentResult> {
const { result, log, errorPrefix = 'API error', projectRoot } = options;
// Get version info for every response
const versionInfo = getVersionInfo();
// Get current tag if project root is provided
const currentTag = projectRoot ? getCurrentTag(projectRoot) : null;
if (!result.success) {
const errorMsg = result.error?.message || `Unknown ${errorPrefix}`;
log?.error?.(`${errorPrefix}: ${errorMsg}`);
let errorText = `Error: ${errorMsg}\nVersion: ${versionInfo.version}\nName: ${versionInfo.name}`;
if (currentTag) {
errorText += `\nCurrent Tag: ${currentTag}`;
}
return {
content: [
{
type: 'text',
text: errorText
}
],
isError: true
};
}
log?.info?.('Successfully completed operation');
// Create the response payload including version info and tag
const responsePayload: any = {
data: result.data,
version: versionInfo
};
// Add current tag if available
if (currentTag) {
responsePayload.tag = currentTag;
}
return {
content: [
{
type: 'text',
text: JSON.stringify(responsePayload, null, 2)
}
]
};
}
/**
* Normalize project root path (handles URI encoding, file:// protocol, Windows paths)
*/
export function normalizeProjectRoot(rawPath: string): string {
if (!rawPath) return process.cwd();
try {
let pathString = rawPath;
// Decode URI encoding
try {
pathString = decodeURIComponent(pathString);
} catch {
// If decoding fails, use as-is
}
// Strip file:// prefix
if (pathString.startsWith('file:///')) {
pathString = pathString.slice(7);
} else if (pathString.startsWith('file://')) {
pathString = pathString.slice(7);
}
// Handle Windows drive letter after stripping prefix (e.g., /C:/...)
if (
pathString.startsWith('/') &&
/[A-Za-z]:/.test(pathString.substring(1, 3))
) {
pathString = pathString.substring(1);
}
// Normalize backslashes to forward slashes
pathString = pathString.replace(/\\/g, '/');
// Resolve to absolute path
return path.resolve(pathString);
} catch {
return path.resolve(rawPath);
}
}
/**
* Get project root from session object
*/
function getProjectRootFromSession(session: any): string | null {
try {
// Check primary location
if (session?.roots?.[0]?.uri) {
return normalizeProjectRoot(session.roots[0].uri);
}
// Check alternate location
else if (session?.roots?.roots?.[0]?.uri) {
return normalizeProjectRoot(session.roots.roots[0].uri);
}
return null;
} catch {
return null;
}
}
/**
* Wrapper to normalize project root in args with proper precedence order
*
* PRECEDENCE ORDER:
* 1. TASK_MASTER_PROJECT_ROOT environment variable (from process.env or session)
* 2. args.projectRoot (explicitly provided)
* 3. Session-based project root resolution
* 4. Current directory fallback
*/
export function withNormalizedProjectRoot<T extends { projectRoot?: string }>(
fn: (
args: T & { projectRoot: string },
context: any
) => Promise<ContentResult>
): (args: T, context: any) => Promise<ContentResult> {
return async (args: T, context: any): Promise<ContentResult> => {
const { log, session } = context;
let normalizedRoot: string | null = null;
let rootSource = 'unknown';
try {
// 1. Check for TASK_MASTER_PROJECT_ROOT environment variable first
if (process.env.TASK_MASTER_PROJECT_ROOT) {
const envRoot = process.env.TASK_MASTER_PROJECT_ROOT;
normalizedRoot = path.isAbsolute(envRoot)
? envRoot
: path.resolve(process.cwd(), envRoot);
rootSource = 'TASK_MASTER_PROJECT_ROOT environment variable';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// Also check session environment variables for TASK_MASTER_PROJECT_ROOT
else if (session?.env?.TASK_MASTER_PROJECT_ROOT) {
const envRoot = session.env.TASK_MASTER_PROJECT_ROOT;
normalizedRoot = path.isAbsolute(envRoot)
? envRoot
: path.resolve(process.cwd(), envRoot);
rootSource = 'TASK_MASTER_PROJECT_ROOT session environment variable';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// 2. If no environment variable, try args.projectRoot
else if (args.projectRoot) {
normalizedRoot = normalizeProjectRoot(args.projectRoot);
rootSource = 'args.projectRoot';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// 3. If no args.projectRoot, try session-based resolution
else {
const sessionRoot = getProjectRootFromSession(session);
if (sessionRoot) {
normalizedRoot = sessionRoot;
rootSource = 'session';
log?.info?.(
`Using project root from ${rootSource}: ${normalizedRoot}`
);
}
}
if (!normalizedRoot) {
log?.error?.(
'Could not determine project root from environment, args, or session.'
);
return handleApiResult({
result: {
success: false,
error: {
message:
'Could not determine project root. Please provide projectRoot argument or ensure TASK_MASTER_PROJECT_ROOT environment variable is set.'
}
}
});
}
// Inject the normalized root back into args
const updatedArgs = { ...args, projectRoot: normalizedRoot } as T & {
projectRoot: string;
};
// Execute the original function with normalized root in args
return await fn(updatedArgs, context);
} catch (error: any) {
log?.error?.(
`Error within withNormalizedProjectRoot HOF (Normalized Root: ${normalizedRoot}): ${error.message}`
);
if (error.stack && log?.debug) {
log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Operation failed: ${error.message}`
}
}
});
}
};
}

View File

@@ -0,0 +1,99 @@
/**
* @fileoverview autopilot-abort MCP tool
* Abort a running TDD workflow and clean up state
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const AbortSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type AbortArgs = z.infer<typeof AbortSchema>;
/**
* Register the autopilot_abort tool with the MCP server
*/
export function registerAutopilotAbortTool(server: FastMCP) {
server.addTool({
name: 'autopilot_abort',
description:
'Abort the current TDD workflow and clean up workflow state. This will remove the workflow state file but will NOT delete the git branch or any code changes.',
parameters: AbortSchema,
execute: withNormalizedProjectRoot(
async (args: AbortArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(`Aborting autopilot workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
const hasWorkflow = await workflowService.hasWorkflow();
if (!hasWorkflow) {
context.log.warn('No active workflow to abort');
return handleApiResult({
result: {
success: true,
data: {
message: 'No active workflow to abort',
hadWorkflow: false
}
},
log: context.log,
projectRoot
});
}
// Get info before aborting
await workflowService.resumeWorkflow();
const status = workflowService.getStatus();
// Abort workflow
await workflowService.abortWorkflow();
context.log.info('Workflow state deleted');
return handleApiResult({
result: {
success: true,
data: {
message: 'Workflow aborted',
hadWorkflow: true,
taskId: status.taskId,
branchName: status.branchName,
note: 'Git branch and code changes were preserved. You can manually clean them up if needed.'
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-abort: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: { message: `Failed to abort workflow: ${error.message}` }
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,240 @@
/**
* @fileoverview autopilot-commit MCP tool
* Create a git commit with automatic staging and message generation
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService, GitAdapter, CommitMessageGenerator } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const CommitSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory'),
files: z
.array(z.string())
.optional()
.describe(
'Specific files to stage (relative to project root). If not provided, stages all changes.'
),
customMessage: z
.string()
.optional()
.describe('Custom commit message to use instead of auto-generated message')
});
type CommitArgs = z.infer<typeof CommitSchema>;
/**
* Register the autopilot_commit tool with the MCP server
*/
export function registerAutopilotCommitTool(server: FastMCP) {
server.addTool({
name: 'autopilot_commit',
description:
'Create a git commit with automatic staging, message generation, and metadata embedding. Generates appropriate commit messages based on subtask context and TDD phase.',
parameters: CommitSchema,
execute: withNormalizedProjectRoot(
async (args: CommitArgs, context: MCPContext) => {
const { projectRoot, files, customMessage } = args;
try {
context.log.info(`Creating commit for workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume workflow
await workflowService.resumeWorkflow();
const status = workflowService.getStatus();
const workflowContext = workflowService.getContext();
// Verify we're in COMMIT phase
if (status.tddPhase !== 'COMMIT') {
context.log.warn(
`Not in COMMIT phase (currently in ${status.tddPhase})`
);
return handleApiResult({
result: {
success: false,
error: {
message: `Cannot commit: currently in ${status.tddPhase} phase. Complete the ${status.tddPhase} phase first using autopilot_complete_phase`
}
},
log: context.log,
projectRoot
});
}
// Verify there's an active subtask
if (!status.currentSubtask) {
return handleApiResult({
result: {
success: false,
error: { message: 'No active subtask to commit' }
},
log: context.log,
projectRoot
});
}
// Initialize git adapter
const gitAdapter = new GitAdapter(projectRoot);
// Stage files
try {
if (files && files.length > 0) {
await gitAdapter.stageFiles(files);
context.log.info(`Staged ${files.length} files`);
} else {
await gitAdapter.stageFiles(['.']);
context.log.info('Staged all changes');
}
} catch (error: any) {
context.log.error(`Failed to stage files: ${error.message}`);
return handleApiResult({
result: {
success: false,
error: { message: `Failed to stage files: ${error.message}` }
},
log: context.log,
projectRoot
});
}
// Check if there are staged changes
const hasStagedChanges = await gitAdapter.hasStagedChanges();
if (!hasStagedChanges) {
context.log.warn('No staged changes to commit');
return handleApiResult({
result: {
success: false,
error: {
message:
'No staged changes to commit. Make code changes before committing'
}
},
log: context.log,
projectRoot
});
}
// Get git status for message generation
const gitStatus = await gitAdapter.getStatus();
// Generate commit message
let commitMessage: string;
if (customMessage) {
commitMessage = customMessage;
context.log.info('Using custom commit message');
} else {
const messageGenerator = new CommitMessageGenerator();
// Determine commit type based on phase and subtask
// RED phase = test files, GREEN phase = implementation
const type = status.tddPhase === 'COMMIT' ? 'feat' : 'test';
// Use subtask title as description
const description = status.currentSubtask.title;
// Construct proper CommitMessageOptions
const options = {
type,
description,
changedFiles: gitStatus.staged,
taskId: status.taskId,
phase: status.tddPhase,
testsPassing: workflowContext.lastTestResults?.passed,
testsFailing: workflowContext.lastTestResults?.failed
};
commitMessage = messageGenerator.generateMessage(options);
context.log.info('Generated commit message automatically');
}
// Create commit
try {
await gitAdapter.createCommit(commitMessage);
context.log.info('Commit created successfully');
} catch (error: any) {
context.log.error(`Failed to create commit: ${error.message}`);
return handleApiResult({
result: {
success: false,
error: { message: `Failed to create commit: ${error.message}` }
},
log: context.log,
projectRoot
});
}
// Get last commit info
const lastCommit = await gitAdapter.getLastCommit();
// Complete COMMIT phase and advance workflow
const newStatus = await workflowService.commit();
context.log.info(
`Commit completed. Current phase: ${newStatus.tddPhase || newStatus.phase}`
);
const isComplete = newStatus.phase === 'COMPLETE';
// Get next action with guidance
const nextAction = workflowService.getNextAction();
return handleApiResult({
result: {
success: true,
data: {
message: isComplete
? 'Workflow completed successfully'
: 'Commit created and workflow advanced',
commitSha: lastCommit.sha,
commitMessage,
...newStatus,
isComplete,
nextAction: nextAction.action,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-commit: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: { message: `Failed to commit: ${error.message}` }
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,152 @@
/**
* @fileoverview autopilot-complete MCP tool
* Complete the current TDD phase with test result validation
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const CompletePhaseSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory'),
testResults: z
.object({
total: z.number().describe('Total number of tests'),
passed: z.number().describe('Number of passing tests'),
failed: z.number().describe('Number of failing tests'),
skipped: z.number().optional().describe('Number of skipped tests')
})
.describe('Test results from running the test suite')
});
type CompletePhaseArgs = z.infer<typeof CompletePhaseSchema>;
/**
* Register the autopilot_complete_phase tool with the MCP server
*/
export function registerAutopilotCompleteTool(server: FastMCP) {
server.addTool({
name: 'autopilot_complete_phase',
description:
'Complete the current TDD phase (RED, GREEN, or COMMIT) with test result validation. RED phase: expects failures (if 0 failures, feature is already implemented and subtask auto-completes). GREEN phase: expects all tests passing.',
parameters: CompletePhaseSchema,
execute: withNormalizedProjectRoot(
async (args: CompletePhaseArgs, context: MCPContext) => {
const { projectRoot, testResults } = args;
try {
context.log.info(
`Completing current phase in workflow for ${projectRoot}`
);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume workflow to get current state
await workflowService.resumeWorkflow();
const currentStatus = workflowService.getStatus();
// Validate that we're in a TDD phase (RED or GREEN)
if (!currentStatus.tddPhase) {
return handleApiResult({
result: {
success: false,
error: {
message: `Cannot complete phase: not in a TDD phase (current phase: ${currentStatus.phase})`
}
},
log: context.log,
projectRoot
});
}
// COMMIT phase completion is handled by autopilot_commit tool
if (currentStatus.tddPhase === 'COMMIT') {
return handleApiResult({
result: {
success: false,
error: {
message:
'Cannot complete COMMIT phase with this tool. Use autopilot_commit instead'
}
},
log: context.log,
projectRoot
});
}
// Map TDD phase to TestResult phase (only RED or GREEN allowed)
const phase = currentStatus.tddPhase as 'RED' | 'GREEN';
// Construct full TestResult with phase
const fullTestResults = {
total: testResults.total,
passed: testResults.passed,
failed: testResults.failed,
skipped: testResults.skipped ?? 0,
phase
};
// Complete phase with test results
const status = await workflowService.completePhase(fullTestResults);
const nextAction = workflowService.getNextAction();
context.log.info(
`Phase completed. New phase: ${status.tddPhase || status.phase}`
);
return handleApiResult({
result: {
success: true,
data: {
message: `Phase completed. Transitioned to ${status.tddPhase || status.phase}`,
...status,
nextAction: nextAction.action,
actionDescription: nextAction.description,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-complete: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to complete phase: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,114 @@
/**
* @fileoverview autopilot-finalize MCP tool
* Finalize and complete the workflow with working tree validation
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const FinalizeSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type FinalizeArgs = z.infer<typeof FinalizeSchema>;
/**
* Register the autopilot_finalize tool with the MCP server
*/
export function registerAutopilotFinalizeTool(server: FastMCP) {
server.addTool({
name: 'autopilot_finalize',
description:
'Finalize and complete the workflow. Validates that all changes are committed and working tree is clean before marking workflow as complete.',
parameters: FinalizeSchema,
execute: withNormalizedProjectRoot(
async (args: FinalizeArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(`Finalizing workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume workflow
await workflowService.resumeWorkflow();
const currentStatus = workflowService.getStatus();
// Verify we're in FINALIZE phase
if (currentStatus.phase !== 'FINALIZE') {
return handleApiResult({
result: {
success: false,
error: {
message: `Cannot finalize: workflow is in ${currentStatus.phase} phase. Complete all subtasks first.`
}
},
log: context.log,
projectRoot
});
}
// Finalize workflow (validates clean working tree)
const newStatus = await workflowService.finalizeWorkflow();
context.log.info('Workflow finalized successfully');
// Get next action
const nextAction = workflowService.getNextAction();
return handleApiResult({
result: {
success: true,
data: {
message: 'Workflow completed successfully',
...newStatus,
nextAction: nextAction.action,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-finalize: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to finalize workflow: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,13 @@
/**
* @fileoverview Autopilot MCP tools index
* Exports all autopilot tool registration functions
*/
export { registerAutopilotStartTool } from './start.tool.js';
export { registerAutopilotResumeTool } from './resume.tool.js';
export { registerAutopilotNextTool } from './next.tool.js';
export { registerAutopilotStatusTool } from './status.tool.js';
export { registerAutopilotCompleteTool } from './complete.tool.js';
export { registerAutopilotCommitTool } from './commit.tool.js';
export { registerAutopilotFinalizeTool } from './finalize.tool.js';
export { registerAutopilotAbortTool } from './abort.tool.js';

View File

@@ -0,0 +1,99 @@
/**
* @fileoverview autopilot-next MCP tool
* Get the next action to perform in the TDD workflow
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const NextActionSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type NextActionArgs = z.infer<typeof NextActionSchema>;
/**
* Register the autopilot_next tool with the MCP server
*/
export function registerAutopilotNextTool(server: FastMCP) {
server.addTool({
name: 'autopilot_next',
description:
'Get the next action to perform in the TDD workflow. Returns detailed context about what needs to be done next, including the current phase, subtask, and expected actions.',
parameters: NextActionSchema,
execute: withNormalizedProjectRoot(
async (args: NextActionArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(
`Getting next action for workflow in ${projectRoot}`
);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume to load state
await workflowService.resumeWorkflow();
// Get next action
const nextAction = workflowService.getNextAction();
const status = workflowService.getStatus();
context.log.info(`Next action determined: ${nextAction.action}`);
return handleApiResult({
result: {
success: true,
data: {
action: nextAction.action,
actionDescription: nextAction.description,
...status,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-next: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to get next action: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,95 @@
/**
* @fileoverview autopilot-resume MCP tool
* Resume a previously started TDD workflow from saved state
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const ResumeWorkflowSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type ResumeWorkflowArgs = z.infer<typeof ResumeWorkflowSchema>;
/**
* Register the autopilot_resume tool with the MCP server
*/
export function registerAutopilotResumeTool(server: FastMCP) {
server.addTool({
name: 'autopilot_resume',
description:
'Resume a previously started TDD workflow from saved state. Restores the workflow state machine and continues from where it left off.',
parameters: ResumeWorkflowSchema,
execute: withNormalizedProjectRoot(
async (args: ResumeWorkflowArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(`Resuming autopilot workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No workflow state found. Start a new workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume workflow
const status = await workflowService.resumeWorkflow();
const nextAction = workflowService.getNextAction();
context.log.info(
`Workflow resumed successfully for task ${status.taskId}`
);
return handleApiResult({
result: {
success: true,
data: {
message: 'Workflow resumed',
...status,
nextAction: nextAction.action,
actionDescription: nextAction.description,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-resume: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: { message: `Failed to resume workflow: ${error.message}` }
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,197 @@
/**
* @fileoverview autopilot-start MCP tool
* Initialize and start a new TDD workflow for a task
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { createTaskMasterCore } from '@tm/core';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const StartWorkflowSchema = z.object({
taskId: z
.string()
.describe(
'Main task ID to start workflow for (e.g., "1", "2", "HAM-123"). Subtask IDs (e.g., "2.3", "1.1") are not allowed.'
),
projectRoot: z
.string()
.describe('Absolute path to the project root directory'),
maxAttempts: z
.number()
.optional()
.default(3)
.describe('Maximum attempts per subtask (default: 3)'),
force: z
.boolean()
.optional()
.default(false)
.describe('Force start even if workflow state exists')
});
type StartWorkflowArgs = z.infer<typeof StartWorkflowSchema>;
/**
* Check if a task ID is a main task (not a subtask)
* Main tasks: "1", "2", "HAM-123", "PROJ-456"
* Subtasks: "1.1", "2.3", "HAM-123.1"
*/
function isMainTaskId(taskId: string): boolean {
// A main task has no dots in the ID after the optional prefix
// Examples: "1" ✓, "HAM-123" ✓, "1.1" ✗, "HAM-123.1" ✗
const parts = taskId.split('.');
return parts.length === 1;
}
/**
* Register the autopilot_start tool with the MCP server
*/
export function registerAutopilotStartTool(server: FastMCP) {
server.addTool({
name: 'autopilot_start',
description:
'Initialize and start a new TDD workflow for a task. Creates a git branch and sets up the workflow state machine.',
parameters: StartWorkflowSchema,
execute: withNormalizedProjectRoot(
async (args: StartWorkflowArgs, context: MCPContext) => {
const { taskId, projectRoot, maxAttempts, force } = args;
try {
context.log.info(
`Starting autopilot workflow for task ${taskId} in ${projectRoot}`
);
// Validate that taskId is a main task (not a subtask)
if (!isMainTaskId(taskId)) {
return handleApiResult({
result: {
success: false,
error: {
message: `Task ID "${taskId}" is a subtask. Autopilot workflows can only be started for main tasks (e.g., "1", "2", "HAM-123"). Please provide the parent task ID instead.`
}
},
log: context.log,
projectRoot
});
}
// Load task data and get current tag
const core = await createTaskMasterCore({
projectPath: projectRoot
});
// Get current tag from ConfigManager
const currentTag = core.getActiveTag();
const taskResult = await core.getTaskWithSubtask(taskId);
if (!taskResult || !taskResult.task) {
await core.close();
return handleApiResult({
result: {
success: false,
error: { message: `Task ${taskId} not found` }
},
log: context.log,
projectRoot
});
}
const task = taskResult.task;
// Validate task has subtasks
if (!task.subtasks || task.subtasks.length === 0) {
await core.close();
return handleApiResult({
result: {
success: false,
error: {
message: `Task ${taskId} has no subtasks. Please use expand_task (with id="${taskId}") to create subtasks first. For improved results, consider running analyze_complexity before expanding the task.`
}
},
log: context.log,
projectRoot
});
}
// Initialize workflow service
const workflowService = new WorkflowService(projectRoot);
// Check for existing workflow
const hasWorkflow = await workflowService.hasWorkflow();
if (hasWorkflow && !force) {
context.log.warn('Workflow state already exists');
return handleApiResult({
result: {
success: false,
error: {
message:
'Workflow already in progress. Use force=true to override or resume the existing workflow. Suggestion: Use autopilot_resume to continue the existing workflow'
}
},
log: context.log,
projectRoot
});
}
// Start workflow
const status = await workflowService.startWorkflow({
taskId,
taskTitle: task.title,
subtasks: task.subtasks.map((st: any) => ({
id: st.id,
title: st.title,
status: st.status,
maxAttempts
})),
maxAttempts,
force,
tag: currentTag // Pass current tag for branch naming
});
context.log.info(`Workflow started successfully for task ${taskId}`);
// Get next action with guidance from WorkflowService
const nextAction = workflowService.getNextAction();
return handleApiResult({
result: {
success: true,
data: {
message: `Workflow started for task ${taskId}`,
taskId,
branchName: status.branchName,
phase: status.phase,
tddPhase: status.tddPhase,
progress: status.progress,
currentSubtask: status.currentSubtask,
nextAction: nextAction.action,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-start: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: { message: `Failed to start workflow: ${error.message}` }
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,93 @@
/**
* @fileoverview autopilot-status MCP tool
* Get comprehensive workflow status and progress information
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const StatusSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type StatusArgs = z.infer<typeof StatusSchema>;
/**
* Register the autopilot_status tool with the MCP server
*/
export function registerAutopilotStatusTool(server: FastMCP) {
server.addTool({
name: 'autopilot_status',
description:
'Get comprehensive workflow status including current phase, progress, subtask details, and activity history.',
parameters: StatusSchema,
execute: withNormalizedProjectRoot(
async (args: StatusArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(`Getting workflow status for ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume to load state
await workflowService.resumeWorkflow();
// Get status
const status = workflowService.getStatus();
context.log.info(
`Workflow status retrieved for task ${status.taskId}`
);
return handleApiResult({
result: {
success: true,
data: status
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-status: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to get workflow status: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

36
apps/mcp/tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": ".",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "NodeNext",
"moduleDetection": "force",
"types": ["node"],
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests", "**/*.test.ts", "**/*.spec.ts"]
}

23
apps/mcp/vitest.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'tests/',
'**/*.test.ts',
'**/*.spec.ts',
'**/*.d.ts',
'**/mocks/**',
'**/fixtures/**',
'vitest.config.ts'
]
}
}
});