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:
@@ -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
|
||||
{
|
||||
|
||||
515
apps/cli/src/commands/autopilot.command.ts
Normal file
515
apps/cli/src/commands/autopilot.command.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
119
apps/cli/src/commands/autopilot/abort.command.ts
Normal file
119
apps/cli/src/commands/autopilot/abort.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
169
apps/cli/src/commands/autopilot/commit.command.ts
Normal file
169
apps/cli/src/commands/autopilot/commit.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
172
apps/cli/src/commands/autopilot/complete.command.ts
Normal file
172
apps/cli/src/commands/autopilot/complete.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
apps/cli/src/commands/autopilot/index.ts
Normal file
82
apps/cli/src/commands/autopilot/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
164
apps/cli/src/commands/autopilot/next.command.ts
Normal file
164
apps/cli/src/commands/autopilot/next.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
111
apps/cli/src/commands/autopilot/resume.command.ts
Normal file
111
apps/cli/src/commands/autopilot/resume.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
262
apps/cli/src/commands/autopilot/shared.ts
Normal file
262
apps/cli/src/commands/autopilot/shared.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
168
apps/cli/src/commands/autopilot/start.command.ts
Normal file
168
apps/cli/src/commands/autopilot/start.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
apps/cli/src/commands/autopilot/status.command.ts
Normal file
114
apps/cli/src/commands/autopilot/status.command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user