feat: implement Phase 0 TDD autopilot dry-run foundation

Implements the complete Phase 0 spike for autonomous TDD workflow with orchestration architecture.

## What's New

### Core Services (tm-core)
- **PreflightChecker**: Validates environment prerequisites
  - Test command detection from package.json
  - Git working tree status validation
  - Required tools availability (git, gh, node, npm)
  - Default branch detection

- **TaskLoaderService**: Comprehensive task validation
  - Task existence and structure validation
  - Subtask dependency analysis with circular detection
  - Execution order calculation via topological sort
  - Helpful expansion suggestions for unready tasks

### CLI Command
- **autopilot command**: `tm autopilot <taskId> --dry-run`
  - Displays complete execution plan without executing
  - Shows preflight check results
  - Lists subtasks in dependency order
  - Preview RED/GREEN/COMMIT phases per subtask
  - Registered in command registry

### Architecture Documentation
- **Phase 0 completion**: Marked tdd-workflow-phase-0-spike.md as complete
- **Orchestration model**: Added execution model section to main workflow doc
  - Clarifies orchestrator guides AI sessions vs direct execution
  - WorkflowOrchestrator API design (getNextWorkUnit, completeWorkUnit)
  - State machine approach for phase transitions

- **Phase 1 roadmap**: New tdd-workflow-phase-1-orchestrator.md
  - Detailed state machine specifications
  - MCP integration plan with new tool definitions
  - Implementation checklist with 6 clear steps
  - Example usage flows

## Technical Details

**Preflight Checks**:
-  Test command detection
-  Git working tree status
-  Required tools validation
-  Default branch detection

**Task Validation**:
-  Task existence check
-  Status validation (no completed/cancelled tasks)
-  Subtask presence validation
-  Dependency resolution with circular detection
-  Execution order calculation

**Architecture Decision**:
Adopted orchestration model where WorkflowOrchestrator maintains state and generates work units, while Claude Code (via MCP) executes the actual work. This provides:
- Clean separation of concerns
- Human-in-the-loop capability
- Simpler implementation (no AI integration in orchestrator)
- Flexible executor support

## Out of Scope (Phase 0)
- Actual test generation
- Actual code implementation
- Git operations (commits, branches, PR)
- Test execution
→ All deferred to Phase 1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ralph Khreish
2025-10-07 18:43:33 +02:00
parent ad9355f97a
commit 8857417870
10 changed files with 1647 additions and 66 deletions

View File

@@ -13,6 +13,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.command.js';
/**
* Command metadata for registration
@@ -63,6 +64,12 @@ export class CommandRegistry {
commandClass: ExportCommand as any,
category: 'task'
},
{
name: 'autopilot',
description: 'Execute a task autonomously using TDD workflow',
commandClass: AutopilotCommand as any,
category: 'development'
},
// Authentication & Context Commands
{

View File

@@ -0,0 +1,424 @@
/**
* @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
} 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;
}
/**
* CLI-specific result type from autopilot command
*/
export interface AutopilotCommandResult {
success: boolean;
taskId: string;
task?: any;
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);
// 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: any) {
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<any | 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: any, 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 * 0.95 || 100
}
)
);
console.log();
}
/**
* Perform autopilot execution using PreflightChecker and TaskLoader
*/
private async performAutopilot(
taskId: string,
task: any,
options: AutopilotCommandOptions
): Promise<AutopilotCommandResult> {
const { PreflightChecker, TaskLoaderService } = await import('@tm/core');
// Run preflight checks
console.log();
console.log(chalk.cyan.bold('Running preflight checks...'));
const preflightChecker = new PreflightChecker(options.project || process.cwd());
const preflightResult = await preflightChecker.runAllChecks();
// Display preflight results
this.displayPreflightResults(preflightResult);
if (!preflightResult.success) {
return {
success: false,
taskId,
task,
error: 'Preflight checks failed',
message: 'Please resolve the issues above before running autopilot'
};
}
// Load and validate task
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
};
}
// Get execution order
const orderedSubtasks = taskLoader.getExecutionOrder(validationResult.task!);
await taskLoader.cleanup();
// Display execution plan
console.log();
console.log(chalk.green.bold('✓ All checks passed!'));
console.log();
console.log(chalk.cyan.bold('Execution Plan:'));
console.log(chalk.white(`Task: ${validationResult.task!.title}`));
console.log(chalk.gray(`${orderedSubtasks.length} subtasks will be executed in dependency order`));
console.log();
// Display subtasks
orderedSubtasks.forEach((subtask, index) => {
console.log(chalk.yellow(`${index + 1}. ${validationResult.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.'));
}
return {
success: true,
taskId,
task: validationResult.task,
message: options.dryRun
? 'Dry run completed successfully'
: 'Autopilot execution ready (actual execution not yet implemented)'
};
}
/**
* Display preflight check results
*/
private displayPreflightResults(result: any): 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, options);
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,
options: AutopilotCommandOptions
): 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: any): void {
const msg = error?.getSanitizedDetails?.() ?? {
message: error?.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) && error.stack) {
console.error(chalk.gray(error.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

@@ -11,6 +11,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 {