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 {