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

@@ -6,8 +6,19 @@
export { TaskService } from './task-service.js';
export { OrganizationService } from './organization.service.js';
export { ExportService } from './export.service.js';
export { PreflightChecker } from './preflight-checker.service.js';
export { TaskLoaderService } from './task-loader.service.js';
export type { Organization, Brief } from './organization.service.js';
export type {
ExportTasksOptions,
ExportResult
} from './export.service.js';
export type {
CheckResult,
PreflightResult
} from './preflight-checker.service.js';
export type {
TaskValidationResult,
ValidationErrorType,
DependencyIssue
} from './task-loader.service.js';

View File

@@ -0,0 +1,316 @@
/**
* @fileoverview Preflight Checker Service
* Validates environment and prerequisites for autopilot execution
*/
import { readFileSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import {
isGitRepository,
isGhCliAvailable,
getDefaultBranch
} from '../../../../scripts/modules/utils/git-utils.js';
import { getLogger } from '../logger/factory.js';
const logger = getLogger('PreflightChecker');
/**
* Result of a single preflight check
*/
export interface CheckResult {
/** Whether the check passed */
success: boolean;
/** The value detected/validated */
value?: any;
/** Error or warning message */
message?: string;
}
/**
* Complete preflight validation results
*/
export interface PreflightResult {
/** Overall success - all checks passed */
success: boolean;
/** Test command detection result */
testCommand: CheckResult;
/** Git working tree status */
gitWorkingTree: CheckResult;
/** Required tools availability */
requiredTools: CheckResult;
/** Default branch detection */
defaultBranch: CheckResult;
/** Summary message */
summary: string;
}
/**
* Tool validation result
*/
interface ToolCheck {
name: string;
available: boolean;
version?: string;
message?: string;
}
/**
* PreflightChecker validates environment for autopilot execution
*/
export class PreflightChecker {
private projectRoot: string;
constructor(projectRoot: string) {
if (!projectRoot) {
throw new Error('projectRoot is required for PreflightChecker');
}
this.projectRoot = projectRoot;
}
/**
* Detect test command from package.json
*/
async detectTestCommand(): Promise<CheckResult> {
try {
const packageJsonPath = join(this.projectRoot, 'package.json');
const packageJsonContent = readFileSync(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageJsonContent);
if (!packageJson.scripts || !packageJson.scripts.test) {
return {
success: false,
message:
'No test script found in package.json. Please add a "test" script.'
};
}
const testCommand = packageJson.scripts.test;
return {
success: true,
value: testCommand,
message: `Test command: ${testCommand}`
};
} catch (error: any) {
if (error.code === 'ENOENT') {
return {
success: false,
message: 'package.json not found in project root'
};
}
return {
success: false,
message: `Failed to read package.json: ${error.message}`
};
}
}
/**
* Check git working tree status
*/
async checkGitWorkingTree(): Promise<CheckResult> {
try {
// Check if it's a git repository
const isRepo = await isGitRepository(this.projectRoot);
if (!isRepo) {
return {
success: false,
message: 'Not a git repository. Initialize git first.'
};
}
// Check for uncommitted changes
try {
execSync('git diff-index --quiet HEAD --', {
cwd: this.projectRoot,
stdio: 'pipe'
});
// Also check for untracked files
const status = execSync('git status --porcelain', {
cwd: this.projectRoot,
encoding: 'utf-8'
});
if (status.trim().length > 0) {
return {
success: false,
value: 'dirty',
message:
'Working tree has uncommitted changes. Please commit or stash them.'
};
}
return {
success: true,
value: 'clean',
message: 'Working tree is clean'
};
} catch (error: any) {
// git diff-index returns non-zero if there are changes
return {
success: false,
value: 'dirty',
message:
'Working tree has uncommitted changes. Please commit or stash them.'
};
}
} catch (error: any) {
return {
success: false,
message: `Git check failed: ${error.message}`
};
}
}
/**
* Validate required tools availability
*/
async validateRequiredTools(): Promise<CheckResult> {
const tools: ToolCheck[] = [];
// Check git
tools.push(this.checkTool('git', ['--version']));
// Check gh CLI
const ghAvailable = await isGhCliAvailable(this.projectRoot);
tools.push({
name: 'gh',
available: ghAvailable,
message: ghAvailable ? 'GitHub CLI available' : 'GitHub CLI not available'
});
// Check node
tools.push(this.checkTool('node', ['--version']));
// Check npm
tools.push(this.checkTool('npm', ['--version']));
// Determine overall success
const allAvailable = tools.every((tool) => tool.available);
const missingTools = tools
.filter((tool) => !tool.available)
.map((tool) => tool.name);
if (!allAvailable) {
return {
success: false,
value: tools,
message: `Missing required tools: ${missingTools.join(', ')}`
};
}
return {
success: true,
value: tools,
message: 'All required tools are available'
};
}
/**
* Check if a command-line tool is available
*/
private checkTool(command: string, versionArgs: string[]): ToolCheck {
try {
const version = execSync(`${command} ${versionArgs.join(' ')}`, {
cwd: this.projectRoot,
encoding: 'utf-8',
stdio: 'pipe'
})
.trim()
.split('\n')[0];
return {
name: command,
available: true,
version,
message: `${command} ${version}`
};
} catch (error) {
return {
name: command,
available: false,
message: `${command} not found`
};
}
}
/**
* Detect default branch
*/
async detectDefaultBranch(): Promise<CheckResult> {
try {
const defaultBranch = await getDefaultBranch(this.projectRoot);
if (!defaultBranch) {
return {
success: false,
message:
'Could not determine default branch. Make sure remote is configured.'
};
}
return {
success: true,
value: defaultBranch,
message: `Default branch: ${defaultBranch}`
};
} catch (error: any) {
return {
success: false,
message: `Failed to detect default branch: ${error.message}`
};
}
}
/**
* Run all preflight checks
*/
async runAllChecks(): Promise<PreflightResult> {
logger.info('Running preflight checks...');
const testCommand = await this.detectTestCommand();
const gitWorkingTree = await this.checkGitWorkingTree();
const requiredTools = await this.validateRequiredTools();
const defaultBranch = await this.detectDefaultBranch();
const allSuccess =
testCommand.success &&
gitWorkingTree.success &&
requiredTools.success &&
defaultBranch.success;
// Build summary
const passed: string[] = [];
const failed: string[] = [];
if (testCommand.success) passed.push('Test command');
else failed.push('Test command');
if (gitWorkingTree.success) passed.push('Git working tree');
else failed.push('Git working tree');
if (requiredTools.success) passed.push('Required tools');
else failed.push('Required tools');
if (defaultBranch.success) passed.push('Default branch');
else failed.push('Default branch');
const summary = allSuccess
? `All preflight checks passed (${passed.length}/4)`
: `Preflight checks failed: ${failed.join(', ')} (${passed.length}/4 passed)`;
logger.info(summary);
return {
success: allSuccess,
testCommand,
gitWorkingTree,
requiredTools,
defaultBranch,
summary
};
}
}

View File

@@ -0,0 +1,389 @@
/**
* @fileoverview Task Loader Service
* Loads and validates tasks for autopilot execution
*/
import type { Task, Subtask, TaskStatus } from '../types/index.js';
import { TaskService } from './task-service.js';
import { ConfigManager } from '../config/config-manager.js';
import { getLogger } from '../logger/factory.js';
const logger = getLogger('TaskLoader');
/**
* Validation error types
*/
export type ValidationErrorType =
| 'task_not_found'
| 'task_completed'
| 'no_subtasks'
| 'circular_dependencies'
| 'missing_dependencies'
| 'invalid_structure';
/**
* Validation result for task loading
*/
export interface TaskValidationResult {
/** Whether validation passed */
success: boolean;
/** Loaded task (only present if validation succeeded) */
task?: Task;
/** Error type */
errorType?: ValidationErrorType;
/** Human-readable error message */
errorMessage?: string;
/** Actionable suggestion for fixing the error */
suggestion?: string;
/** Dependency analysis (only for dependency errors) */
dependencyIssues?: DependencyIssue[];
}
/**
* Dependency issue details
*/
export interface DependencyIssue {
/** Subtask ID with the issue */
subtaskId: string;
/** Type of dependency issue */
issueType: 'circular' | 'missing' | 'invalid';
/** Description of the issue */
message: string;
/** The problematic dependency reference */
dependencyRef?: string;
}
/**
* TaskLoaderService loads and validates tasks for autopilot execution
*/
export class TaskLoaderService {
private taskService: TaskService;
private projectRoot: string;
constructor(projectRoot: string) {
if (!projectRoot) {
throw new Error('projectRoot is required for TaskLoaderService');
}
this.projectRoot = projectRoot;
// Initialize TaskService with ConfigManager
const configManager = new ConfigManager(projectRoot);
this.taskService = new TaskService(configManager);
}
/**
* Load and validate a task for autopilot execution
*/
async loadAndValidateTask(taskId: string): Promise<TaskValidationResult> {
logger.info(`Loading task ${taskId}...`);
// Step 1: Load task
const task = await this.loadTask(taskId);
if (!task) {
return {
success: false,
errorType: 'task_not_found',
errorMessage: `Task with ID "${taskId}" not found`,
suggestion:
'Use "task-master list" to see available tasks or verify the task ID is correct.'
};
}
// Step 2: Validate task status
const statusValidation = this.validateTaskStatus(task);
if (!statusValidation.success) {
return statusValidation;
}
// Step 3: Check for subtasks
const subtaskValidation = this.validateSubtasksExist(task);
if (!subtaskValidation.success) {
return subtaskValidation;
}
// Step 4: Validate subtask structure
const structureValidation = this.validateSubtaskStructure(task);
if (!structureValidation.success) {
return structureValidation;
}
// Step 5: Analyze dependencies
const dependencyValidation = this.validateDependencies(task);
if (!dependencyValidation.success) {
return dependencyValidation;
}
logger.info(`Task ${taskId} validated successfully`);
return {
success: true,
task
};
}
/**
* Load task using TaskService
*/
private async loadTask(taskId: string): Promise<Task | null> {
try {
const result = await this.taskService.getTask(taskId);
return result.task || null;
} catch (error) {
logger.error(`Failed to load task ${taskId}:`, error);
return null;
}
}
/**
* Validate task status is appropriate for autopilot
*/
private validateTaskStatus(task: Task): TaskValidationResult {
const completedStatuses: TaskStatus[] = ['done', 'completed', 'cancelled'];
if (completedStatuses.includes(task.status)) {
return {
success: false,
errorType: 'task_completed',
errorMessage: `Task "${task.title}" is already ${task.status}`,
suggestion:
'Autopilot can only execute tasks that are pending or in-progress. Use a different task.'
};
}
return { success: true };
}
/**
* Validate task has subtasks
*/
private validateSubtasksExist(task: Task): TaskValidationResult {
if (!task.subtasks || task.subtasks.length === 0) {
return {
success: false,
errorType: 'no_subtasks',
errorMessage: `Task "${task.title}" has no subtasks`,
suggestion: this.buildExpansionSuggestion(task)
};
}
return { success: true };
}
/**
* Build helpful suggestion for expanding tasks
*/
private buildExpansionSuggestion(task: Task): string {
const suggestions: string[] = [
`Autopilot requires tasks to be broken down into subtasks for execution.`
];
// Add expansion command suggestion
suggestions.push(`\nExpand this task using:`);
suggestions.push(` task-master expand --id=${task.id}`);
// If task has complexity analysis, mention it
if (task.complexity || task.recommendedSubtasks) {
suggestions.push(
`\nThis task has complexity analysis available. Consider reviewing it first:`
);
suggestions.push(` task-master show ${task.id}`);
} else {
suggestions.push(
`\nOr analyze task complexity first to determine optimal subtask count:`
);
suggestions.push(` task-master analyze-complexity --from=${task.id}`);
}
return suggestions.join('\n');
}
/**
* Validate subtask structure
*/
private validateSubtaskStructure(task: Task): TaskValidationResult {
for (const subtask of task.subtasks) {
// Check required fields
if (!subtask.title || !subtask.description) {
return {
success: false,
errorType: 'invalid_structure',
errorMessage: `Subtask ${task.id}.${subtask.id} is missing required fields`,
suggestion:
'Subtasks must have title and description. Re-expand the task or manually fix the subtask structure.'
};
}
// Validate dependencies are arrays
if (subtask.dependencies && !Array.isArray(subtask.dependencies)) {
return {
success: false,
errorType: 'invalid_structure',
errorMessage: `Subtask ${task.id}.${subtask.id} has invalid dependencies format`,
suggestion:
'Dependencies must be an array. Fix the task structure manually.'
};
}
}
return { success: true };
}
/**
* Validate subtask dependencies
*/
private validateDependencies(task: Task): TaskValidationResult {
const issues: DependencyIssue[] = [];
const subtaskIds = new Set(
task.subtasks.map((st) => String(st.id))
);
for (const subtask of task.subtasks) {
const subtaskId = `${task.id}.${subtask.id}`;
// Check for missing dependencies
if (subtask.dependencies && subtask.dependencies.length > 0) {
for (const depId of subtask.dependencies) {
const depIdStr = String(depId);
if (!subtaskIds.has(depIdStr)) {
issues.push({
subtaskId,
issueType: 'missing',
message: `References non-existent subtask ${depIdStr}`,
dependencyRef: depIdStr
});
}
}
}
// Check for circular dependencies
const circularCheck = this.detectCircularDependency(
subtask,
task.subtasks,
new Set()
);
if (circularCheck) {
issues.push({
subtaskId,
issueType: 'circular',
message: `Circular dependency detected: ${circularCheck.join(' -> ')}`
});
}
}
if (issues.length > 0) {
const errorType =
issues[0].issueType === 'circular'
? 'circular_dependencies'
: 'missing_dependencies';
return {
success: false,
errorType,
errorMessage: `Task "${task.title}" has dependency issues`,
suggestion:
'Fix dependency issues manually or re-expand the task:\n' +
issues.map((issue) => ` - ${issue.subtaskId}: ${issue.message}`).join('\n'),
dependencyIssues: issues
};
}
return { success: true };
}
/**
* Detect circular dependencies using depth-first search
*/
private detectCircularDependency(
subtask: Subtask,
allSubtasks: Subtask[],
visited: Set<string>
): string[] | null {
const subtaskId = String(subtask.id);
if (visited.has(subtaskId)) {
return [subtaskId];
}
visited.add(subtaskId);
if (subtask.dependencies && subtask.dependencies.length > 0) {
for (const depId of subtask.dependencies) {
const depIdStr = String(depId);
const dependency = allSubtasks.find((st) => String(st.id) === depIdStr);
if (dependency) {
const circular = this.detectCircularDependency(
dependency,
allSubtasks,
new Set(visited)
);
if (circular) {
return [subtaskId, ...circular];
}
}
}
}
return null;
}
/**
* Get ordered subtask execution sequence
* Returns subtasks in dependency order (tasks with no deps first)
*/
getExecutionOrder(task: Task): Subtask[] {
const ordered: Subtask[] = [];
const completed = new Set<string>();
// Keep adding subtasks whose dependencies are all completed
while (ordered.length < task.subtasks.length) {
const added = false;
for (const subtask of task.subtasks) {
const subtaskId = String(subtask.id);
if (completed.has(subtaskId)) {
continue;
}
// Check if all dependencies are completed
const allDepsCompleted =
!subtask.dependencies ||
subtask.dependencies.length === 0 ||
subtask.dependencies.every((depId) => completed.has(String(depId)));
if (allDepsCompleted) {
ordered.push(subtask);
completed.add(subtaskId);
break;
}
}
// Safety check to prevent infinite loop
if (!added && ordered.length < task.subtasks.length) {
logger.warn(
`Could not determine complete execution order for task ${task.id}`
);
// Add remaining subtasks in original order
for (const subtask of task.subtasks) {
if (!completed.has(String(subtask.id))) {
ordered.push(subtask);
}
}
break;
}
}
return ordered;
}
/**
* Clean up resources
*/
async cleanup(): Promise<void> {
await this.taskService.close();
}
}