feat: Phase 1 - Complete TDD Workflow Automation System (#1289)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ralph Khreish
2025-10-14 20:25:01 +02:00
parent c92cee72c7
commit 11ace9da1f
83 changed files with 17215 additions and 2342 deletions

View File

@@ -1,3 +1,3 @@
reviews:
profile: assertive
profile: chill
poem: false

5
.gitignore vendored
View File

@@ -96,4 +96,7 @@ apps/extension/.vscode-test/
apps/extension/vsix-build/
# turbo
.turbo
.turbo
# TaskMaster Workflow State (now stored in ~/.taskmaster/sessions/)
# No longer needed in .gitignore as state is stored globally

View File

@@ -90,4 +90,4 @@
"reasoning": "Moderate complexity involving schema validation, file operations, and configuration management. The package.json shows ajv dependency is available. Configuration systems require careful validation and user-friendly error reporting, but follow established patterns."
}
]
}
}

File diff suppressed because one or more lines are too long

View File

@@ -6,13 +6,20 @@
## Test Guidelines
### Test File Placement
- **Package & tests**: Place in `packages/<package-name>/src/<module>/<file>.spec.ts` or `apps/<app-name>/src/<module>/<file.spec.ts>` alongside source
- **Package integration tests**: Place in `packages/<package-name>/tests/integration/<module>/<file>.test.ts` or `apps/<app-name>/tests/integration/<module>/<file>.test.ts` alongside source
- **Isolated unit tests**: Use `tests/unit/packages/<package-name>/` only when parallel placement isn't possible
- **Test extension**: Always use `.ts` for TypeScript tests, never `.js`
### Synchronous Tests
- **NEVER use async/await in test functions** unless testing actual asynchronous operations
- Use synchronous top-level imports instead of dynamic `await import()`
- Test bodies should be synchronous whenever possible
- Example:
```javascript
// ✅ CORRECT - Synchronous imports
```typescript
// ✅ CORRECT - Synchronous imports with .ts extension
import { MyClass } from '../src/my-class.js';
it('should verify behavior', () => {
@@ -26,6 +33,11 @@
});
```
## Documentation Guidelines
- **Documentation location**: Write docs in `apps/docs/` (Mintlify site source), not `docs/`
- **Documentation URL**: Reference docs at https://docs.task-master.dev, not local file paths
## Changeset Guidelines
- When creating changesets, remember that it's user-facing, meaning we don't have to get into the specifics of the code, but rather mention what the end-user is getting or fixing from this changeset.

View File

@@ -14,7 +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.command.js';
import { AutopilotCommand } from './commands/autopilot/index.js';
/**
* Command metadata for registration
@@ -73,7 +73,8 @@ export class CommandRegistry {
},
{
name: 'autopilot',
description: 'Execute a task autonomously using TDD workflow',
description:
'AI agent orchestration for TDD workflow (start, resume, next, complete, commit, status, abort)',
commandClass: AutopilotCommand as any,
category: 'development'
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -52,6 +52,20 @@
"capabilities/cli-root-commands",
"capabilities/task-structure"
]
},
{
"group": "TDD Workflow (Autopilot)",
"pages": [
"tdd-workflow/quickstart",
"tdd-workflow/ai-agent-integration",
{
"group": "Templates & Examples",
"pages": [
"tdd-workflow/templates/claude-template",
"tdd-workflow/templates/example-prompts"
]
}
]
}
]
}

View File

@@ -0,0 +1,944 @@
---
title: "AI Agent Integration Guide"
description: "Complete guide for integrating AI agents with TaskMaster's autonomous TDD workflow system"
---
Complete guide for integrating AI agents with TaskMaster's autonomous TDD workflow system.
## Overview
TaskMaster provides a complete TDD workflow orchestration system that enables AI agents to autonomously implement features following strict Test-Driven Development practices. The system manages workflow state, git operations, test validation, and progress tracking.
### Key Features
- **TDD State Machine**: Enforces RED → GREEN → COMMIT cycle
- **Git Integration**: Automated branch creation, commits with metadata
- **Test Validation**: Ensures RED phase has failures, GREEN phase passes
- **Progress Tracking**: Subtask completion, attempt counting, error logging
- **State Persistence**: Automatic workflow state management
- **Dual Interface**: CLI commands and MCP tools for flexibility
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ AI Agent │
│ (Claude Code, Custom Agent, etc.) │
└─────────────┬───────────────────────────────────────┘
│ Uses CLI or MCP
┌─────────────▼───────────────────────────────────────┐
│ TaskMaster Interface │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ CLI Commands │ │ MCP Tools │ │
│ └──────┬───────┘ └──────┬───────┘ │
└─────────┼────────────────────────┼─────────────────┘
│ │
┌─────────▼────────────────────────▼─────────────────┐
│ WorkflowOrchestrator (Core) │
│ ┌─────────────────────────────────────────────┐ │
│ │ State Machine: RED → GREEN → COMMIT │ │
│ └─────────────────────────────────────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │GitAdapter│ │TestResult│ │CommitMessage │ │
│ │ │ │Validator │ │Generator │ │
│ └──────────┘ └──────────┘ └──────────────┘ │
└────────────────────────────────────────────────────┘
│ Persists to
┌─────────▼───────────────────────────────────────────┐
│ .taskmaster/workflow-state.json │
└──────────────────────────────────────────────────────┘
```
### Component Responsibilities
**WorkflowOrchestrator**
- Manages TDD state machine transitions
- Tracks current subtask and progress
- Enforces workflow rules and validations
- Emits events for state changes
**GitAdapter**
- Creates and manages workflow branches
- Stages files and creates commits
- Validates git repository state
- Provides safety checks (clean working tree, etc.)
**TestResultValidator**
- Validates RED phase has test failures
- Validates GREEN phase has all tests passing
- Parses test results from various formats
**CommitMessageGenerator**
- Generates conventional commit messages
- Embeds workflow metadata (subtask ID, phase, etc.)
- Follows project commit conventions
## Getting Started
### Prerequisites
1. TaskMaster initialized project with subtasks
2. Git repository with clean working tree
3. Test framework configured (vitest, jest, etc.)
### Quick Start
```bash
# 1. Initialize workflow for a task
tm autopilot start 7
# 2. Check what to do next
tm autopilot next
# 3. Write failing test (RED phase)
# ... create test file ...
# 4. Run tests and complete RED phase
tm autopilot complete --results '{"total":1,"passed":0,"failed":1,"skipped":0}'
# 5. Implement code to pass tests (GREEN phase)
# ... write implementation ...
# 6. Run tests and complete GREEN phase
tm autopilot complete --results '{"total":1,"passed":1,"failed":0,"skipped":0}'
# 7. Commit changes
tm autopilot commit
# 8. Repeat for next subtask (automatically advanced)
tm autopilot next
```
## CLI Commands
All commands support `--json` flag for machine-readable output.
### `tm autopilot start <taskId>`
Initialize a new TDD workflow for a task.
**Options:**
- `--max-attempts <number>`: Maximum attempts per subtask (default: 3)
- `--force`: Force start even if workflow exists
- `--project-root <path>`: Project root directory
- `--json`: Output JSON
**Example:**
```bash
tm autopilot start 7 --max-attempts 5 --json
```
**JSON Output:**
```json
{
"success": true,
"message": "Workflow started for task 7",
"taskId": "7",
"branchName": "task-7",
"phase": "SUBTASK_LOOP",
"tddPhase": "RED",
"progress": {
"completed": 0,
"total": 5,
"percentage": 0
},
"currentSubtask": {
"id": "1",
"title": "Implement start command",
"status": "in-progress",
"attempts": 0
},
"nextAction": "generate_test"
}
```
### `tm autopilot resume`
Resume a previously started workflow from saved state.
**Example:**
```bash
tm autopilot resume --json
```
### `tm autopilot next`
Get the next action to perform with detailed context.
**JSON Output:**
```json
{
"action": "generate_test",
"actionDescription": "Write a failing test for the current subtask",
"phase": "SUBTASK_LOOP",
"tddPhase": "RED",
"taskId": "7",
"branchName": "task-7",
"progress": {
"completed": 0,
"total": 5,
"current": 1,
"percentage": 0
},
"currentSubtask": {
"id": "1",
"title": "Implement start command",
"status": "in-progress",
"attempts": 0,
"maxAttempts": 3
},
"expectedFiles": ["test file"],
"context": {
"canProceed": false,
"errors": []
}
}
```
### `tm autopilot status`
Get comprehensive workflow progress and state information.
**Example:**
```bash
tm autopilot status --json
```
### `tm autopilot complete`
Complete the current TDD phase with test result validation.
**Options:**
- `--results <json>`: Test results JSON string
**Example:**
```bash
tm autopilot complete --results '{"total":10,"passed":9,"failed":1,"skipped":0}' --json
```
**Validation Rules:**
- **RED Phase**: Must have at least one failing test
- **GREEN Phase**: All tests must pass (failed === 0)
### `tm autopilot commit`
Create a git commit with enhanced message generation.
**Options:**
- `--message <text>`: Custom commit message (optional)
- `--files <paths...>`: Specific files to stage (optional)
**Example:**
```bash
tm autopilot commit --json
```
### `tm autopilot abort`
Abort the workflow and clean up state (preserves git branch and code).
**Example:**
```bash
tm autopilot abort --force --json
```
## MCP Tools
MCP tools provide the same functionality as CLI commands for programmatic integration.
### `autopilot_start`
**Parameters:**
```typescript
{
taskId: string; // Required: Task ID (e.g., "7", "2.3")
projectRoot: string; // Required: Absolute path to project
tag?: string; // Optional: Tag context
maxAttempts?: number; // Optional: Default 3
force?: boolean; // Optional: Default false
}
```
**Returns:**
```typescript
{
success: boolean;
message: string;
taskId: string;
branchName: string;
phase: WorkflowPhase;
tddPhase: TDDPhase;
progress: {
completed: number;
total: number;
percentage: number;
};
currentSubtask: SubtaskInfo | null;
nextAction: string;
}
```
### `autopilot_resume`
**Parameters:**
```typescript
{
projectRoot: string; // Required: Absolute path to project
}
```
### `autopilot_next`
**Parameters:**
```typescript
{
projectRoot: string; // Required: Absolute path to project
}
```
**Returns:**
```typescript
{
action: string; // 'generate_test' | 'implement_code' | 'commit_changes'
actionDescription: string;
phase: WorkflowPhase;
tddPhase: TDDPhase;
taskId: string;
branchName: string;
progress: ProgressInfo;
currentSubtask: SubtaskInfo | null;
expectedFiles: string[];
context: {
canProceed: boolean;
errors: string[];
};
}
```
### `autopilot_status`
**Parameters:**
```typescript
{
projectRoot: string; // Required: Absolute path to project
}
```
**Returns:**
```typescript
{
taskId: string;
branchName: string;
phase: WorkflowPhase;
tddPhase: TDDPhase;
progress: ProgressInfo;
currentSubtask: SubtaskInfo | null;
subtasks: SubtaskInfo[];
errors: string[];
metadata: Record<string, any>;
canProceed: boolean;
}
```
### `autopilot_complete_phase`
**Parameters:**
```typescript
{
projectRoot: string; // Required: Absolute path to project
testResults: {
total: number; // Required: Total tests
passed: number; // Required: Passing tests
failed: number; // Required: Failing tests
skipped?: number; // Optional: Skipped tests
};
}
```
### `autopilot_commit`
**Parameters:**
```typescript
{
projectRoot: string; // Required: Absolute path to project
files?: string[]; // Optional: Files to stage
customMessage?: string; // Optional: Custom commit message
}
```
### `autopilot_abort`
**Parameters:**
```typescript
{
projectRoot: string; // Required: Absolute path to project
}
```
## Workflow Phases
### Phase Diagram
```
PREFLIGHT → BRANCH_SETUP → SUBTASK_LOOP → FINALIZE → COMPLETE
RED → GREEN → COMMIT
↑ ↓
└──────────────┘
(Next Subtask)
```
### Phase Descriptions
**PREFLIGHT**
- Validate task has subtasks
- Check git repository state
- Verify preconditions
**BRANCH_SETUP**
- Create workflow branch: `task-{taskId}`
- Checkout new branch
- Initialize workflow context
**SUBTASK_LOOP**
- **RED Phase**: Write failing tests
- Action: `generate_test`
- Validation: At least one test must fail
- Files: Test files
- **GREEN Phase**: Implement code
- Action: `implement_code`
- Validation: All tests must pass
- Files: Implementation files
- **COMMIT Phase**: Create commit
- Action: `commit_changes`
- Auto-generates commit message
- Advances to next subtask
**FINALIZE**
- All subtasks complete
- Workflow ready for review/merge
**COMPLETE**
- Workflow finished
- State can be cleaned up
## Responsibility Matrix
Clear division of responsibilities between AI Agent and TaskMaster.
| Responsibility | AI Agent | TaskMaster |
|---------------|----------|------------|
| **Workflow Orchestration** | | ✓ |
| Start/resume workflow | Call CLI/MCP | Execute & validate |
| Track workflow state | Read state | Persist state |
| Manage TDD phases | Request transitions | Enforce transitions |
| Validate phase completion | | ✓ (RED must fail, GREEN must pass) |
| **Test Management** | | |
| Write test code | ✓ | |
| Run tests | ✓ | |
| Parse test output | ✓ | |
| Report test results | Provide JSON | Validate results |
| **Implementation** | | |
| Write implementation code | ✓ | |
| Ensure tests pass | ✓ | |
| Follow TDD cycle | ✓ (guided by TaskMaster) | Enforce rules |
| **Git Operations** | | |
| Create workflow branch | Request | ✓ Execute |
| Stage files | Request (optional) | ✓ Execute |
| Generate commit messages | | ✓ |
| Create commits | Request | ✓ Execute |
| **Progress Tracking** | | |
| Query progress | Call status | ✓ Provide data |
| Advance subtasks | | ✓ (automatic on commit) |
| Count attempts | | ✓ |
| Log activity | | ✓ |
### AI Agent Responsibilities
1. **Read and understand subtask requirements**
2. **Write test code** that validates the requirement
3. **Run test suite** using project's test command
4. **Parse test output** into JSON format
5. **Report results** to TaskMaster for validation
6. **Write implementation** to satisfy tests
7. **Request commits** when GREEN phase complete
8. **Handle errors** and retry within attempt limits
### TaskMaster Responsibilities
1. **Manage workflow state machine**
2. **Enforce TDD rules** (RED must fail, GREEN must pass)
3. **Track progress** (completed, current, attempts)
4. **Create git commits** with enhanced messages
5. **Manage git branches** and repository safety
6. **Validate transitions** between phases
7. **Persist state** for resumability
8. **Generate reports** and activity logs
## Examples
### Complete TDD Cycle Example
#### 1. Start Workflow
```bash
$ tm autopilot start 7 --json
```
```json
{
"success": true,
"taskId": "7",
"branchName": "task-7",
"tddPhase": "RED",
"currentSubtask": {
"id": "1",
"title": "Implement start command",
"status": "in-progress"
},
"nextAction": "generate_test"
}
```
#### 2. Get Next Action
```bash
$ tm autopilot next --json
```
```json
{
"action": "generate_test",
"actionDescription": "Write a failing test for the current subtask",
"tddPhase": "RED",
"currentSubtask": {
"id": "1",
"title": "Implement start command"
}
}
```
#### 3. Write Failing Test
AI Agent creates `tests/start.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { StartCommand } from '../src/commands/start.js';
describe('StartCommand', () => {
it('should initialize workflow and create branch', async () => {
const command = new StartCommand();
const result = await command.execute({ taskId: '7' });
expect(result.success).toBe(true);
expect(result.branchName).toBe('task-7');
});
});
```
#### 4. Run Tests (Should Fail)
```bash
$ npm test
# Output: 1 test failed (expected)
```
Parse output to JSON:
```json
{
"total": 1,
"passed": 0,
"failed": 1,
"skipped": 0
}
```
#### 5. Complete RED Phase
```bash
$ tm autopilot complete --results '{"total":1,"passed":0,"failed":1,"skipped":0}' --json
```
```json
{
"success": true,
"message": "Completed RED phase",
"previousPhase": "RED",
"currentPhase": "GREEN",
"nextAction": "implement_code"
}
```
#### 6. Implement Code
AI Agent creates `src/commands/start.ts`:
```typescript
export class StartCommand {
async execute(options: { taskId: string }) {
// Implementation that makes test pass
return {
success: true,
branchName: `task-${options.taskId}`
};
}
}
```
#### 7. Run Tests (Should Pass)
```bash
$ npm test
# Output: 1 test passed
```
Parse output:
```json
{
"total": 1,
"passed": 1,
"failed": 0,
"skipped": 0
}
```
#### 8. Complete GREEN Phase
```bash
$ tm autopilot complete --results '{"total":1,"passed":1,"failed":0,"skipped":0}' --json
```
```json
{
"success": true,
"previousPhase": "GREEN",
"currentPhase": "COMMIT",
"nextAction": "commit_changes"
}
```
#### 9. Commit Changes
```bash
$ tm autopilot commit --json
```
```json
{
"success": true,
"commit": {
"hash": "abc123",
"message": "feat(autopilot): implement start command (Task 7.1)\n\n..."
},
"subtaskCompleted": "1",
"currentSubtask": {
"id": "2",
"title": "Implement resume command"
},
"nextAction": "generate_test"
}
```
### MCP Integration Example
```typescript
// AI Agent using MCP tools
async function implementTask(taskId: string) {
// Start workflow
const start = await mcp.call('autopilot_start', {
taskId,
projectRoot: '/path/to/project'
});
console.log(`Started task ${start.taskId} on branch ${start.branchName}`);
while (true) {
// Get next action
const next = await mcp.call('autopilot_next', {
projectRoot: '/path/to/project'
});
console.log(`Next action: ${next.action}`);
if (next.action === 'generate_test') {
// AI generates test
const testCode = await generateTest(next.currentSubtask);
await writeFile(testCode);
// Run tests
const results = await runTests();
// Complete RED phase
await mcp.call('autopilot_complete_phase', {
projectRoot: '/path/to/project',
testResults: results
});
} else if (next.action === 'implement_code') {
// AI generates implementation
const implCode = await generateImplementation(next.currentSubtask);
await writeFile(implCode);
// Run tests
const results = await runTests();
// Complete GREEN phase
await mcp.call('autopilot_complete_phase', {
projectRoot: '/path/to/project',
testResults: results
});
} else if (next.action === 'commit_changes') {
// Commit
const commit = await mcp.call('autopilot_commit', {
projectRoot: '/path/to/project'
});
console.log(`Committed: ${commit.commit.hash}`);
if (commit.isComplete) {
console.log('Task complete!');
break;
}
}
}
}
```
## Error Handling
### Common Errors and Solutions
#### Workflow Already Exists
**Error:**
```json
{
"error": "Workflow already in progress",
"suggestion": "Use autopilot_resume to continue the existing workflow"
}
```
**Solution:**
```bash
# Resume existing workflow
tm autopilot resume
# OR force start new workflow
tm autopilot start 7 --force
```
#### RED Phase Validation Failed
**Error:**
```json
{
"error": "RED phase validation failed",
"reason": "At least one test must be failing in RED phase",
"actual": { "passed": 10, "failed": 0 },
"suggestion": "Ensure you have written a failing test before proceeding"
}
```
**Solution:** The test isn't actually testing the new feature. Write a test that validates the new behavior that doesn't exist yet.
#### GREEN Phase Validation Failed
**Error:**
```json
{
"error": "GREEN phase validation failed",
"reason": "All tests must pass in GREEN phase",
"actual": { "passed": 9, "failed": 1 },
"suggestion": "Fix the implementation to make all tests pass"
}
```
**Solution:** Implementation isn't complete. Debug failing test and fix implementation.
#### No Staged Changes
**Error:**
```json
{
"error": "No staged changes to commit",
"suggestion": "Make code changes before committing"
}
```
**Solution:** Ensure you've actually created/modified files before committing.
#### Git Working Tree Not Clean
**Error:**
```json
{
"error": "Git validation failed: working tree not clean",
"suggestion": "Commit or stash changes before starting workflow"
}
```
**Solution:**
```bash
git status
git add . && git commit -m "chore: save work"
# Then start workflow
```
### Error Recovery Patterns
#### Retry Pattern
```typescript
async function withRetry<T>(
fn: () => Promise<T>,
maxAttempts: number = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) throw error;
console.log(`Attempt ${attempt} failed, retrying...`);
await sleep(1000 * attempt); // Exponential backoff
}
}
throw new Error('Should not reach here');
}
// Usage
const results = await withRetry(async () => {
const output = await runTests();
return parseTestResults(output);
});
```
#### Graceful Degradation
```typescript
async function completePhase(projectRoot: string, results: TestResults) {
try {
return await mcp.call('autopilot_complete_phase', {
projectRoot,
testResults: results
});
} catch (error) {
console.error('Phase completion failed:', error);
// Log error for debugging
await logError(error);
// Attempt manual recovery
console.log('Attempting manual state recovery...');
const status = await mcp.call('autopilot_status', { projectRoot });
console.log('Current state:', status);
// Provide user guidance
console.log('Manual intervention required:');
console.log('1. Check test results are correct');
console.log('2. Verify current phase allows transition');
console.log('3. Run: tm autopilot status');
throw error;
}
}
```
## Troubleshooting
### Workflow State Issues
**Problem:** State file corrupted or inconsistent
**Solution:**
```bash
# Check state file
cat .taskmaster/workflow-state.json
# If corrupted, abort and restart
tm autopilot abort --force
tm autopilot start 7
```
### Test Results Parsing
**Problem:** Test output format not recognized
**Solution:**
Ensure test results JSON has required fields:
```json
{
"total": 10, // Required
"passed": 8, // Required
"failed": 2, // Required
"skipped": 0 // Optional
}
```
### Branch Conflicts
**Problem:** Workflow branch already exists
**Solution:**
```bash
# Check branches
git branch
# Delete old workflow branch if safe
git branch -D task-7
# Start workflow again
tm autopilot start 7
```
### Permission Issues
**Problem:** Cannot write to .taskmaster directory
**Solution:**
```bash
# Check directory permissions
ls -la .taskmaster/
# Fix permissions
chmod -R u+w .taskmaster/
```
### State Persistence Failures
**Problem:** State not saving between commands
**Solution:**
```bash
# Check file system permissions
ls -la .taskmaster/workflow-state.json
# Verify state is being written
tm autopilot status --json | jq .
# If all else fails, reinstall
rm -rf .taskmaster/
tm init
```
---
## Additional Resources
- [Command Reference](./command-reference.mdx) - Complete CLI command documentation
- [MCP Provider Guide](./mcp-provider-guide.mdx) - MCP integration details
- [Task Structure](./task-structure.mdx) - Understanding TaskMaster's task system
- [Configuration](./configuration.mdx) - Project configuration options
## Support
For issues, questions, or contributions:
- GitHub Issues: https://github.com/eyaltoledano/claude-task-master/issues
- Documentation: https://docs.task-master.dev

View File

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

View File

@@ -0,0 +1,388 @@
---
title: "CLAUDE.md Template"
description: "Ready-to-use CLAUDE.md template for AI agent integration with TDD workflow"
---
This file provides integration instructions for AI agents (like Claude Code) to work with TaskMaster's autonomous TDD workflow system.
## Quick Reference
```bash
# Start workflow
tm autopilot start <taskId>
# Get next action
tm autopilot next --json
# Complete phase with test results
tm autopilot complete --results '{"total":N,"passed":N,"failed":N,"skipped":N}'
# Commit changes
tm autopilot commit
# Check status
tm autopilot status --json
# Abort workflow
tm autopilot abort
```
## Integration Pattern
### 1. Start Task
Before implementing a task:
```bash
tm autopilot start {TASK_ID}
```
This creates a workflow branch and initializes the TDD state machine.
### 2. Follow TDD Cycle
For each subtask, repeat this cycle:
#### RED Phase - Write Failing Test
1. Check next action:
```bash
tm autopilot next --json
```
2. Write a test that **fails** because the feature doesn't exist yet
3. Run tests and report results:
```bash
npm test # or appropriate test command
tm autopilot complete --results '{TEST_RESULTS_JSON}'
```
**Important:** RED phase MUST have at least one failing test.
#### GREEN Phase - Implement Feature
1. Check next action confirms GREEN phase
2. Write minimal implementation to make tests pass
3. Run tests and report results:
```bash
npm test
tm autopilot complete --results '{TEST_RESULTS_JSON}'
```
**Important:** GREEN phase MUST have all tests passing (failed === 0).
#### COMMIT Phase - Save Progress
1. Review changes:
```bash
git status
git diff
```
2. Commit (auto-generates message with metadata):
```bash
tm autopilot commit
```
3. Workflow automatically advances to next subtask
### 3. Monitor Progress
```bash
# Check overall progress
tm autopilot status --json
# See what's next
tm autopilot next --json
```
### 4. Handle Completion
When all subtasks are done:
- Workflow enters COMPLETE phase
- Branch remains for review/merge
- State can be cleaned up
## Example Session
```bash
# Start task with 3 subtasks
$ tm autopilot start 7
✓ Workflow started for task 7
✓ Created branch: task-7
✓ Phase: RED
✓ Next: generate_test for subtask 7.1
# Write failing test for subtask 7.1
$ cat > tests/feature.test.ts
# ... write test ...
$ npm test
# 1 test, 0 passed, 1 failed
$ tm autopilot complete --results '{"total":1,"passed":0,"failed":1,"skipped":0}'
✓ RED phase complete
✓ Phase: GREEN
✓ Next: implement_code
# Write implementation
$ cat > src/feature.ts
# ... write code ...
$ npm test
# 1 test, 1 passed, 0 failed
$ tm autopilot complete --results '{"total":1,"passed":1,"failed":0,"skipped":0}'
✓ GREEN phase complete
✓ Phase: COMMIT
✓ Next: commit_changes
$ tm autopilot commit
✓ Created commit: abc123
✓ Message: feat(feature): implement feature (Task 7.1)
✓ Advanced to subtask 7.2
✓ Phase: RED
✓ Next: generate_test
# Repeat for subtasks 7.2 and 7.3...
```
## Test Result Format
Always provide test results in this JSON format:
```json
{
"total": 10, // Total number of tests
"passed": 8, // Number of passing tests
"failed": 2, // Number of failing tests
"skipped": 0 // Number of skipped tests (optional)
}
```
### Parsing Test Output
Common test frameworks output that needs parsing:
**Vitest:**
```
Test Files 1 passed (1)
Tests 10 passed | 2 failed (12)
```
→ `{"total":12,"passed":10,"failed":2,"skipped":0}`
**Jest:**
```
Tests: 2 failed, 10 passed, 12 total
```
→ `{"total":12,"passed":10,"failed":2,"skipped":0}`
**Mocha:**
```
12 passing
2 failing
```
→ `{"total":14,"passed":12,"failed":2,"skipped":0}`
## Error Handling
### Common Issues
**1. RED Phase Won't Complete**
- Error: "RED phase validation failed: no test failures"
- Solution: Your test isn't actually testing new behavior. Write a test that fails.
**2. GREEN Phase Won't Complete**
- Error: "GREEN phase validation failed: tests still failing"
- Solution: Implementation incomplete. Debug and fix failing tests.
**3. Workflow Already Exists**
- Error: "Workflow already in progress"
- Solution: Run `tm autopilot resume` or `tm autopilot abort --force` then restart
**4. No Staged Changes**
- Error: "No staged changes to commit"
- Solution: Ensure you've actually created/modified files
### Recovery
If workflow gets stuck:
```bash
# Check current state
tm autopilot status --json
# If corrupted, abort and restart
tm autopilot abort --force
tm autopilot start {TASK_ID}
```
## Best Practices
### 1. One Feature Per Test Cycle
Each RED-GREEN-COMMIT cycle should implement exactly one small feature or behavior.
**Good:**
- RED: Test that `getUser()` returns user object
- GREEN: Implement `getUser()` to return user
- COMMIT: One commit for getUser feature
**Bad:**
- RED: Test multiple features at once
- GREEN: Implement entire module
- COMMIT: Massive commit with unrelated changes
### 2. Meaningful Test Names
Tests should clearly describe what they're validating:
```typescript
// Good
it('should return 404 when user not found', async () => {
const result = await getUser('nonexistent');
expect(result.status).toBe(404);
});
// Bad
it('test 1', () => {
// what does this test?
});
```
### 3. Minimal Implementation
In GREEN phase, write just enough code to pass the test:
```typescript
// Good - minimal implementation
function getUser(id: string) {
if (id === 'nonexistent') {
return { status: 404 };
}
return { status: 200, data: users[id] };
}
// Bad - over-engineering
function getUser(id: string) {
// Adds caching, validation, logging, etc. that isn't tested
}
```
### 4. Keep Tests Fast
Fast tests mean fast feedback:
- Avoid network calls (use mocks)
- Avoid file system operations (use in-memory)
- Avoid waiting/sleeping
### 5. Commit Message Quality
Let TaskMaster generate commit messages - they include:
- Conventional commit format (feat, fix, refactor, etc.)
- Subtask context and ID
- Workflow metadata
- Co-authorship attribution
## MCP Integration (Advanced)
For programmatic integration, use MCP tools instead of CLI:
```typescript
import { MCPClient } from '@modelcontextprotocol/sdk';
const client = new MCPClient();
// Start workflow
const start = await client.call('autopilot_start', {
taskId: '7',
projectRoot: '/path/to/project'
});
// Get next action
const next = await client.call('autopilot_next', {
projectRoot: '/path/to/project'
});
// Complete phase
const complete = await client.call('autopilot_complete_phase', {
projectRoot: '/path/to/project',
testResults: { total: 1, passed: 0, failed: 1, skipped: 0 }
});
// Commit
const commit = await client.call('autopilot_commit', {
projectRoot: '/path/to/project'
});
```
See [AI Agent Integration Guide](../ai-agent-integration.mdx) for complete MCP documentation.
## Workflow State Files
TaskMaster persists workflow state to `.taskmaster/workflow-state.json`:
```json
{
"phase": "SUBTASK_LOOP",
"context": {
"taskId": "7",
"subtasks": [...],
"currentSubtaskIndex": 0,
"currentTDDPhase": "RED",
"branchName": "task-7",
"errors": [],
"metadata": {
"startedAt": "2025-01-10T..."
}
}
}
```
**Important:** Never manually edit this file. Use CLI/MCP tools only.
## Project Structure
```
project/
├── .taskmaster/
│ ├── workflow-state.json # Current workflow state
│ ├── tasks/
│ │ └── tasks.json # Task definitions
│ └── docs/
│ └── prd.txt # Product requirements
├── src/ # Implementation files
├── tests/ # Test files
└── package.json
```
## Additional Resources
- [AI Agent Integration Guide](../ai-agent-integration.mdx) - Complete integration documentation
- [Command Reference](../command-reference.mdx) - All CLI commands
- [Task Structure](../task-structure.mdx) - Understanding tasks and subtasks
- [MCP Provider Guide](../mcp-provider-guide.mdx) - MCP integration details
## Troubleshooting
**Q: Workflow won't start**
A: Check that task has subtasks (`tm show <taskId>`) and git working tree is clean
**Q: Can't complete RED phase**
A: Verify at least one test is actually failing (not skipped, not passing)
**Q: Can't complete GREEN phase**
A: Verify ALL tests pass (zero failures)
**Q: Commit fails**
A: Check that you've made changes and they're staged (or stageable)
**Q: State seems wrong**
A: Check `.taskmaster/workflow-state.json` or run `tm autopilot status`
---
**For detailed documentation, see:** [AI Agent Integration Guide](../ai-agent-integration.mdx)

View File

@@ -0,0 +1,478 @@
---
title: "Example Prompts"
description: "Collection of effective prompts for AI agents working with TaskMaster's TDD workflow system"
---
Collection of effective prompts for AI agents working with TaskMaster's TDD workflow system.
## Getting Started Prompts
### Start a Task
```
I want to implement Task 7 using TDD workflow. Please:
1. Start the autopilot workflow
2. Show me the first subtask to implement
3. Begin the RED-GREEN-COMMIT cycle
```
### Resume Work
```
I have an in-progress workflow. Please:
1. Resume the autopilot workflow
2. Show current status and progress
3. Continue from where we left off
```
### Understanding Current State
```
What's the current state of the workflow? Please show:
- Which subtask we're on
- Current TDD phase (RED/GREEN/COMMIT)
- Progress percentage
- Next action required
```
## Test Generation Prompts
### Basic Test Generation
```
We're in RED phase for subtask "{SUBTASK_TITLE}". Please:
1. Read the subtask requirements
2. Write a comprehensive test that validates the behavior
3. The test MUST fail because the feature doesn't exist yet
4. Use the project's testing framework (vitest/jest/etc)
5. Follow the project's test file conventions
```
### Test for Specific Feature
```
For subtask: "Implement user authentication endpoint"
Write a failing test that:
1. Tests POST /api/auth/login
2. Validates request body (email, password)
3. Checks response format and status codes
4. Uses proper mocking for database calls
5. Follows security best practices
```
### Edge Case Testing
```
The basic happy path test is passing. Now write additional tests for:
1. Error cases (invalid input, missing fields)
2. Edge cases (empty strings, null values, etc.)
3. Security concerns (SQL injection, XSS)
4. Performance expectations (timeout, rate limits)
Each test should initially fail.
```
### Test Refactoring
```
Our tests are passing but could be improved. Please:
1. Review existing tests for duplication
2. Extract common setup into beforeEach/fixtures
3. Improve test descriptions for clarity
4. Add missing edge cases
5. Ensure all new tests fail first (RED phase)
```
## Implementation Prompts
### Basic Implementation
```
We're in GREEN phase. The test is failing with: {ERROR_MESSAGE}
Please:
1. Implement the minimal code to make this test pass
2. Don't over-engineer or add features not tested
3. Follow the project's code style and patterns
4. Ensure the implementation is clean and readable
```
### Implementation with Constraints
```
Implement the feature to pass the test, but:
- Use TypeScript with strict type checking
- Follow SOLID principles
- Keep functions under 20 lines
- Use dependency injection where appropriate
- Add JSDoc comments for public APIs
```
### Fix Failing Tests
```
GREEN phase validation failed - {N} tests still failing.
Please:
1. Review the failing test output
2. Identify what's not working
3. Fix the implementation to pass all tests
4. Don't modify tests to make them pass
5. Explain what was wrong
```
### Refactor Implementation
```
Tests are passing but code quality needs improvement:
1. Extract repeated logic into functions
2. Improve variable names
3. Add error handling
4. Optimize performance if needed
5. Ensure tests still pass after refactoring
```
## Debugging Prompts
### Test Output Parsing
```
Here's the test output:
{PASTE_TEST_OUTPUT}
Please parse this into the required JSON format:
{
"total": N,
"passed": N,
"failed": N,
"skipped": N
}
Then complete the current phase.
```
### Workflow Stuck
```
The workflow seems stuck. Please:
1. Check the current workflow status
2. Identify the issue
3. If corrupted, abort and restart
4. Explain what went wrong and how to prevent it
```
### Phase Validation Failing
```
I'm getting: "RED phase validation failed: no test failures"
Please:
1. Review the test I just wrote
2. Identify why it's not actually testing new behavior
3. Rewrite the test to properly fail
4. Explain what makes a good failing test
```
### Git Issues
```
Getting git errors when trying to start workflow:
{PASTE_ERROR}
Please:
1. Diagnose the git issue
2. Provide commands to fix it
3. Restart the workflow once fixed
```
## Advanced Patterns
### Parallel Test Generation
```
We have 3 subtasks to implement. For efficiency:
1. Read all 3 subtask descriptions
2. Plan the test structure for each
3. Identify shared test utilities needed
4. Generate tests for subtask 1 (they should fail)
5. Once we complete 1, move to 2, then 3
```
### Integration Test Strategy
```
This subtask requires integration testing. Please:
1. Set up test database/environment
2. Write integration tests that exercise the full stack
3. Use proper cleanup in afterEach
4. Mock external services (APIs, third-party)
5. Ensure tests are deterministic and fast
```
### Test-Driven Refactoring
```
We need to refactor {MODULE_NAME} but keep behavior unchanged:
1. First, write comprehensive tests for current behavior
2. Ensure all tests pass (document current state)
3. Refactor the implementation
4. Verify all tests still pass
5. Commit the refactoring
```
### Complex Feature Implementation
```
Subtask: "{COMPLEX_SUBTASK}"
This is complex. Let's break it down:
1. Identify the core functionality to test
2. Write tests for the simplest version
3. Implement minimal working code
4. Commit that cycle
5. Then iteratively add more tests for additional features
6. Each iteration is a RED-GREEN-COMMIT cycle
```
### Performance Testing
```
Write performance tests for {FEATURE}:
1. Measure baseline performance (current state)
2. Write test that fails if operation takes > {N}ms
3. Implement optimizations to pass the test
4. Document performance improvements
5. Consider edge cases (large inputs, concurrent requests)
```
### Security Testing
```
Write security-focused tests for {FEATURE}:
1. Test input validation (injection attacks)
2. Test authentication/authorization
3. Test data sanitization
4. Test rate limiting
5. Document security assumptions
Each test should initially fail and demonstrate the vulnerability.
```
## Workflow Automation Patterns
### Full Autonomous Mode
```
Implement Task {TASK_ID} completely autonomously:
1. Start the workflow
2. For each subtask:
a. Read requirements
b. Write failing tests
c. Implement to pass tests
d. Commit changes
3. Continue until all subtasks complete
4. Report final status
Rules:
- Never skip the RED phase
- Always verify tests fail first
- Implement minimal working code
- Commit after each subtask
- Handle errors gracefully with retries
```
### Supervised Mode
```
Work on Task {TASK_ID} with human oversight:
1. Start workflow and show plan
2. For each subtask:
a. Show test plan, wait for approval
b. Write and run tests, show results
c. Show implementation plan, wait for approval
d. Implement and verify
e. Show commit message, wait for approval
3. Request feedback between subtasks
```
### Review Mode
```
Review the current workflow state:
1. Show all completed subtasks and their commits
2. Identify remaining subtasks
3. Check test coverage
4. Verify git history is clean
5. Recommend next steps
```
## Error Recovery Patterns
### Retry with Learning
```
The {PHASE} phase failed {N} times. Please:
1. Review all previous attempts
2. Identify the pattern of failures
3. Propose a different approach
4. Explain why this approach should work
5. Implement with the new approach
```
### Escalate to Human
```
After {MAX_ATTEMPTS} attempts, unable to complete {SUBTASK}.
Please:
1. Document what was tried
2. Explain what's not working
3. Provide relevant code and test output
4. Suggest where human expertise is needed
5. Save current state for manual intervention
```
### Reset and Restart
```
Workflow is in an inconsistent state. Please:
1. Save any valuable work
2. Abort the current workflow
3. Explain what went wrong
4. Propose a better approach
5. Restart with improved strategy
```
## Example Complete Session
```
I need to implement Task 7 which has 5 subtasks. Please work autonomously with these preferences:
1. Testing Framework: vitest
2. Code Style: TypeScript strict mode, functional style preferred
3. Commit Style: Conventional commits with detailed messages
4. Review: Show me status after each subtask completion
Workflow:
1. Start autopilot for task 7
2. For each subtask (7.1 through 7.5):
- RED: Write comprehensive failing tests
- GREEN: Implement minimal code to pass
- COMMIT: Auto-generate commit and advance
3. Final: Show summary of all commits and changes
Error Handling:
- If phase validation fails, explain why and retry
- If tests are flaky, identify and fix
- If stuck after 3 attempts, ask for help
Let's begin!
```
## Tips for Effective Prompts
### 1. Be Specific About Context
**Good:**
```
For the UserAuthenticationService in src/services/auth.ts,
write tests for the login method using vitest.
```
**Bad:**
```
Write tests for authentication.
```
### 2. Specify Success Criteria
**Good:**
```
Tests should cover:
1. Successful login with valid credentials
2. Failed login with invalid password
3. Account lockout after 5 failures
4. Rate limiting (max 10 attempts/minute)
```
**Bad:**
```
Test login functionality.
```
### 3. Request Explanations
**Good:**
```
Implement the feature and explain:
1. Why this approach was chosen
2. What edge cases are handled
3. What assumptions were made
```
**Bad:**
```
Just implement it.
```
### 4. Include Project Context
**Good:**
```
Following the existing pattern in src/models/,
create a User model that:
- Extends BaseModel
- Uses Zod for validation
- Includes proper TypeScript types
```
**Bad:**
```
Create a user model.
```
## Troubleshooting Prompts
### When Tests Won't Fail
```
My test is passing when it should fail. Please:
1. Review the test code
2. Identify why it's passing
3. Check if implementation already exists
4. Rewrite test to actually test new behavior
5. Verify it fails this time
```
### When Implementation is Incomplete
```
Tests are still failing after implementation. Please:
1. Show me the failing test output
2. Review the implementation
3. Identify what's missing
4. Fix the implementation
5. Verify all tests pass
```
### When Workflow Won't Advance
```
Can't complete the phase. Getting error: {ERROR}
Please:
1. Check workflow status
2. Verify test results format is correct
3. Check if phase validation requirements are met
4. If needed, show me how to manually fix state
```
---
## Additional Resources
- [AI Agent Integration Guide](../ai-agent-integration.mdx)
- [CLAUDE.md Template](./CLAUDE.md.template)
- [Command Reference](../command-reference.mdx)
- [Testing Best Practices](./testing-best-practices.mdx)

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -7,6 +7,9 @@ import logger from './src/logger.js';
// Load environment variables
dotenv.config();
// Set MCP mode to silence tm-core console output
process.env.TASK_MASTER_MCP = 'true';
/**
* Start the MCP server
*/

View File

@@ -40,8 +40,20 @@ import { registerRulesTool } from './rules.js';
import { registerScopeUpTool } from './scope-up.js';
import { registerScopeDownTool } from './scope-down.js';
// Import TypeScript autopilot tools from apps/mcp
import {
registerAutopilotStartTool,
registerAutopilotResumeTool,
registerAutopilotNextTool,
registerAutopilotStatusTool,
registerAutopilotCompleteTool,
registerAutopilotCommitTool,
registerAutopilotFinalizeTool,
registerAutopilotAbortTool
} from '@tm/mcp';
/**
* Comprehensive tool registry mapping all 36 tool names to their registration functions
* Comprehensive tool registry mapping all 44 tool names to their registration functions
* Used for dynamic tool registration and validation
*/
export const toolRegistry = {
@@ -80,7 +92,15 @@ export const toolRegistry = {
use_tag: registerUseTagTool,
rename_tag: registerRenameTagTool,
copy_tag: registerCopyTagTool,
research: registerResearchTool
research: registerResearchTool,
autopilot_start: registerAutopilotStartTool,
autopilot_resume: registerAutopilotResumeTool,
autopilot_next: registerAutopilotNextTool,
autopilot_status: registerAutopilotStatusTool,
autopilot_complete: registerAutopilotCompleteTool,
autopilot_commit: registerAutopilotCommitTool,
autopilot_finalize: registerAutopilotFinalizeTool,
autopilot_abort: registerAutopilotAbortTool
};
/**

1198
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -86,6 +86,7 @@
"express": "^4.21.2",
"fastmcp": "^3.5.0",
"figlet": "^1.8.0",
"fs-extra": "^11.3.0",
"fuse.js": "^7.1.0",
"gpt-tokens": "^1.3.14",
"gradient-string": "^3.0.0",
@@ -98,7 +99,9 @@
"marked": "^15.0.12",
"marked-terminal": "^7.3.0",
"ollama-ai-provider-v2": "^1.3.1",
"open": "^10.2.0",
"ora": "^8.2.0",
"simple-git": "^3.28.0",
"uuid": "^11.1.0",
"zod": "^4.1.11"
},

View File

@@ -31,11 +31,15 @@
},
"dependencies": {
"@supabase/supabase-js": "^2.57.4",
"fs-extra": "^11.3.2",
"simple-git": "^3.28.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.10.5",
"@vitest/coverage-v8": "^3.2.4",
"strip-literal": "^3.1.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
"strip-literal": "3.1.0"

View File

@@ -25,10 +25,13 @@ import { getLogger } from '../logger/index.js';
*/
export class AuthManager {
private static instance: AuthManager | null = null;
private static readonly staticLogger = getLogger('AuthManager');
private credentialStore: CredentialStore;
private oauthService: OAuthService;
private supabaseClient: SupabaseAuthClient;
private organizationService?: OrganizationService;
private readonly logger = getLogger('AuthManager');
private refreshPromise: Promise<AuthCredentials> | null = null;
private constructor(config?: Partial<AuthConfig>) {
this.credentialStore = CredentialStore.getInstance(config);
@@ -50,8 +53,7 @@ export class AuthManager {
await this.supabaseClient.initialize();
} catch (error) {
// Log but don't throw - session might not exist yet
const logger = getLogger('AuthManager');
logger.debug('No existing session to restore');
this.logger.debug('No existing session to restore');
}
}
@@ -63,8 +65,7 @@ export class AuthManager {
AuthManager.instance = new AuthManager(config);
} else if (config) {
// Warn if config is provided after initialization
const logger = getLogger('AuthManager');
logger.warn(
AuthManager.staticLogger.warn(
'getInstance called with config after initialization; config is ignored.'
);
}
@@ -159,7 +160,7 @@ export class AuthManager {
await this.supabaseClient.signOut();
} catch (error) {
// Log but don't throw - we still want to clear local credentials
getLogger('AuthManager').warn('Failed to sign out from Supabase:', error);
this.logger.warn('Failed to sign out from Supabase:', error);
}
// Always clear local credentials (removes auth.json file)

View File

@@ -27,6 +27,7 @@ export type {
ProviderConfig,
TaskSettings,
TagSettings,
WorkflowSettings,
StorageSettings,
RetrySettings,
LoggingSettings,

View File

@@ -38,6 +38,35 @@ export class ConfigLoader {
main: DEFAULT_CONFIG_VALUES.MODELS.MAIN,
fallback: DEFAULT_CONFIG_VALUES.MODELS.FALLBACK
},
workflow: {
enableAutopilot: DEFAULT_CONFIG_VALUES.WORKFLOW.ENABLE_AUTOPILOT,
maxPhaseAttempts: DEFAULT_CONFIG_VALUES.WORKFLOW.MAX_PHASE_ATTEMPTS,
branchPattern: DEFAULT_CONFIG_VALUES.WORKFLOW.BRANCH_PATTERN,
requireCleanWorkingTree:
DEFAULT_CONFIG_VALUES.WORKFLOW.REQUIRE_CLEAN_WORKING_TREE,
autoStageChanges: DEFAULT_CONFIG_VALUES.WORKFLOW.AUTO_STAGE_CHANGES,
includeCoAuthor: DEFAULT_CONFIG_VALUES.WORKFLOW.INCLUDE_CO_AUTHOR,
coAuthorName: DEFAULT_CONFIG_VALUES.WORKFLOW.CO_AUTHOR_NAME,
coAuthorEmail: DEFAULT_CONFIG_VALUES.WORKFLOW.CO_AUTHOR_EMAIL,
testThresholds: {
minTests: DEFAULT_CONFIG_VALUES.WORKFLOW.MIN_TESTS,
maxFailuresInGreen:
DEFAULT_CONFIG_VALUES.WORKFLOW.MAX_FAILURES_IN_GREEN
},
commitMessageTemplate:
DEFAULT_CONFIG_VALUES.WORKFLOW.COMMIT_MESSAGE_TEMPLATE,
allowedCommitTypes: [
...DEFAULT_CONFIG_VALUES.WORKFLOW.ALLOWED_COMMIT_TYPES
],
defaultCommitType: DEFAULT_CONFIG_VALUES.WORKFLOW.DEFAULT_COMMIT_TYPE,
operationTimeout: DEFAULT_CONFIG_VALUES.WORKFLOW.OPERATION_TIMEOUT,
enableActivityLogging:
DEFAULT_CONFIG_VALUES.WORKFLOW.ENABLE_ACTIVITY_LOGGING,
activityLogPath: DEFAULT_CONFIG_VALUES.WORKFLOW.ACTIVITY_LOG_PATH,
enableStateBackup: DEFAULT_CONFIG_VALUES.WORKFLOW.ENABLE_STATE_BACKUP,
maxStateBackups: DEFAULT_CONFIG_VALUES.WORKFLOW.MAX_STATE_BACKUPS,
abortOnMaxAttempts: DEFAULT_CONFIG_VALUES.WORKFLOW.ABORT_ON_MAX_ATTEMPTS
},
storage: {
type: DEFAULT_CONFIG_VALUES.STORAGE.TYPE,
encoding: DEFAULT_CONFIG_VALUES.STORAGE.ENCODING,

View File

@@ -10,6 +10,7 @@ import {
ERROR_CODES,
TaskMasterError
} from '../../errors/task-master-error.js';
import { getLogger } from '../../logger/index.js';
/**
* Persistence options
@@ -30,6 +31,7 @@ export interface PersistenceOptions {
export class ConfigPersistence {
private localConfigPath: string;
private backupDir: string;
private readonly logger = getLogger('ConfigPersistence');
constructor(projectRoot: string) {
this.localConfigPath = path.join(projectRoot, '.taskmaster', 'config.json');
@@ -94,7 +96,7 @@ export class ConfigPersistence {
return backupPath;
} catch (error) {
console.warn('Failed to create backup:', error);
this.logger.warn('Failed to create backup:', error);
throw error;
}
}
@@ -116,7 +118,7 @@ export class ConfigPersistence {
await fs.unlink(path.join(this.backupDir, file));
}
} catch (error) {
console.warn('Failed to clean old backups:', error);
this.logger.warn('Failed to clean old backups:', error);
}
}

View File

@@ -4,6 +4,7 @@
*/
import type { PartialConfiguration } from '../../interfaces/configuration.interface.js';
import { getLogger } from '../../logger/index.js';
/**
* Environment variable mapping definition
@@ -24,6 +25,8 @@ interface EnvMapping {
* Single responsibility: Environment variable configuration extraction
*/
export class EnvironmentConfigProvider {
private readonly logger = getLogger('EnvironmentConfigProvider');
/**
* Default environment variable mappings
*/
@@ -75,7 +78,7 @@ export class EnvironmentConfigProvider {
// Validate value if validator is provided
if (mapping.validate && !mapping.validate(value)) {
console.warn(`Invalid value for ${mapping.env}: ${value}`);
this.logger.warn(`Invalid value for ${mapping.env}: ${value}`);
continue;
}

View File

@@ -10,6 +10,7 @@ import {
TaskMasterError
} from '../../errors/task-master-error.js';
import { DEFAULT_CONFIG_VALUES } from '../../interfaces/configuration.interface.js';
import { getLogger } from '../../logger/index.js';
/**
* Runtime state data structure
@@ -30,6 +31,7 @@ export interface RuntimeState {
export class RuntimeStateManager {
private stateFilePath: string;
private currentState: RuntimeState;
private readonly logger = getLogger('RuntimeStateManager');
constructor(projectRoot: string) {
this.stateFilePath = path.join(projectRoot, '.taskmaster', 'state.json');
@@ -66,7 +68,7 @@ export class RuntimeStateManager {
} catch (error: any) {
if (error.code === 'ENOENT') {
// State file doesn't exist, use defaults
console.debug('No state.json found, using default state');
this.logger.debug('No state.json found, using default state');
// Check environment variable
if (process.env.TASKMASTER_TAG) {
@@ -76,7 +78,8 @@ export class RuntimeStateManager {
return this.currentState;
}
console.warn('Failed to load state file:', error.message);
// Failed to load, use defaults
this.logger.warn('Failed to load state file:', error.message);
return this.currentState;
}
}

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import {
generateBranchName,
sanitizeBranchName
} from './branch-name-generator.js';
describe('Branch Name Generator', () => {
describe('sanitizeBranchName', () => {
it('should remove invalid characters', () => {
const result = sanitizeBranchName('feature/my feature!');
expect(result).toBe('feature-my-feature');
});
it('should replace spaces with hyphens', () => {
const result = sanitizeBranchName('my feature branch');
expect(result).toBe('my-feature-branch');
});
it('should convert to lowercase', () => {
const result = sanitizeBranchName('MyFeature');
expect(result).toBe('myfeature');
});
it('should remove consecutive hyphens', () => {
const result = sanitizeBranchName('my---feature');
expect(result).toBe('my-feature');
});
it('should handle empty string', () => {
const result = sanitizeBranchName('');
expect(result).toBe('branch');
});
});
describe('generateBranchName', () => {
it('should generate branch name from task ID', () => {
const result = generateBranchName({ taskId: '2.7' });
expect(result).toMatch(/^task-2-7-/);
});
it('should include description in branch name', () => {
const result = generateBranchName({
taskId: '2.7',
description: 'Add Feature'
});
expect(result).toContain('task-2-7');
expect(result).toContain('add-feature');
});
it('should handle custom pattern', () => {
const result = generateBranchName({
taskId: '2.7',
pattern: 'feature/{taskId}'
});
expect(result).toBe('feature-2-7');
});
it('should truncate long descriptions', () => {
const longDesc = 'a'.repeat(100);
const result = generateBranchName({
taskId: '2.7',
description: longDesc
});
expect(result.length).toBeLessThan(80);
});
});
});

View File

@@ -0,0 +1,69 @@
/**
* Branch Name Generator - Generates valid git branch names from patterns
* @module branch-name-generator
*/
/**
* Sanitizes a string to be a valid git branch name.
* Removes invalid characters, converts to lowercase, replaces spaces with hyphens.
*
* @param {string} name - Name to sanitize
* @returns {string} Sanitized branch name
*/
export function sanitizeBranchName(name: string): string {
if (!name || name.trim() === '') {
return 'branch';
}
return name
.toLowerCase()
.replace(/[^a-z0-9-_.\/]/g, '-') // Replace invalid chars with hyphens
.replace(/\//g, '-') // Replace slashes with hyphens
.replace(/-+/g, '-') // Remove consecutive hyphens
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
}
/**
* Generates a branch name from a pattern and variables.
*
* @param {Object} options - Generation options
* @param {string} options.taskId - Task ID to include
* @param {string} [options.description] - Description to include
* @param {string} [options.pattern] - Custom pattern (default: 'task-{taskId}-{description}')
* @param {number} [options.maxLength=50] - Maximum branch name length
* @returns {string} Generated branch name
*/
export function generateBranchName(options: {
taskId: string;
description?: string;
pattern?: string;
maxLength?: number;
}): string {
const maxLength = options.maxLength || 50;
const pattern = options.pattern || 'task-{taskId}-{description}';
// Sanitize task ID (replace dots with hyphens)
const sanitizedTaskId = sanitizeBranchName(
options.taskId.replace(/\./g, '-')
);
// Sanitize description if provided
const sanitizedDescription = options.description
? sanitizeBranchName(options.description)
: sanitizeBranchName(Date.now().toString());
// Replace pattern variables
let branchName = pattern
.replace(/{taskId}/g, sanitizedTaskId)
.replace(/{description}/g, sanitizedDescription);
// Sanitize the final result
branchName = sanitizeBranchName(branchName);
// Truncate if too long
if (branchName.length > maxLength) {
branchName = branchName.substring(0, maxLength).replace(/-+$/, '');
}
return branchName;
}

View File

@@ -0,0 +1,319 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CommitMessageGenerator } from './commit-message-generator.js';
describe('CommitMessageGenerator', () => {
let generator: CommitMessageGenerator;
beforeEach(() => {
generator = new CommitMessageGenerator();
});
describe('generateMessage', () => {
it('should generate basic conventional commit message', () => {
const message = generator.generateMessage({
type: 'feat',
description: 'add user authentication',
changedFiles: ['packages/tm-core/src/auth/auth-manager.ts']
});
expect(message).toContain('feat(core): add user authentication');
});
it('should include scope from changed files', () => {
const message = generator.generateMessage({
type: 'fix',
description: 'resolve CLI argument parsing',
changedFiles: ['packages/cli/src/commands/start.ts']
});
expect(message).toContain('fix(cli): resolve CLI argument parsing');
});
it('should include task metadata in commit body', () => {
const message = generator.generateMessage({
type: 'feat',
description: 'implement feature',
changedFiles: ['packages/tm-core/src/index.ts'],
taskId: '5.3',
phase: 'GREEN'
});
expect(message).toContain('Task: 5.3');
expect(message).toContain('Phase: GREEN');
});
it('should include test results metadata', () => {
const message = generator.generateMessage({
type: 'test',
description: 'add unit tests',
changedFiles: ['packages/tm-core/src/auth/auth.test.ts'],
testsPassing: 42,
testsFailing: 0
});
expect(message).toContain('Tests: 42 passing');
});
it('should include failing test count when present', () => {
const message = generator.generateMessage({
type: 'fix',
description: 'fix test failures',
changedFiles: ['packages/tm-core/src/index.ts'],
testsPassing: 40,
testsFailing: 2
});
expect(message).toContain('Tests: 40 passing, 2 failing');
});
it('should include custom body text', () => {
const message = generator.generateMessage({
type: 'feat',
description: 'add new feature',
changedFiles: ['packages/tm-core/src/index.ts'],
body: 'This is a detailed explanation\nof the changes made.'
});
expect(message).toContain('This is a detailed explanation');
expect(message).toContain('of the changes made.');
});
it('should handle multiple changed files with different scopes', () => {
const message = generator.generateMessage({
type: 'refactor',
description: 'reorganize code structure',
changedFiles: [
'packages/cli/src/index.ts',
'packages/tm-core/src/index.ts'
]
});
// Should use CLI scope (higher priority due to count or priority)
expect(message).toMatch(/refactor\((cli|core)\):/);
});
it('should handle test files and detect test scope', () => {
const message = generator.generateMessage({
type: 'test',
description: 'add integration tests',
changedFiles: ['packages/tm-core/src/workflow/workflow.test.ts']
});
expect(message).toContain('test(test):');
});
it('should handle docs changes', () => {
const message = generator.generateMessage({
type: 'docs',
description: 'update README',
changedFiles: ['README.md', 'docs/guide.md']
});
expect(message).toContain('docs(docs):');
});
it('should omit scope if not detected', () => {
const message = generator.generateMessage({
type: 'chore',
description: 'update dependencies',
changedFiles: []
});
expect(message).toContain('chore(repo): update dependencies');
});
it('should support manual scope override', () => {
const message = generator.generateMessage({
type: 'feat',
description: 'add feature',
changedFiles: ['packages/tm-core/src/index.ts'],
scope: 'api'
});
expect(message).toContain('feat(api): add feature');
});
it('should handle breaking changes indicator', () => {
const message = generator.generateMessage({
type: 'feat',
description: 'change API structure',
changedFiles: ['packages/tm-core/src/index.ts'],
breaking: true
});
expect(message).toContain('feat(core)!: change API structure');
});
it('should format complete message with all metadata', () => {
const message = generator.generateMessage({
type: 'feat',
description: 'implement TDD workflow',
changedFiles: ['packages/tm-core/src/workflow/orchestrator.ts'],
body: 'Implemented complete RED-GREEN-COMMIT cycle with state persistence.',
taskId: '4.1',
phase: 'GREEN',
testsPassing: 74,
testsFailing: 0
});
expect(message).toContain('feat(core): implement TDD workflow');
expect(message).toContain('Implemented complete RED-GREEN-COMMIT cycle');
expect(message).toContain('Task: 4.1');
expect(message).toContain('Phase: GREEN');
expect(message).toContain('Tests: 74 passing');
});
});
describe('validateConventionalCommit', () => {
it('should validate correct conventional commit format', () => {
const message = 'feat(core): add feature\n\nDetails here.';
const result = generator.validateConventionalCommit(message);
expect(result.isValid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should detect missing type', () => {
const message = 'add feature';
const result = generator.validateConventionalCommit(message);
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain('Invalid conventional commit format');
});
it('should detect invalid type', () => {
const message = 'invalid(core): add feature';
const result = generator.validateConventionalCommit(message);
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it('should detect missing description', () => {
const message = 'feat(core):';
const result = generator.validateConventionalCommit(message);
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain('Invalid conventional commit format');
});
it('should accept valid types', () => {
const validTypes = [
'feat',
'fix',
'docs',
'style',
'refactor',
'test',
'chore'
];
for (const type of validTypes) {
const message = `${type}(core): do something`;
const result = generator.validateConventionalCommit(message);
expect(result.isValid).toBe(true);
}
});
it('should accept breaking change indicator', () => {
const message = 'feat(core)!: breaking change';
const result = generator.validateConventionalCommit(message);
expect(result.isValid).toBe(true);
});
it('should accept message without scope', () => {
const message = 'fix: resolve issue';
const result = generator.validateConventionalCommit(message);
expect(result.isValid).toBe(true);
});
});
describe('parseCommitMessage', () => {
it('should parse conventional commit message', () => {
const message = 'feat(core): add feature\n\nDetailed explanation.';
const parsed = generator.parseCommitMessage(message);
expect(parsed.type).toBe('feat');
expect(parsed.scope).toBe('core');
expect(parsed.description).toBe('add feature');
expect(parsed.body).toContain('Detailed explanation.');
expect(parsed.breaking).toBe(false);
});
it('should parse breaking change indicator', () => {
const message = 'feat(core)!: breaking change';
const parsed = generator.parseCommitMessage(message);
expect(parsed.type).toBe('feat');
expect(parsed.breaking).toBe(true);
});
it('should parse message without scope', () => {
const message = 'fix: resolve issue';
const parsed = generator.parseCommitMessage(message);
expect(parsed.type).toBe('fix');
expect(parsed.scope).toBeUndefined();
expect(parsed.description).toBe('resolve issue');
});
it('should handle multiline body', () => {
const message = 'feat: add feature\n\nLine 1\nLine 2\nLine 3';
const parsed = generator.parseCommitMessage(message);
expect(parsed.body).toContain('Line 1');
expect(parsed.body).toContain('Line 2');
expect(parsed.body).toContain('Line 3');
});
});
describe('edge cases', () => {
it('should handle empty changed files list', () => {
const message = generator.generateMessage({
type: 'chore',
description: 'general maintenance',
changedFiles: []
});
expect(message).toContain('chore(repo):');
});
it('should handle very long description', () => {
const longDesc = 'a'.repeat(200);
const message = generator.generateMessage({
type: 'feat',
description: longDesc,
changedFiles: ['packages/tm-core/src/index.ts']
});
expect(message).toContain(longDesc);
});
it('should handle special characters in description', () => {
const message = generator.generateMessage({
type: 'fix',
description: 'resolve issue with $special @characters #123',
changedFiles: ['packages/tm-core/src/index.ts']
});
expect(message).toContain('$special @characters #123');
});
it('should handle zero passing tests', () => {
const message = generator.generateMessage({
type: 'test',
description: 'add failing test',
changedFiles: ['test.ts'],
testsPassing: 0,
testsFailing: 1
});
expect(message).toContain('Tests: 0 passing, 1 failing');
});
});
});

View File

@@ -0,0 +1,205 @@
/**
* CommitMessageGenerator - Generate conventional commit messages with metadata
*
* Combines TemplateEngine and ScopeDetector to create structured commit messages
* that follow conventional commits specification and include task metadata.
*/
import { TemplateEngine } from './template-engine.js';
import { ScopeDetector } from './scope-detector.js';
export interface CommitMessageOptions {
type: string;
description: string;
changedFiles: string[];
scope?: string;
body?: string;
breaking?: boolean;
taskId?: string;
phase?: string;
tag?: string;
testsPassing?: number;
testsFailing?: number;
coveragePercent?: number;
}
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
export interface ParsedCommitMessage {
type: string;
scope?: string;
breaking: boolean;
description: string;
body?: string;
}
const CONVENTIONAL_COMMIT_TYPES = [
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'build',
'ci',
'chore',
'revert'
];
export class CommitMessageGenerator {
private templateEngine: TemplateEngine;
private scopeDetector: ScopeDetector;
constructor(
customTemplates?: Record<string, string>,
customScopeMappings?: Record<string, string>,
customScopePriorities?: Record<string, number>
) {
this.templateEngine = new TemplateEngine(customTemplates);
this.scopeDetector = new ScopeDetector(
customScopeMappings,
customScopePriorities
);
}
/**
* Generate a conventional commit message with metadata
*/
generateMessage(options: CommitMessageOptions): string {
const {
type,
description,
changedFiles,
scope: manualScope,
body,
breaking = false,
taskId,
phase,
tag,
testsPassing,
testsFailing,
coveragePercent
} = options;
// Determine scope (manual override or auto-detect)
const scope = manualScope ?? this.scopeDetector.detectScope(changedFiles);
// Build template variables
const variables = {
type,
scope,
breaking: breaking ? '!' : '',
description,
body,
taskId,
phase,
tag,
testsPassing,
testsFailing,
coveragePercent
};
// Generate message from template
return this.templateEngine.render('commitMessage', variables);
}
/**
* Validate that a commit message follows conventional commits format
*/
validateConventionalCommit(message: string): ValidationResult {
const errors: string[] = [];
// Parse first line (header)
const lines = message.split('\n');
const header = lines[0];
if (!header) {
errors.push('Missing commit message');
return { isValid: false, errors };
}
// Check format: type(scope)?: description
const headerRegex = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
const match = header.match(headerRegex);
if (!match) {
errors.push(
'Invalid conventional commit format. Expected: type(scope): description'
);
return { isValid: false, errors };
}
const [, type, , , description] = match;
// Validate type
if (!CONVENTIONAL_COMMIT_TYPES.includes(type)) {
errors.push(
`Invalid commit type "${type}". Must be one of: ${CONVENTIONAL_COMMIT_TYPES.join(', ')}`
);
}
// Validate description
if (!description || description.trim().length === 0) {
errors.push('Missing description');
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* Parse a conventional commit message into its components
*/
parseCommitMessage(message: string): ParsedCommitMessage {
const lines = message.split('\n');
const header = lines[0];
// Parse header: type(scope)!: description
const headerRegex = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
const match = header.match(headerRegex);
if (!match) {
throw new Error('Invalid conventional commit format');
}
const [, type, scope, breaking, description] = match;
// Body is everything after the first blank line
const bodyStartIndex = lines.findIndex((line, i) => i > 0 && line === '');
const body =
bodyStartIndex !== -1
? lines
.slice(bodyStartIndex + 1)
.join('\n')
.trim()
: undefined;
return {
type,
scope,
breaking: breaking === '!',
description,
body
};
}
/**
* Get the scope detector instance (for testing/customization)
*/
getScopeDetector(): ScopeDetector {
return this.scopeDetector;
}
/**
* Get the template engine instance (for testing/customization)
*/
getTemplateEngine(): TemplateEngine {
return this.templateEngine;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,780 @@
/**
* GitAdapter - Safe git operations wrapper with validation and safety checks.
* Handles all git operations (branching, committing, pushing) with built-in safety gates.
*
* @module git-adapter
*/
import { simpleGit, type SimpleGit } from 'simple-git';
import fs from 'fs-extra';
import path from 'path';
/**
* GitAdapter class for safe git operations
*/
export class GitAdapter {
public projectPath: string;
public git: SimpleGit;
/**
* Creates a new GitAdapter instance.
*
* @param {string} projectPath - Absolute path to the project directory
* @throws {Error} If projectPath is invalid or not absolute
*
* @example
* const git = new GitAdapter('/path/to/project');
* await git.ensureGitRepository();
*/
constructor(projectPath: string) {
// Validate project path
if (!projectPath) {
throw new Error('Project path is required');
}
if (!path.isAbsolute(projectPath)) {
throw new Error('Project path must be an absolute path');
}
// Normalize path
this.projectPath = path.normalize(projectPath);
// Initialize simple-git
this.git = simpleGit(this.projectPath);
}
/**
* Checks if the current directory is a git repository.
* Looks for .git directory or file (worktree/submodule).
*
* @returns {Promise<boolean>} True if in a git repository
*
* @example
* const isRepo = await git.isGitRepository();
* if (!isRepo) {
* console.log('Not a git repository');
* }
*/
async isGitRepository(): Promise<boolean> {
try {
// Check if .git exists (directory or file for submodules/worktrees)
const gitPath = path.join(this.projectPath, '.git');
if (await fs.pathExists(gitPath)) {
return true;
}
// Try to find git root from subdirectory
try {
await this.git.revparse(['--git-dir']);
return true;
} catch {
return false;
}
} catch (error) {
return false;
}
}
/**
* Validates that git is installed and accessible.
* Checks git binary availability and version.
*
* @returns {Promise<void>}
* @throws {Error} If git is not installed or not accessible
*
* @example
* await git.validateGitInstallation();
* console.log('Git is installed');
*/
async validateGitInstallation(): Promise<void> {
try {
await this.git.version();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(
`Git is not installed or not accessible: ${errorMessage}`
);
}
}
/**
* Gets the git version information.
*
* @returns {Promise<{major: number, minor: number, patch: number, agent: string}>}
*
* @example
* const version = await git.getGitVersion();
* console.log(`Git version: ${version.major}.${version.minor}.${version.patch}`);
*/
async getGitVersion(): Promise<{
major: number;
minor: number;
patch: number;
agent: string;
}> {
const versionResult = await this.git.version();
return {
major: versionResult.major,
minor: versionResult.minor,
patch:
typeof versionResult.patch === 'string'
? parseInt(versionResult.patch)
: versionResult.patch || 0,
agent: versionResult.agent
};
}
/**
* Gets the repository root path.
* Works even when called from a subdirectory.
*
* @returns {Promise<string>} Absolute path to repository root
* @throws {Error} If not in a git repository
*
* @example
* const root = await git.getRepositoryRoot();
* console.log(`Repository root: ${root}`);
*/
async getRepositoryRoot(): Promise<string> {
try {
const result = await this.git.revparse(['--show-toplevel']);
return path.normalize(result.trim());
} catch (error) {
throw new Error(`not a git repository: ${this.projectPath}`);
}
}
/**
* Validates the repository state.
* Checks for corruption and basic integrity.
*
* @returns {Promise<void>}
* @throws {Error} If repository is corrupted or invalid
*
* @example
* await git.validateRepository();
* console.log('Repository is valid');
*/
async validateRepository(): Promise<void> {
// Check if it's a git repository
const isRepo = await this.isGitRepository();
if (!isRepo) {
throw new Error(`not a git repository: ${this.projectPath}`);
}
// Try to get repository status to verify it's not corrupted
try {
await this.git.status();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Repository validation failed: ${errorMessage}`);
}
}
/**
* Ensures we're in a valid git repository before performing operations.
* Convenience method that throws descriptive errors.
*
* @returns {Promise<void>}
* @throws {Error} If not in a valid git repository
*
* @example
* await git.ensureGitRepository();
* // Safe to perform git operations after this
*/
async ensureGitRepository(): Promise<void> {
const isRepo = await this.isGitRepository();
if (!isRepo) {
throw new Error(
`not a git repository: ${this.projectPath}\n` +
`Please run this command from within a git repository, or initialize one with 'git init'.`
);
}
}
/**
* Checks if the working tree is clean (no uncommitted changes).
* A clean working tree has no staged, unstaged, or untracked files.
*
* @returns {Promise<boolean>} True if working tree is clean
*
* @example
* const isClean = await git.isWorkingTreeClean();
* if (!isClean) {
* console.log('Working tree has uncommitted changes');
* }
*/
async isWorkingTreeClean(): Promise<boolean> {
const status = await this.git.status();
return status.isClean();
}
/**
* Gets the detailed status of the working tree.
* Returns raw status from simple-git with all file changes.
*
* @returns {Promise<import('simple-git').StatusResult>} Detailed status object
*
* @example
* const status = await git.getStatus();
* console.log('Modified files:', status.modified);
* console.log('Staged files:', status.staged);
*/
async getStatus(): Promise<import('simple-git').StatusResult> {
return await this.git.status();
}
/**
* Checks if there are any uncommitted changes in the working tree.
* Includes staged, unstaged, and untracked files.
*
* @returns {Promise<boolean>} True if there are uncommitted changes
*
* @example
* const hasChanges = await git.hasUncommittedChanges();
* if (hasChanges) {
* console.log('Please commit your changes before proceeding');
* }
*/
async hasUncommittedChanges(): Promise<boolean> {
const status = await this.git.status();
return !status.isClean();
}
/**
* Checks if there are any staged changes ready to commit.
*
* @returns {Promise<boolean>} True if there are staged changes
*
* @example
* const hasStaged = await git.hasStagedChanges();
* if (hasStaged) {
* console.log('Ready to commit');
* }
*/
async hasStagedChanges(): Promise<boolean> {
const status = await this.git.status();
return status.staged.length > 0;
}
/**
* Checks if there are any untracked files in the working tree.
*
* @returns {Promise<boolean>} True if there are untracked files
*
* @example
* const hasUntracked = await git.hasUntrackedFiles();
* if (hasUntracked) {
* console.log('You have untracked files');
* }
*/
async hasUntrackedFiles(): Promise<boolean> {
const status = await this.git.status();
return status.not_added.length > 0;
}
/**
* Gets a summary of the working tree status with counts.
*
* @returns {Promise<{isClean: boolean, staged: number, modified: number, deleted: number, untracked: number, totalChanges: number}>}
*
* @example
* const summary = await git.getStatusSummary();
* console.log(`${summary.totalChanges} total changes`);
*/
async getStatusSummary(): Promise<{
isClean: boolean;
staged: number;
modified: number;
deleted: number;
untracked: number;
totalChanges: number;
}> {
const status = await this.git.status();
const staged = status.staged.length;
const modified = status.modified.length;
const deleted = status.deleted.length;
const untracked = status.not_added.length;
const totalChanges = staged + modified + deleted + untracked;
return {
isClean: status.isClean(),
staged,
modified,
deleted,
untracked,
totalChanges
};
}
/**
* Ensures the working tree is clean before performing operations.
* Throws an error with details if there are uncommitted changes.
*
* @returns {Promise<void>}
* @throws {Error} If working tree is not clean
*
* @example
* await git.ensureCleanWorkingTree();
* // Safe to perform git operations that require clean state
*/
async ensureCleanWorkingTree(): Promise<void> {
const status = await this.git.status();
if (!status.isClean()) {
const summary = await this.getStatusSummary();
throw new Error(
`working tree is not clean: ${this.projectPath}\n` +
`Staged: ${summary.staged}, Modified: ${summary.modified}, ` +
`Deleted: ${summary.deleted}, Untracked: ${summary.untracked}\n` +
`Please commit or stash your changes before proceeding.`
);
}
}
/**
* Gets the name of the current branch.
*
* @returns {Promise<string>} Current branch name
* @throws {Error} If unable to determine current branch
*
* @example
* const branch = await git.getCurrentBranch();
* console.log(`Currently on: ${branch}`);
*/
async getCurrentBranch(): Promise<string> {
const status = await this.git.status();
return status.current || 'HEAD';
}
/**
* Lists all local branches in the repository.
*
* @returns {Promise<string[]>} Array of branch names
*
* @example
* const branches = await git.listBranches();
* console.log('Available branches:', branches);
*/
async listBranches(): Promise<string[]> {
const branchSummary = await this.git.branchLocal();
return Object.keys(branchSummary.branches);
}
/**
* Checks if a branch exists in the repository.
*
* @param {string} branchName - Name of branch to check
* @returns {Promise<boolean>} True if branch exists
*
* @example
* const exists = await git.branchExists('feature-branch');
* if (!exists) {
* console.log('Branch does not exist');
* }
*/
async branchExists(branchName: string): Promise<boolean> {
const branches = await this.listBranches();
return branches.includes(branchName);
}
/**
* Creates a new branch without checking it out.
*
* @param {string} branchName - Name for the new branch
* @param {Object} options - Branch creation options
* @param {boolean} options.checkout - Whether to checkout after creation
* @returns {Promise<void>}
* @throws {Error} If branch already exists or working tree is dirty (when checkout=true)
*
* @example
* await git.createBranch('feature-branch');
* await git.createBranch('feature-branch', { checkout: true });
*/
async createBranch(
branchName: string,
options: { checkout?: boolean } = {}
): Promise<void> {
// Check if branch already exists
const exists = await this.branchExists(branchName);
if (exists) {
throw new Error(`branch already exists: ${branchName}`);
}
// If checkout is requested, ensure working tree is clean
if (options.checkout) {
await this.ensureCleanWorkingTree();
}
// Create the branch
await this.git.branch([branchName]);
// Checkout if requested
if (options.checkout) {
await this.git.checkout(branchName);
}
}
/**
* Checks out an existing branch.
*
* @param {string} branchName - Name of branch to checkout
* @param {Object} options - Checkout options
* @param {boolean} options.force - Force checkout even with uncommitted changes
* @returns {Promise<void>}
* @throws {Error} If branch doesn't exist or working tree is dirty (unless force=true)
*
* @example
* await git.checkoutBranch('feature-branch');
* await git.checkoutBranch('feature-branch', { force: true });
*/
async checkoutBranch(
branchName: string,
options: { force?: boolean } = {}
): Promise<void> {
// Check if branch exists
const exists = await this.branchExists(branchName);
if (!exists) {
throw new Error(`branch does not exist: ${branchName}`);
}
// Ensure clean working tree unless force is specified
if (!options.force) {
await this.ensureCleanWorkingTree();
}
// Checkout the branch
const checkoutOptions = options.force ? ['-f', branchName] : [branchName];
await this.git.checkout(checkoutOptions);
}
/**
* Creates a new branch and checks it out.
* Convenience method combining createBranch and checkoutBranch.
*
* @param {string} branchName - Name for the new branch
* @returns {Promise<void>}
* @throws {Error} If branch already exists or working tree is dirty
*
* @example
* await git.createAndCheckoutBranch('new-feature');
*/
async createAndCheckoutBranch(branchName: string): Promise<void> {
// Ensure working tree is clean
await this.ensureCleanWorkingTree();
// Check if branch already exists
const exists = await this.branchExists(branchName);
if (exists) {
throw new Error(`branch already exists: ${branchName}`);
}
// Create and checkout the branch
await this.git.checkoutLocalBranch(branchName);
}
/**
* Deletes a branch.
*
* @param {string} branchName - Name of branch to delete
* @param {Object} options - Delete options
* @param {boolean} options.force - Force delete even if unmerged
* @returns {Promise<void>}
* @throws {Error} If branch doesn't exist or is currently checked out
*
* @example
* await git.deleteBranch('old-feature');
* await git.deleteBranch('unmerged-feature', { force: true });
*/
async deleteBranch(
branchName: string,
options: { force?: boolean } = {}
): Promise<void> {
// Check if branch exists
const exists = await this.branchExists(branchName);
if (!exists) {
throw new Error(`branch does not exist: ${branchName}`);
}
// Check if trying to delete current branch
const current = await this.getCurrentBranch();
if (current === branchName) {
throw new Error(`cannot delete current branch: ${branchName}`);
}
// Delete the branch
const deleteOptions = options.force
? ['-D', branchName]
: ['-d', branchName];
await this.git.branch(deleteOptions);
}
/**
* Stages files for commit.
*
* @param {string[]} files - Array of file paths to stage
* @returns {Promise<void>}
*
* @example
* await git.stageFiles(['file1.txt', 'file2.txt']);
* await git.stageFiles(['.']); // Stage all changes
*/
async stageFiles(files: string[]): Promise<void> {
await this.git.add(files);
}
/**
* Unstages files that were previously staged.
*
* @param {string[]} files - Array of file paths to unstage
* @returns {Promise<void>}
*
* @example
* await git.unstageFiles(['file1.txt']);
*/
async unstageFiles(files: string[]): Promise<void> {
await this.git.reset(['HEAD', '--', ...files]);
}
/**
* Creates a commit with optional metadata embedding.
*
* @param {string} message - Commit message
* @param {Object} options - Commit options
* @param {Object} options.metadata - Metadata to embed in commit message
* @param {boolean} options.allowEmpty - Allow empty commits
* @param {boolean} options.enforceNonDefaultBranch - Prevent commits on default branch
* @param {boolean} options.force - Force commit even on default branch
* @returns {Promise<void>}
* @throws {Error} If no staged changes (unless allowEmpty), or on default branch (unless force)
*
* @example
* await git.createCommit('Add feature');
* await git.createCommit('Add feature', {
* metadata: { taskId: '2.4', phase: 'implementation' }
* });
* await git.createCommit('Add feature', {
* enforceNonDefaultBranch: true
* });
*/
async createCommit(
message: string,
options: {
metadata?: Record<string, string>;
allowEmpty?: boolean;
enforceNonDefaultBranch?: boolean;
force?: boolean;
} = {}
): Promise<void> {
// Check if on default branch and enforcement is requested
if (options.enforceNonDefaultBranch && !options.force) {
const currentBranch = await this.getCurrentBranch();
const defaultBranches = ['main', 'master', 'develop'];
if (defaultBranches.includes(currentBranch)) {
throw new Error(
`cannot commit to default branch: ${currentBranch}\n` +
`Please create a feature branch or use force option.`
);
}
}
// Check for staged changes unless allowEmpty
if (!options.allowEmpty) {
const hasStaged = await this.hasStagedChanges();
if (!hasStaged) {
throw new Error('no staged changes to commit');
}
}
// Build commit arguments
const commitArgs: string[] = ['commit'];
// Add message
commitArgs.push('-m', message);
// Add metadata as separate commit message lines
if (options.metadata) {
commitArgs.push('-m', ''); // Empty line separator
for (const [key, value] of Object.entries(options.metadata)) {
commitArgs.push('-m', `[${key}:${value}]`);
}
}
// Add flags
commitArgs.push('--no-gpg-sign');
if (options.allowEmpty) {
commitArgs.push('--allow-empty');
}
await this.git.raw(commitArgs);
}
/**
* Gets the commit log history.
*
* @param {Object} options - Log options
* @param {number} options.maxCount - Maximum number of commits to return
* @returns {Promise<Array>} Array of commit objects
*
* @example
* const log = await git.getCommitLog();
* const recentLog = await git.getCommitLog({ maxCount: 10 });
*/
async getCommitLog(options: { maxCount?: number } = {}): Promise<any[]> {
const logOptions: any = {
format: {
hash: '%H',
date: '%ai',
message: '%B', // Full commit message including body
author_name: '%an',
author_email: '%ae'
}
};
if (options.maxCount) {
logOptions.maxCount = options.maxCount;
}
const log = await this.git.log(logOptions);
return [...log.all];
}
/**
* Gets the last commit.
*
* @returns {Promise<any>} Last commit object
*
* @example
* const lastCommit = await git.getLastCommit();
* console.log(lastCommit.message);
*/
async getLastCommit(): Promise<any> {
const log = await this.git.log({
maxCount: 1,
format: {
hash: '%H',
date: '%ai',
message: '%B', // Full commit message including body
author_name: '%an',
author_email: '%ae'
}
});
return log.latest;
}
/**
* Detects the default branch for the repository.
* Returns the current branch name, assuming it's the default if it's main/master/develop.
*
* @returns {Promise<string>} Default branch name
*
* @example
* const defaultBranch = await git.getDefaultBranch();
* console.log(`Default branch: ${defaultBranch}`);
*/
async getDefaultBranch(): Promise<string> {
const currentBranch = await this.getCurrentBranch();
const defaultBranches = ['main', 'master', 'develop'];
if (defaultBranches.includes(currentBranch)) {
return currentBranch;
}
// If not on a default branch, check which default branches exist
const branches = await this.listBranches();
for (const defaultBranch of defaultBranches) {
if (branches.includes(defaultBranch)) {
return defaultBranch;
}
}
// Fallback to main
return 'main';
}
/**
* Checks if a given branch name is considered a default branch.
* Default branches are: main, master, develop.
*
* @param {string} branchName - Branch name to check
* @returns {Promise<boolean>} True if branch is a default branch
*
* @example
* const isDefault = await git.isDefaultBranch('main');
* if (isDefault) {
* console.log('This is a default branch');
* }
*/
async isDefaultBranch(branchName: string): Promise<boolean> {
const defaultBranches = ['main', 'master', 'develop'];
return defaultBranches.includes(branchName);
}
/**
* Checks if currently on a default branch.
*
* @returns {Promise<boolean>} True if on a default branch
*
* @example
* const onDefault = await git.isOnDefaultBranch();
* if (onDefault) {
* console.log('Warning: You are on a default branch');
* }
*/
async isOnDefaultBranch(): Promise<boolean> {
const currentBranch = await this.getCurrentBranch();
return await this.isDefaultBranch(currentBranch);
}
/**
* Ensures the current branch is not a default branch.
* Throws an error if on a default branch.
*
* @returns {Promise<void>}
* @throws {Error} If currently on a default branch
*
* @example
* await git.ensureNotOnDefaultBranch();
* // Safe to perform operations that shouldn't happen on default branches
*/
async ensureNotOnDefaultBranch(): Promise<void> {
const onDefault = await this.isOnDefaultBranch();
if (onDefault) {
const currentBranch = await this.getCurrentBranch();
throw new Error(
`currently on default branch: ${currentBranch}\n` +
`Please create a feature branch before proceeding.`
);
}
}
/**
* Checks if the repository has any remotes configured.
*
* @returns {Promise<boolean>} True if remotes exist
*
* @example
* const hasRemote = await git.hasRemote();
* if (!hasRemote) {
* console.log('No remotes configured');
* }
*/
async hasRemote(): Promise<boolean> {
const remotes = await this.git.getRemotes();
return remotes.length > 0;
}
/**
* Gets all configured remotes.
*
* @returns {Promise<Array>} Array of remote objects
*
* @example
* const remotes = await git.getRemotes();
* console.log('Remotes:', remotes);
*/
async getRemotes(): Promise<any[]> {
return await this.git.getRemotes(true);
}
}

View File

@@ -0,0 +1,13 @@
/**
* @fileoverview Git operations layer for the tm-core package
* This file exports all git-related classes and interfaces
*/
// Export GitAdapter
export { GitAdapter } from './git-adapter.js';
// Export branch name utilities
export {
generateBranchName,
sanitizeBranchName
} from './branch-name-generator.js';

View File

@@ -0,0 +1,284 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ScopeDetector } from './scope-detector.js';
describe('ScopeDetector', () => {
let scopeDetector: ScopeDetector;
beforeEach(() => {
scopeDetector = new ScopeDetector();
});
describe('detectScope', () => {
it('should detect cli scope from CLI file changes', () => {
const files = ['packages/cli/src/commands/start.ts'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('cli');
});
it('should detect core scope from core package changes', () => {
const files = ['packages/tm-core/src/workflow/orchestrator.ts'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('core');
});
it('should detect test scope from test file changes', () => {
const files = ['packages/tm-core/src/workflow/orchestrator.test.ts'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('test');
});
it('should detect docs scope from documentation changes', () => {
const files = ['README.md', 'docs/guide.md'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('docs');
});
it('should detect config scope from configuration changes', () => {
const files = ['tsconfig.json'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('config');
});
it('should detect workflow scope from workflow files', () => {
const files = ['packages/tm-core/src/workflow/types.ts'];
const scope = scopeDetector.detectScope(files);
// Files within packages get the package scope (more specific than feature scope)
expect(scope).toBe('core');
});
it('should detect git scope from git adapter files', () => {
const files = ['packages/tm-core/src/git/git-adapter.ts'];
const scope = scopeDetector.detectScope(files);
// Files within packages get the package scope (more specific than feature scope)
expect(scope).toBe('core');
});
it('should detect storage scope from storage files', () => {
const files = ['packages/tm-core/src/storage/state-manager.ts'];
const scope = scopeDetector.detectScope(files);
// Files within packages get the package scope (more specific than feature scope)
expect(scope).toBe('core');
});
it('should use most relevant scope when multiple files', () => {
const files = [
'packages/cli/src/commands/start.ts',
'packages/cli/src/commands/stop.ts',
'packages/tm-core/src/types.ts'
];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('cli');
});
it('should handle mixed scopes by choosing highest priority', () => {
const files = [
'README.md',
'packages/tm-core/src/workflow/orchestrator.ts'
];
const scope = scopeDetector.detectScope(files);
// Core is higher priority than docs
expect(scope).toBe('core');
});
it('should handle empty file list gracefully', () => {
const files: string[] = [];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('repo');
});
it('should detect mcp scope from MCP server files', () => {
const files = ['packages/mcp-server/src/tools.ts'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('mcp');
});
it('should detect auth scope from authentication files', () => {
const files = ['packages/tm-core/src/auth/auth-manager.ts'];
const scope = scopeDetector.detectScope(files);
// Files within packages get the package scope (more specific than feature scope)
expect(scope).toBe('core');
});
it('should detect deps scope from dependency changes', () => {
const files = ['pnpm-lock.yaml'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('deps');
});
});
describe('detectScopeWithCustomRules', () => {
it('should use custom scope mapping rules', () => {
const customRules: Record<string, number> = {
custom: 100
};
const customDetector = new ScopeDetector(
{
'custom/**': 'custom'
},
customRules
);
const files = ['custom/file.ts'];
const scope = customDetector.detectScope(files);
expect(scope).toBe('custom');
});
it('should override default priorities with custom priorities', () => {
const customPriorities: Record<string, number> = {
docs: 100, // Make docs highest priority
core: 10
};
const customDetector = new ScopeDetector(undefined, customPriorities);
const files = [
'README.md',
'packages/tm-core/src/workflow/orchestrator.ts'
];
const scope = customDetector.detectScope(files);
expect(scope).toBe('docs');
});
});
describe('getAllMatchingScopes', () => {
it('should return all matching scopes for files', () => {
const files = [
'packages/cli/src/commands/start.ts',
'packages/tm-core/src/workflow/orchestrator.ts',
'README.md'
];
const scopes = scopeDetector.getAllMatchingScopes(files);
expect(scopes).toContain('cli');
expect(scopes).toContain('core');
expect(scopes).toContain('docs');
expect(scopes).toHaveLength(3);
});
it('should return unique scopes only', () => {
const files = [
'packages/cli/src/commands/start.ts',
'packages/cli/src/commands/stop.ts'
];
const scopes = scopeDetector.getAllMatchingScopes(files);
expect(scopes).toEqual(['cli']);
});
it('should return empty array for files with no matches', () => {
const files = ['unknown/path/file.ts'];
const scopes = scopeDetector.getAllMatchingScopes(files);
expect(scopes).toEqual([]);
});
});
describe('getScopePriority', () => {
it('should return priority for known scope', () => {
const priority = scopeDetector.getScopePriority('core');
expect(priority).toBeGreaterThan(0);
});
it('should return 0 for unknown scope', () => {
const priority = scopeDetector.getScopePriority('nonexistent');
expect(priority).toBe(0);
});
it('should prioritize core > cli > test > docs', () => {
const corePriority = scopeDetector.getScopePriority('core');
const cliPriority = scopeDetector.getScopePriority('cli');
const testPriority = scopeDetector.getScopePriority('test');
const docsPriority = scopeDetector.getScopePriority('docs');
expect(corePriority).toBeGreaterThan(cliPriority);
expect(cliPriority).toBeGreaterThan(testPriority);
expect(testPriority).toBeGreaterThan(docsPriority);
});
});
describe('edge cases', () => {
it('should handle Windows paths', () => {
const files = ['packages\\cli\\src\\commands\\start.ts'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('cli');
});
it('should handle absolute paths', () => {
const files = [
'/home/user/project/packages/tm-core/src/workflow/orchestrator.ts'
];
const scope = scopeDetector.detectScope(files);
// Absolute paths won't match package patterns
expect(scope).toBe('workflow');
});
it('should handle paths with special characters', () => {
const files = ['packages/tm-core/src/workflow/orchestrator@v2.ts'];
const scope = scopeDetector.detectScope(files);
// Files within packages get the package scope
expect(scope).toBe('core');
});
it('should handle very long file paths', () => {
const files = [
'packages/tm-core/src/deeply/nested/directory/structure/with/many/levels/file.ts'
];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('core');
});
it('should handle files in root directory', () => {
const files = ['file.ts'];
const scope = scopeDetector.detectScope(files);
expect(scope).toBe('repo');
});
});
describe('getMatchingScope', () => {
it('should return matching scope for single file', () => {
const scope = scopeDetector.getMatchingScope('packages/cli/src/index.ts');
expect(scope).toBe('cli');
});
it('should return null for non-matching file', () => {
const scope = scopeDetector.getMatchingScope('unknown/file.ts');
expect(scope).toBeNull();
});
it('should match test files', () => {
const scope = scopeDetector.getMatchingScope(
'src/components/button.test.tsx'
);
expect(scope).toBe('test');
});
});
});

View File

@@ -0,0 +1,204 @@
/**
* ScopeDetector - Intelligent scope detection from changed files
*
* Automatically determines conventional commit scopes based on file paths
* using configurable pattern matching and priority-based resolution.
* // TODO: remove this
*/
export interface ScopeMapping {
[pattern: string]: string;
}
export interface ScopePriority {
[scope: string]: number;
}
// Ordered from most specific to least specific
const DEFAULT_SCOPE_MAPPINGS: Array<[string, string]> = [
// Special file types (check first - most specific)
['**/*.test.*', 'test'],
['**/*.spec.*', 'test'],
['**/test/**', 'test'],
['**/tests/**', 'test'],
['**/__tests__/**', 'test'],
// Dependencies (specific files)
['**/package-lock.json', 'deps'],
['package-lock.json', 'deps'],
['**/pnpm-lock.yaml', 'deps'],
['pnpm-lock.yaml', 'deps'],
['**/yarn.lock', 'deps'],
['yarn.lock', 'deps'],
// Configuration files (before packages so root configs don't match package patterns)
['**/package.json', 'config'],
['package.json', 'config'],
['**/tsconfig*.json', 'config'],
['tsconfig*.json', 'config'],
['**/.eslintrc*', 'config'],
['.eslintrc*', 'config'],
['**/vite.config.*', 'config'],
['vite.config.*', 'config'],
['**/vitest.config.*', 'config'],
['vitest.config.*', 'config'],
// Package-level scopes (more specific than feature-level)
['packages/cli/**', 'cli'],
['packages/tm-core/**', 'core'],
['packages/mcp-server/**', 'mcp'],
// Feature-level scopes (within any package)
['**/workflow/**', 'workflow'],
['**/git/**', 'git'],
['**/storage/**', 'storage'],
['**/auth/**', 'auth'],
['**/config/**', 'config'],
// Documentation (least specific)
['**/*.md', 'docs'],
['**/docs/**', 'docs'],
['README*', 'docs'],
['CHANGELOG*', 'docs']
];
const DEFAULT_SCOPE_PRIORITIES: ScopePriority = {
core: 100,
cli: 90,
mcp: 85,
workflow: 80,
git: 75,
storage: 70,
auth: 65,
config: 60,
test: 50,
docs: 30,
deps: 20,
repo: 10
};
export class ScopeDetector {
private scopeMappings: Array<[string, string]>;
private scopePriorities: ScopePriority;
constructor(customMappings?: ScopeMapping, customPriorities?: ScopePriority) {
// Start with default mappings
this.scopeMappings = [...DEFAULT_SCOPE_MAPPINGS];
// Add custom mappings at the start (highest priority)
if (customMappings) {
const customEntries = Object.entries(customMappings);
this.scopeMappings = [...customEntries, ...this.scopeMappings];
}
this.scopePriorities = {
...DEFAULT_SCOPE_PRIORITIES,
...customPriorities
};
}
/**
* Detect the most relevant scope from a list of changed files
* Returns the scope with the highest priority
*/
detectScope(files: string[]): string {
if (files.length === 0) {
return 'repo';
}
const scopeCounts = new Map<string, number>();
// Count occurrences of each scope
for (const file of files) {
const scope = this.getMatchingScope(file);
if (scope) {
scopeCounts.set(scope, (scopeCounts.get(scope) || 0) + 1);
}
}
// If no scopes matched, default to 'repo'
if (scopeCounts.size === 0) {
return 'repo';
}
// Find scope with highest priority (considering both priority and count)
let bestScope = 'repo';
let bestScore = 0;
for (const [scope, count] of scopeCounts) {
const priority = this.getScopePriority(scope);
// Score = priority * count (files in that scope)
const score = priority * count;
if (score > bestScore) {
bestScore = score;
bestScope = scope;
}
}
return bestScope;
}
/**
* Get all matching scopes for the given files
*/
getAllMatchingScopes(files: string[]): string[] {
const scopes = new Set<string>();
for (const file of files) {
const scope = this.getMatchingScope(file);
if (scope) {
scopes.add(scope);
}
}
return Array.from(scopes);
}
/**
* Get the matching scope for a single file
* Returns the first matching scope (order matters!)
*/
getMatchingScope(file: string): string | null {
// Normalize path separators
const normalizedFile = file.replace(/\\/g, '/');
for (const [pattern, scope] of this.scopeMappings) {
if (this.matchesPattern(normalizedFile, pattern)) {
return scope;
}
}
return null;
}
/**
* Get the priority of a scope
*/
getScopePriority(scope: string): number {
return this.scopePriorities[scope] || 0;
}
/**
* Match a file path against a glob-like pattern
* Supports:
* - ** for multi-level directory matching
* - * for single-level matching
*/
private matchesPattern(filePath: string, pattern: string): boolean {
// Replace ** first with a unique placeholder
let regexPattern = pattern.replace(/\*\*/g, '§GLOBSTAR§');
// Escape special regex characters (but not our placeholder or *)
regexPattern = regexPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
// Replace single * with [^/]* (matches anything except /)
regexPattern = regexPattern.replace(/\*/g, '[^/]*');
// Replace placeholder with .* (matches anything including /)
regexPattern = regexPattern.replace(/§GLOBSTAR§/g, '.*');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(filePath);
}
}

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { TemplateEngine } from './template-engine.js';
describe('TemplateEngine', () => {
let templateEngine: TemplateEngine;
beforeEach(() => {
templateEngine = new TemplateEngine();
});
describe('constructor and initialization', () => {
it('should initialize with default templates', () => {
expect(templateEngine).toBeDefined();
});
it('should accept custom templates in constructor', () => {
const customTemplate = '{{type}}({{scope}}): {{description}}';
const engine = new TemplateEngine({ commitMessage: customTemplate });
const result = engine.render('commitMessage', {
type: 'feat',
scope: 'core',
description: 'add feature'
});
expect(result).toBe('feat(core): add feature');
});
});
describe('render', () => {
it('should render simple template with single variable', () => {
const template = 'Hello {{name}}';
const result = templateEngine.render('test', { name: 'World' }, template);
expect(result).toBe('Hello World');
});
it('should render template with multiple variables', () => {
const template = '{{type}}({{scope}}): {{description}}';
const result = templateEngine.render(
'test',
{
type: 'feat',
scope: 'api',
description: 'add endpoint'
},
template
);
expect(result).toBe('feat(api): add endpoint');
});
it('should handle missing variables by leaving placeholder', () => {
const template = 'Hello {{name}} from {{location}}';
const result = templateEngine.render('test', { name: 'Alice' }, template);
expect(result).toBe('Hello Alice from {{location}}');
});
it('should handle empty variable values', () => {
const template = '{{prefix}}{{message}}';
const result = templateEngine.render(
'test',
{
prefix: '',
message: 'hello'
},
template
);
expect(result).toBe('hello');
});
it('should handle numeric values', () => {
const template = 'Count: {{count}}';
const result = templateEngine.render('test', { count: 42 }, template);
expect(result).toBe('Count: 42');
});
it('should handle boolean values', () => {
const template = 'Active: {{active}}';
const result = templateEngine.render('test', { active: true }, template);
expect(result).toBe('Active: true');
});
});
describe('setTemplate', () => {
it('should set and use custom template', () => {
templateEngine.setTemplate('custom', 'Value: {{value}}');
const result = templateEngine.render('custom', { value: '123' });
expect(result).toBe('Value: 123');
});
it('should override existing template', () => {
templateEngine.setTemplate('commitMessage', 'Custom: {{msg}}');
const result = templateEngine.render('commitMessage', { msg: 'hello' });
expect(result).toBe('Custom: hello');
});
});
describe('getTemplate', () => {
it('should return existing template', () => {
templateEngine.setTemplate('test', 'Template: {{value}}');
const template = templateEngine.getTemplate('test');
expect(template).toBe('Template: {{value}}');
});
it('should return undefined for non-existent template', () => {
const template = templateEngine.getTemplate('nonexistent');
expect(template).toBeUndefined();
});
});
describe('hasTemplate', () => {
it('should return true for existing template', () => {
templateEngine.setTemplate('test', 'Template');
expect(templateEngine.hasTemplate('test')).toBe(true);
});
it('should return false for non-existent template', () => {
expect(templateEngine.hasTemplate('nonexistent')).toBe(false);
});
});
describe('validateTemplate', () => {
it('should validate template with all required variables', () => {
const template = '{{type}}({{scope}}): {{description}}';
const requiredVars = ['type', 'scope', 'description'];
const result = templateEngine.validateTemplate(template, requiredVars);
expect(result.isValid).toBe(true);
expect(result.missingVars).toEqual([]);
});
it('should detect missing required variables', () => {
const template = '{{type}}: {{description}}';
const requiredVars = ['type', 'scope', 'description'];
const result = templateEngine.validateTemplate(template, requiredVars);
expect(result.isValid).toBe(false);
expect(result.missingVars).toEqual(['scope']);
});
it('should detect multiple missing variables', () => {
const template = '{{type}}';
const requiredVars = ['type', 'scope', 'description'];
const result = templateEngine.validateTemplate(template, requiredVars);
expect(result.isValid).toBe(false);
expect(result.missingVars).toEqual(['scope', 'description']);
});
it('should handle optional variables in template', () => {
const template = '{{type}}({{scope}}): {{description}} [{{taskId}}]';
const requiredVars = ['type', 'scope', 'description'];
const result = templateEngine.validateTemplate(template, requiredVars);
expect(result.isValid).toBe(true);
expect(result.missingVars).toEqual([]);
});
});
describe('extractVariables', () => {
it('should extract all variables from template', () => {
const template = '{{type}}({{scope}}): {{description}}';
const variables = templateEngine.extractVariables(template);
expect(variables).toEqual(['type', 'scope', 'description']);
});
it('should extract unique variables only', () => {
const template = '{{name}} and {{name}} with {{other}}';
const variables = templateEngine.extractVariables(template);
expect(variables).toEqual(['name', 'other']);
});
it('should return empty array for template without variables', () => {
const template = 'Static text with no variables';
const variables = templateEngine.extractVariables(template);
expect(variables).toEqual([]);
});
it('should handle template with whitespace in placeholders', () => {
const template = '{{ type }} and {{ scope }}';
const variables = templateEngine.extractVariables(template);
expect(variables).toEqual(['type', 'scope']);
});
});
describe('edge cases', () => {
it('should handle empty template', () => {
const result = templateEngine.render('test', { name: 'value' }, '');
expect(result).toBe('');
});
it('should handle template with no variables', () => {
const template = 'Static text';
const result = templateEngine.render('test', {}, template);
expect(result).toBe('Static text');
});
it('should handle empty variables object', () => {
const template = 'Hello {{name}}';
const result = templateEngine.render('test', {}, template);
expect(result).toBe('Hello {{name}}');
});
it('should handle special characters in values', () => {
const template = 'Value: {{value}}';
const result = templateEngine.render(
'test',
{
value: 'hello$world{test}'
},
template
);
expect(result).toBe('Value: hello$world{test}');
});
it('should handle multiline templates', () => {
const template = '{{type}}: {{description}}\n\n{{body}}';
const result = templateEngine.render(
'test',
{
type: 'feat',
description: 'add feature',
body: 'Details here'
},
template
);
expect(result).toBe('feat: add feature\n\nDetails here');
});
});
describe('default commit message template', () => {
it('should have default commit message template', () => {
const template = templateEngine.getTemplate('commitMessage');
expect(template).toBeDefined();
expect(template).toContain('{{type}}');
expect(template).toContain('{{description}}');
});
it('should render default commit message template', () => {
const result = templateEngine.render('commitMessage', {
type: 'feat',
scope: 'core',
description: 'implement feature',
body: 'Additional details',
taskId: '5.1'
});
expect(result).toContain('feat');
expect(result).toContain('core');
expect(result).toContain('implement feature');
});
});
});

View File

@@ -0,0 +1,203 @@
/**
* TemplateEngine - Configurable template system for generating text from templates
*
* Supports:
* - Variable substitution using {{variableName}} syntax
* - Custom templates via constructor or setTemplate
* - Template validation with required variables
* - Variable extraction from templates
* - Multiple template storage and retrieval
*/
export interface TemplateValidationResult {
isValid: boolean;
missingVars: string[];
}
export interface TemplateVariables {
[key: string]: string | number | boolean | undefined;
}
export interface TemplateCollection {
[templateName: string]: string;
}
export interface TemplateEngineOptions {
customTemplates?: TemplateCollection;
preservePlaceholders?: boolean;
}
const DEFAULT_TEMPLATES: TemplateCollection = {
commitMessage: `{{type}}{{#scope}}({{scope}}){{/scope}}{{#breaking}}!{{/breaking}}: {{description}}
{{#body}}{{body}}
{{/body}}{{#taskId}}Task: {{taskId}}{{/taskId}}{{#phase}}
Phase: {{phase}}{{/phase}}{{#testsPassing}}
Tests: {{testsPassing}} passing{{#testsFailing}}, {{testsFailing}} failing{{/testsFailing}}{{/testsPassing}}`
};
export class TemplateEngine {
private templates: TemplateCollection;
private preservePlaceholders: boolean;
constructor(
optionsOrTemplates: TemplateEngineOptions | TemplateCollection = {}
) {
// Backward compatibility: support old signature (TemplateCollection) and new signature (TemplateEngineOptions)
const isOptions =
'customTemplates' in optionsOrTemplates ||
'preservePlaceholders' in optionsOrTemplates;
const options: TemplateEngineOptions = isOptions
? (optionsOrTemplates as TemplateEngineOptions)
: { customTemplates: optionsOrTemplates as TemplateCollection };
this.templates = {
...DEFAULT_TEMPLATES,
...(options.customTemplates || {})
};
this.preservePlaceholders = options.preservePlaceholders ?? false;
}
/**
* Render a template with provided variables
*/
render(
templateName: string,
variables: TemplateVariables,
inlineTemplate?: string
): string {
const template =
inlineTemplate !== undefined
? inlineTemplate
: this.templates[templateName];
if (template === undefined) {
throw new Error(`Template "${templateName}" not found`);
}
return this.substituteVariables(template, variables);
}
/**
* Set or update a template
*/
setTemplate(name: string, template: string): void {
this.templates[name] = template;
}
/**
* Get a template by name
*/
getTemplate(name: string): string | undefined {
return this.templates[name];
}
/**
* Check if a template exists
*/
hasTemplate(name: string): boolean {
return name in this.templates;
}
/**
* Validate that a template contains all required variables
*/
validateTemplate(
template: string,
requiredVars: string[]
): TemplateValidationResult {
const templateVars = this.extractVariables(template);
const missingVars = requiredVars.filter(
(varName) => !templateVars.includes(varName)
);
return {
isValid: missingVars.length === 0,
missingVars
};
}
/**
* Extract all variable names from a template
*/
extractVariables(template: string): string[] {
const regex = /\{\{\s*([^}#/\s]+)\s*\}\}/g;
const matches = template.matchAll(regex);
const variables = new Set<string>();
for (const match of matches) {
variables.add(match[1]);
}
return Array.from(variables);
}
/**
* Substitute variables in template
* Supports both {{variable}} and {{#variable}}...{{/variable}} (conditional blocks)
*/
private substituteVariables(
template: string,
variables: TemplateVariables
): string {
let result = template;
// Handle conditional blocks first ({{#var}}...{{/var}})
result = this.processConditionalBlocks(result, variables);
// Handle simple variable substitution ({{var}})
result = result.replace(/\{\{\s*([^}#/\s]+)\s*\}\}/g, (_, varName) => {
const value = variables[varName];
return value !== undefined && value !== null
? String(value)
: this.preservePlaceholders
? `{{${varName}}}`
: '';
});
return result;
}
/**
* Process conditional blocks in template
* {{#variable}}content{{/variable}} - shows content only if variable is truthy
* Processes innermost blocks first to handle nesting
*/
private processConditionalBlocks(
template: string,
variables: TemplateVariables
): string {
let result = template;
let hasChanges = true;
// Keep processing until no more conditional blocks are found
while (hasChanges) {
const before = result;
// Find and replace innermost conditional blocks (non-greedy match)
result = result.replace(
/\{\{#([^}]+)\}\}((?:(?!\{\{#).)*?)\{\{\/\1\}\}/gs,
(_, varName, content) => {
const value = variables[varName.trim()];
// Show content if variable is truthy (not undefined, null, false, or empty string)
if (
value !== undefined &&
value !== null &&
value !== false &&
value !== ''
) {
return content;
}
return '';
}
);
hasChanges = result !== before;
}
return result;
}
}

View File

@@ -83,3 +83,35 @@ export {
type ValidationErrorType,
type DependencyIssue
} from './services/index.js';
// Re-export Git adapter
export { GitAdapter } from './git/git-adapter.js';
export {
CommitMessageGenerator,
type CommitMessageOptions
} from './git/commit-message-generator.js';
// Re-export workflow orchestrator, state manager, activity logger, and types
export { WorkflowOrchestrator } from './workflow/workflow-orchestrator.js';
export { WorkflowStateManager } from './workflow/workflow-state-manager.js';
export { WorkflowActivityLogger } from './workflow/workflow-activity-logger.js';
export type {
WorkflowPhase,
TDDPhase,
WorkflowContext,
WorkflowState,
WorkflowEvent,
WorkflowEventData,
WorkflowEventListener,
SubtaskInfo,
TestResult,
WorkflowError
} from './workflow/types.js';
// Re-export workflow service
export { WorkflowService } from './services/workflow.service.js';
export type {
StartWorkflowOptions,
WorkflowStatus,
NextAction
} from './services/workflow.service.js';

View File

@@ -9,6 +9,17 @@ import type {
StorageType
} from '../types/index.js';
/**
* Conventional Commit types allowed in workflow
*/
export type CommitType =
| 'feat'
| 'fix'
| 'refactor'
| 'test'
| 'docs'
| 'chore';
/**
* Model configuration for different AI roles
*/
@@ -45,9 +56,15 @@ export interface TaskSettings {
defaultPriority: TaskPriority;
/** Default complexity for analysis */
defaultComplexity: TaskComplexity;
/** Maximum number of subtasks per task */
/**
* Maximum number of subtasks per task
* @minimum 1
*/
maxSubtasks: number;
/** Maximum number of concurrent tasks */
/**
* Maximum number of concurrent tasks
* @minimum 1
*/
maxConcurrentTasks: number;
/** Enable automatic task ID generation */
autoGenerateIds: boolean;
@@ -69,7 +86,10 @@ export interface TagSettings {
enableTags: boolean;
/** Default tag for new tasks */
defaultTag: string;
/** Maximum number of tags per task */
/**
* Maximum number of tags per task
* @minimum 1
*/
maxTagsPerTask: number;
/** Enable automatic tag creation from Git branches */
autoCreateFromBranch: boolean;
@@ -114,7 +134,10 @@ export interface StorageSettings
readonly apiConfigured?: boolean;
/** Enable automatic backups */
enableBackup: boolean;
/** Maximum number of backups to retain */
/**
* Maximum number of backups to retain
* @minimum 0
*/
maxBackups: number;
/** Enable compression for storage */
enableCompression: boolean;
@@ -128,15 +151,30 @@ export interface StorageSettings
* Retry and resilience settings
*/
export interface RetrySettings {
/** Number of retry attempts for failed operations */
/**
* Number of retry attempts for failed operations
* @minimum 0
*/
retryAttempts: number;
/** Base delay between retries in milliseconds */
/**
* Base delay between retries in milliseconds
* @minimum 0
*/
retryDelay: number;
/** Maximum delay between retries in milliseconds */
/**
* Maximum delay between retries in milliseconds
* @minimum 0
*/
maxRetryDelay: number;
/** Exponential backoff multiplier */
/**
* Exponential backoff multiplier
* @minimum 1
*/
backoffMultiplier: number;
/** Request timeout in milliseconds */
/**
* Request timeout in milliseconds
* @minimum 0
*/
requestTimeout: number;
/** Enable retry for network errors */
retryOnNetworkError: boolean;
@@ -160,9 +198,15 @@ export interface LoggingSettings {
logPerformance: boolean;
/** Enable error stack traces */
logStackTraces: boolean;
/** Maximum log file size in MB */
/**
* Maximum log file size in MB
* @minimum 1
*/
maxFileSize: number;
/** Maximum number of log files to retain */
/**
* Maximum number of log files to retain
* @minimum 1
*/
maxFiles: number;
}
@@ -174,11 +218,17 @@ export interface SecuritySettings {
validateApiKeys: boolean;
/** Enable request rate limiting */
enableRateLimit: boolean;
/** Maximum requests per minute */
/**
* Maximum requests per minute
* @minimum 1
*/
maxRequestsPerMinute: number;
/** Enable input sanitization */
sanitizeInputs: boolean;
/** Maximum prompt length in characters */
/**
* Maximum prompt length in characters
* @minimum 1
*/
maxPromptLength: number;
/** Allowed file extensions for imports */
allowedFileExtensions: string[];
@@ -186,6 +236,72 @@ export interface SecuritySettings {
enableCors: boolean;
}
/**
* Workflow and autopilot TDD settings
*/
export interface WorkflowSettings {
/** Enable autopilot/TDD workflow features */
enableAutopilot: boolean;
/**
* Maximum retry attempts for phase validation
* @minimum 1
* @maximum 10
*/
maxPhaseAttempts: number;
/** Branch naming pattern for workflow branches */
branchPattern: string;
/** Require clean working tree before starting workflow */
requireCleanWorkingTree: boolean;
/** Automatically stage all changes during commit phase */
autoStageChanges: boolean;
/** Include co-author attribution in commits */
includeCoAuthor: boolean;
/** Co-author name for commit messages */
coAuthorName: string;
/** Co-author email for commit messages (defaults to taskmaster@tryhamster.com) */
coAuthorEmail: string;
/** Test result thresholds for phase validation */
testThresholds: {
/**
* Minimum test count for valid RED phase
* @minimum 0
*/
minTests: number;
/**
* Maximum allowed failing tests in GREEN phase
* @minimum 0
*/
maxFailuresInGreen: number;
};
/** Commit message template pattern */
commitMessageTemplate: string;
/** Conventional commit types allowed */
allowedCommitTypes: readonly CommitType[];
/**
* Default commit type for autopilot
* @validation Must be present in allowedCommitTypes array
*/
defaultCommitType: CommitType;
/**
* Timeout for workflow operations in milliseconds
* @minimum 0
*/
operationTimeout: number;
/** Enable activity logging for workflow events */
enableActivityLogging: boolean;
/** Path to store workflow activity logs */
activityLogPath: string;
/** Enable automatic backup of workflow state */
enableStateBackup: boolean;
/**
* Maximum workflow state backups to retain
* @minimum 0
*/
maxStateBackups: number;
/** Abort workflow if validation fails after max attempts */
abortOnMaxAttempts: boolean;
}
/**
* Main configuration interface for Task Master core
*/
@@ -211,6 +327,9 @@ export interface IConfiguration {
/** Tag and context settings */
tags: TagSettings;
/** Workflow and autopilot settings */
workflow: WorkflowSettings;
/** Storage configuration */
storage: StorageSettings;
@@ -414,6 +533,35 @@ export const DEFAULT_CONFIG_VALUES = {
MAX_TAGS_PER_TASK: 10,
NAMING_CONVENTION: 'kebab-case' as const
},
WORKFLOW: {
ENABLE_AUTOPILOT: true,
MAX_PHASE_ATTEMPTS: 3,
BRANCH_PATTERN: 'task-{taskId}',
REQUIRE_CLEAN_WORKING_TREE: true,
AUTO_STAGE_CHANGES: true,
INCLUDE_CO_AUTHOR: true,
CO_AUTHOR_NAME: 'TaskMaster AI',
CO_AUTHOR_EMAIL: 'taskmaster@tryhamster.com',
MIN_TESTS: 1,
MAX_FAILURES_IN_GREEN: 0,
COMMIT_MESSAGE_TEMPLATE:
'{type}({scope}): {description} (Task {taskId}.{subtaskIndex})',
ALLOWED_COMMIT_TYPES: [
'feat',
'fix',
'refactor',
'test',
'docs',
'chore'
] as const satisfies readonly CommitType[],
DEFAULT_COMMIT_TYPE: 'feat' as CommitType,
OPERATION_TIMEOUT: 60000,
ENABLE_ACTIVITY_LOGGING: true,
ACTIVITY_LOG_PATH: '.taskmaster/logs/workflow-activity.log',
ENABLE_STATE_BACKUP: true,
MAX_STATE_BACKUPS: 5,
ABORT_ON_MAX_ATTEMPTS: false
},
STORAGE: {
TYPE: 'auto' as const,
ENCODING: 'utf8' as BufferEncoding,

View File

@@ -8,6 +8,7 @@ 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 { TestResultValidator } from './test-result-validator.js';
export type { Organization, Brief } from './organization.service.js';
export type {
ExportTasksOptions,
@@ -22,3 +23,11 @@ export type {
ValidationErrorType,
DependencyIssue
} from './task-loader.service.js';
export type {
TestResult,
TestPhase,
Coverage,
CoverageThresholds,
ValidationResult,
PhaseValidationOptions
} from './test-result-validator.types.js';

View File

@@ -0,0 +1,456 @@
import { describe, it, expect } from 'vitest';
import { TestResultValidator } from './test-result-validator.js';
import type {
TestResult,
ValidationResult,
TestPhase
} from './test-result-validator.types.js';
describe('TestResultValidator - Input Validation', () => {
const validator = new TestResultValidator();
describe('Schema Validation', () => {
it('should validate a valid test result', () => {
const validResult: TestResult = {
total: 10,
passed: 5,
failed: 5,
skipped: 0,
phase: 'RED'
};
const result = validator.validate(validResult);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should reject negative test counts', () => {
const invalidResult = {
total: -1,
passed: 0,
failed: 0,
skipped: 0,
phase: 'RED'
};
const result = validator.validate(invalidResult as TestResult);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it('should reject when totals do not match', () => {
const invalidResult: TestResult = {
total: 10,
passed: 3,
failed: 3,
skipped: 3, // 3 + 3 + 3 = 9, not 10
phase: 'RED'
};
const result = validator.validate(invalidResult);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
'Total tests must equal passed + failed + skipped'
);
});
it('should reject missing required fields', () => {
const invalidResult = {
total: 10,
passed: 5
// missing failed, skipped, phase
};
const result = validator.validate(invalidResult as TestResult);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it('should accept optional coverage data', () => {
const resultWithCoverage: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'GREEN',
coverage: {
line: 85,
branch: 75,
function: 90,
statement: 85
}
};
const result = validator.validate(resultWithCoverage);
expect(result.valid).toBe(true);
});
it('should reject invalid coverage percentages', () => {
const invalidResult: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'GREEN',
coverage: {
line: 150, // Invalid: > 100
branch: -10, // Invalid: < 0
function: 90,
statement: 85
}
};
const result = validator.validate(invalidResult);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it('should reject invalid phase values', () => {
const invalidResult = {
total: 10,
passed: 5,
failed: 5,
skipped: 0,
phase: 'INVALID_PHASE'
};
const result = validator.validate(invalidResult as TestResult);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
});
describe('TestResultValidator - RED Phase Validation', () => {
const validator = new TestResultValidator();
it('should pass validation when RED phase has failures', () => {
const redResult: TestResult = {
total: 10,
passed: 5,
failed: 5,
skipped: 0,
phase: 'RED'
};
const result = validator.validateRedPhase(redResult);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should fail validation when RED phase has zero failures', () => {
const redResult: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'RED'
};
const result = validator.validateRedPhase(redResult);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
'RED phase must have at least one failing test'
);
expect(result.suggestions).toContain(
'Write failing tests first to follow TDD workflow'
);
});
it('should fail validation when RED phase has empty test suite', () => {
const emptyResult: TestResult = {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
phase: 'RED'
};
const result = validator.validateRedPhase(emptyResult);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Cannot validate empty test suite');
expect(result.suggestions).toContain(
'Add at least one test to begin TDD cycle'
);
});
it('should propagate base validation errors', () => {
const invalidResult: TestResult = {
total: 10,
passed: 3,
failed: 3,
skipped: 3, // Total mismatch
phase: 'RED'
};
const result = validator.validateRedPhase(invalidResult);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
'Total tests must equal passed + failed + skipped'
);
});
});
describe('TestResultValidator - GREEN Phase Validation', () => {
const validator = new TestResultValidator();
it('should pass validation when GREEN phase has all tests passing', () => {
const greenResult: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'GREEN'
};
const result = validator.validateGreenPhase(greenResult);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should fail validation when GREEN phase has failures', () => {
const greenResult: TestResult = {
total: 10,
passed: 5,
failed: 5,
skipped: 0,
phase: 'GREEN'
};
const result = validator.validateGreenPhase(greenResult);
expect(result.valid).toBe(false);
expect(result.errors).toContain('GREEN phase must have zero failures');
expect(result.suggestions).toContain(
'Fix implementation to make all tests pass'
);
});
it('should fail validation when GREEN phase has no passing tests', () => {
const greenResult: TestResult = {
total: 5,
passed: 0,
failed: 0,
skipped: 5,
phase: 'GREEN'
};
const result = validator.validateGreenPhase(greenResult);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
'GREEN phase must have at least one passing test'
);
});
it('should warn when test count decreases', () => {
const greenResult: TestResult = {
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
};
const result = validator.validateGreenPhase(greenResult, 10);
expect(result.valid).toBe(true);
expect(result.warnings).toContain('Test count decreased from 10 to 5');
expect(result.suggestions).toContain(
'Verify that no tests were accidentally removed'
);
});
it('should not warn when test count increases', () => {
const greenResult: TestResult = {
total: 15,
passed: 15,
failed: 0,
skipped: 0,
phase: 'GREEN'
};
const result = validator.validateGreenPhase(greenResult, 10);
expect(result.valid).toBe(true);
expect(result.warnings || []).toEqual([]);
});
it('should propagate base validation errors', () => {
const invalidResult: TestResult = {
total: 10,
passed: 3,
failed: 3,
skipped: 3, // Total mismatch
phase: 'GREEN'
};
const result = validator.validateGreenPhase(invalidResult);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
'Total tests must equal passed + failed + skipped'
);
});
});
describe('TestResultValidator - Coverage Threshold Validation', () => {
const validator = new TestResultValidator();
it('should pass validation when coverage meets thresholds', () => {
const result: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'GREEN',
coverage: {
line: 85,
branch: 80,
function: 90,
statement: 85
}
};
const thresholds = {
line: 80,
branch: 75,
function: 85,
statement: 80
};
const validationResult = validator.validateCoverage(result, thresholds);
expect(validationResult.valid).toBe(true);
expect(validationResult.errors).toEqual([]);
});
it('should fail validation when line coverage is below threshold', () => {
const result: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'GREEN',
coverage: {
line: 70,
branch: 80,
function: 90,
statement: 85
}
};
const thresholds = {
line: 80
};
const validationResult = validator.validateCoverage(result, thresholds);
expect(validationResult.valid).toBe(false);
expect(validationResult.errors[0]).toContain('line coverage (70% < 80%)');
expect(validationResult.suggestions).toContain(
'Add more tests to improve code coverage'
);
});
it('should fail validation when multiple coverage types are below threshold', () => {
const result: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'GREEN',
coverage: {
line: 70,
branch: 60,
function: 75,
statement: 65
}
};
const thresholds = {
line: 80,
branch: 75,
function: 85,
statement: 80
};
const validationResult = validator.validateCoverage(result, thresholds);
expect(validationResult.valid).toBe(false);
expect(validationResult.errors[0]).toContain('line coverage (70% < 80%)');
expect(validationResult.errors[0]).toContain('branch coverage (60% < 75%)');
expect(validationResult.errors[0]).toContain(
'function coverage (75% < 85%)'
);
expect(validationResult.errors[0]).toContain(
'statement coverage (65% < 80%)'
);
});
it('should skip validation when no coverage data is provided', () => {
const result: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'GREEN'
};
const thresholds = {
line: 80,
branch: 75
};
const validationResult = validator.validateCoverage(result, thresholds);
expect(validationResult.valid).toBe(true);
expect(validationResult.errors).toEqual([]);
});
it('should only validate specified threshold types', () => {
const result: TestResult = {
total: 10,
passed: 10,
failed: 0,
skipped: 0,
phase: 'GREEN',
coverage: {
line: 70,
branch: 60,
function: 90,
statement: 85
}
};
const thresholds = {
line: 80
// Only checking line coverage
};
const validationResult = validator.validateCoverage(result, thresholds);
expect(validationResult.valid).toBe(false);
expect(validationResult.errors[0]).toContain('line coverage');
expect(validationResult.errors[0]).not.toContain('branch coverage');
});
it('should propagate base validation errors', () => {
const invalidResult: TestResult = {
total: 10,
passed: 3,
failed: 3,
skipped: 3, // Total mismatch
phase: 'GREEN',
coverage: {
line: 90,
branch: 90,
function: 90,
statement: 90
}
};
const thresholds = {
line: 80
};
const validationResult = validator.validateCoverage(
invalidResult,
thresholds
);
expect(validationResult.valid).toBe(false);
expect(validationResult.errors).toContain(
'Total tests must equal passed + failed + skipped'
);
});
});

View File

@@ -0,0 +1,268 @@
import { z } from 'zod';
import type {
TestResult,
ValidationResult,
CoverageThresholds,
PhaseValidationOptions
} from './test-result-validator.types.js';
/**
* Schema for coverage metrics validation
*/
const coverageSchema = z.object({
line: z.number().min(0).max(100),
branch: z.number().min(0).max(100),
function: z.number().min(0).max(100),
statement: z.number().min(0).max(100)
});
/**
* Schema for test result validation
*/
const testResultSchema = z.object({
total: z.number().int().nonnegative(),
passed: z.number().int().nonnegative(),
failed: z.number().int().nonnegative(),
skipped: z.number().int().nonnegative(),
phase: z.enum(['RED', 'GREEN', 'REFACTOR']),
coverage: coverageSchema.optional()
});
/**
* Validates test results according to TDD phase semantics
*/
export class TestResultValidator {
/**
* Validates a test result object
*/
validate(testResult: TestResult): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
const suggestions: string[] = [];
// Schema validation
const parseResult = testResultSchema.safeParse(testResult);
if (!parseResult.success) {
const zodIssues = parseResult.error.issues || [];
errors.push(
...zodIssues.map((e) => {
const path = e.path.length > 0 ? `${e.path.join('.')}: ` : '';
return `${path}${e.message}`;
})
);
return { valid: false, errors, warnings, suggestions };
}
// Total validation
const sum = testResult.passed + testResult.failed + testResult.skipped;
if (sum !== testResult.total) {
errors.push('Total tests must equal passed + failed + skipped');
}
// If there are validation errors, return early
if (errors.length > 0) {
return { valid: false, errors, warnings, suggestions };
}
return { valid: true, errors, warnings, suggestions };
}
/**
* Validates RED phase test results
* RED phase must have at least one failing test
*/
validateRedPhase(testResult: TestResult): ValidationResult {
const baseValidation = this.validate(testResult);
if (!baseValidation.valid) {
return baseValidation;
}
const errors: string[] = [];
const suggestions: string[] = [];
// RED phase must have failures
if (testResult.failed === 0) {
errors.push('RED phase must have at least one failing test');
suggestions.push('Write failing tests first to follow TDD workflow');
}
// Must have at least one test
if (testResult.total === 0) {
errors.push('Cannot validate empty test suite');
suggestions.push('Add at least one test to begin TDD cycle');
}
return {
valid: errors.length === 0,
errors,
suggestions
};
}
/**
* Validates GREEN phase test results
* GREEN phase must have zero failures
*/
validateGreenPhase(
testResult: TestResult,
previousTestCount?: number
): ValidationResult {
const baseValidation = this.validate(testResult);
if (!baseValidation.valid) {
return baseValidation;
}
const errors: string[] = [];
const warnings: string[] = [];
const suggestions: string[] = [];
// GREEN phase must have zero failures
if (testResult.failed > 0) {
errors.push('GREEN phase must have zero failures');
suggestions.push('Fix implementation to make all tests pass');
}
// Must have at least one passing test
if (testResult.passed === 0) {
errors.push('GREEN phase must have at least one passing test');
suggestions.push('Ensure tests exist and implementation makes them pass');
}
// Check for test count regression
if (
previousTestCount !== undefined &&
testResult.total < previousTestCount
) {
warnings.push(
`Test count decreased from ${previousTestCount} to ${testResult.total}`
);
suggestions.push('Verify that no tests were accidentally removed');
}
return {
valid: errors.length === 0,
errors,
warnings,
suggestions
};
}
/**
* Validates coverage thresholds if provided
*/
validateCoverage(
testResult: TestResult,
thresholds: CoverageThresholds
): ValidationResult {
const baseValidation = this.validate(testResult);
if (!baseValidation.valid) {
return baseValidation;
}
const errors: string[] = [];
const suggestions: string[] = [];
// Skip validation if no coverage data
if (!testResult.coverage) {
return { valid: true, errors: [], suggestions: [] };
}
const coverage = testResult.coverage;
const gaps: string[] = [];
// Check each coverage type against threshold
if (thresholds.line !== undefined && coverage.line < thresholds.line) {
gaps.push(`line coverage (${coverage.line}% < ${thresholds.line}%)`);
}
if (
thresholds.branch !== undefined &&
coverage.branch < thresholds.branch
) {
gaps.push(
`branch coverage (${coverage.branch}% < ${thresholds.branch}%)`
);
}
if (
thresholds.function !== undefined &&
coverage.function < thresholds.function
) {
gaps.push(
`function coverage (${coverage.function}% < ${thresholds.function}%)`
);
}
if (
thresholds.statement !== undefined &&
coverage.statement < thresholds.statement
) {
gaps.push(
`statement coverage (${coverage.statement}% < ${thresholds.statement}%)`
);
}
if (gaps.length > 0) {
errors.push(`Coverage thresholds not met: ${gaps.join(', ')}`);
suggestions.push('Add more tests to improve code coverage');
}
return {
valid: errors.length === 0,
errors,
suggestions
};
}
/**
* Validates test results based on TDD phase
*/
validatePhase(
testResult: TestResult,
options?: PhaseValidationOptions
): ValidationResult {
const phase = options?.phase ?? testResult.phase;
// Phase-specific validation
let phaseResult: ValidationResult;
if (phase === 'RED') {
phaseResult = this.validateRedPhase(testResult);
} else if (phase === 'GREEN') {
phaseResult = this.validateGreenPhase(
testResult,
options?.previousTestCount
);
} else {
// REFACTOR phase uses same rules as GREEN
phaseResult = this.validateGreenPhase(
testResult,
options?.previousTestCount
);
}
if (!phaseResult.valid) {
return phaseResult;
}
// Coverage validation if thresholds provided
if (options?.coverageThresholds) {
const coverageResult = this.validateCoverage(
testResult,
options.coverageThresholds
);
// Merge results
return {
valid: coverageResult.valid,
errors: [...(phaseResult.errors || []), ...coverageResult.errors],
warnings: phaseResult.warnings,
suggestions: [
...(phaseResult.suggestions || []),
...(coverageResult.suggestions || [])
]
};
}
return phaseResult;
}
}

View File

@@ -0,0 +1,55 @@
/**
* Test phase in TDD workflow
*/
export type TestPhase = 'RED' | 'GREEN' | 'REFACTOR';
/**
* Code coverage metrics
*/
export interface Coverage {
line: number;
branch: number;
function: number;
statement: number;
}
/**
* Test result data structure
*/
export interface TestResult {
total: number;
passed: number;
failed: number;
skipped: number;
phase: TestPhase;
coverage?: Coverage;
}
/**
* Coverage threshold configuration
*/
export interface CoverageThresholds {
line?: number;
branch?: number;
function?: number;
statement?: number;
}
/**
* Validation result structure
*/
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings?: string[];
suggestions?: string[];
}
/**
* Phase-specific validation options
*/
export interface PhaseValidationOptions {
phase: TestPhase;
coverageThresholds?: CoverageThresholds;
previousTestCount?: number;
}

View File

@@ -0,0 +1,494 @@
/**
* @fileoverview WorkflowService - High-level facade for TDD workflow operations
* Provides a simplified API for MCP tools while delegating to WorkflowOrchestrator
*/
import { WorkflowOrchestrator } from '../workflow/workflow-orchestrator.js';
import { WorkflowStateManager } from '../workflow/workflow-state-manager.js';
import { WorkflowActivityLogger } from '../workflow/workflow-activity-logger.js';
import type {
WorkflowContext,
SubtaskInfo,
TestResult,
WorkflowPhase,
TDDPhase,
WorkflowState
} from '../workflow/types.js';
import { GitAdapter } from '../git/git-adapter.js';
/**
* Options for starting a new workflow
*/
export interface StartWorkflowOptions {
taskId: string;
taskTitle: string;
subtasks: Array<{
id: string;
title: string;
status: string;
maxAttempts?: number;
}>;
maxAttempts?: number;
force?: boolean;
tag?: string; // Optional tag for branch naming
}
/**
* Simplified workflow status for MCP responses
*/
export interface WorkflowStatus {
taskId: string;
phase: WorkflowPhase;
tddPhase?: TDDPhase;
branchName?: string;
currentSubtask?: {
id: string;
title: string;
attempts: number;
maxAttempts: number;
};
progress: {
completed: number;
total: number;
current: number;
percentage: number;
};
}
/**
* Next action recommendation for AI agent
*/
export interface NextAction {
action: string;
description: string;
nextSteps: string;
phase: WorkflowPhase;
tddPhase?: TDDPhase;
subtask?: {
id: string;
title: string;
};
}
/**
* WorkflowService - Facade for workflow operations
* Manages WorkflowOrchestrator lifecycle and state persistence
*/
export class WorkflowService {
private readonly projectRoot: string;
private readonly stateManager: WorkflowStateManager;
private orchestrator?: WorkflowOrchestrator;
private activityLogger?: WorkflowActivityLogger;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.stateManager = new WorkflowStateManager(projectRoot);
}
/**
* Check if workflow state exists
*/
async hasWorkflow(): Promise<boolean> {
return await this.stateManager.exists();
}
/**
* Start a new TDD workflow
*/
async startWorkflow(options: StartWorkflowOptions): Promise<WorkflowStatus> {
const {
taskId,
taskTitle,
subtasks,
maxAttempts = 3,
force,
tag
} = options;
// Check for existing workflow
if ((await this.hasWorkflow()) && !force) {
throw new Error(
'Workflow already exists. Use force=true to override or resume existing workflow.'
);
}
// Initialize git adapter and ensure clean state
const gitAdapter = new GitAdapter(this.projectRoot);
await gitAdapter.ensureGitRepository();
await gitAdapter.ensureCleanWorkingTree();
// Parse subtasks to WorkflowContext format
const workflowSubtasks: SubtaskInfo[] = subtasks.map((st) => ({
id: st.id,
title: st.title,
status: st.status === 'done' ? 'completed' : 'pending',
attempts: 0,
maxAttempts: st.maxAttempts || maxAttempts
}));
// Find the first incomplete subtask to resume from
const firstIncompleteIndex = workflowSubtasks.findIndex(
(st) => st.status !== 'completed'
);
// If all subtasks are already completed, throw an error
if (firstIncompleteIndex === -1) {
throw new Error(
`All subtasks for task ${taskId} are already completed. Nothing to do.`
);
}
// Create workflow context, starting from first incomplete subtask
const context: WorkflowContext = {
taskId,
subtasks: workflowSubtasks,
currentSubtaskIndex: firstIncompleteIndex,
errors: [],
metadata: {
startedAt: new Date().toISOString(),
taskTitle,
resumedFromSubtask:
firstIncompleteIndex > 0
? workflowSubtasks[firstIncompleteIndex].id
: undefined
}
};
// Create orchestrator with auto-persistence
this.orchestrator = new WorkflowOrchestrator(context);
this.orchestrator.enableAutoPersist(async (state: WorkflowState) => {
await this.stateManager.save(state);
});
// Initialize activity logger to track all workflow events
this.activityLogger = new WorkflowActivityLogger(
this.orchestrator,
this.stateManager.getActivityLogPath()
);
this.activityLogger.start();
// Transition through PREFLIGHT and BRANCH_SETUP phases
this.orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
// Create git branch with descriptive name
const branchName = this.generateBranchName(taskId, taskTitle, tag);
// Check if we're already on the target branch
const currentBranch = await gitAdapter.getCurrentBranch();
if (currentBranch !== branchName) {
// Only create branch if we're not already on it
await gitAdapter.createAndCheckoutBranch(branchName);
}
// Transition to SUBTASK_LOOP with RED phase
this.orchestrator.transition({
type: 'BRANCH_CREATED',
branchName
});
return this.getStatus();
}
/**
* Resume an existing workflow
*/
async resumeWorkflow(): Promise<WorkflowStatus> {
// Load state
const state = await this.stateManager.load();
// Create new orchestrator with loaded context
this.orchestrator = new WorkflowOrchestrator(state.context);
// Validate and restore state
if (!this.orchestrator.canResumeFromState(state)) {
throw new Error(
'Invalid workflow state. State may be corrupted. Consider starting a new workflow.'
);
}
this.orchestrator.restoreState(state);
// Re-enable auto-persistence
this.orchestrator.enableAutoPersist(async (newState: WorkflowState) => {
await this.stateManager.save(newState);
});
// Initialize activity logger to continue tracking events
this.activityLogger = new WorkflowActivityLogger(
this.orchestrator,
this.stateManager.getActivityLogPath()
);
this.activityLogger.start();
return this.getStatus();
}
/**
* Get current workflow status
*/
getStatus(): WorkflowStatus {
if (!this.orchestrator) {
throw new Error('No active workflow. Start or resume a workflow first.');
}
const context = this.orchestrator.getContext();
const progress = this.orchestrator.getProgress();
const currentSubtask = this.orchestrator.getCurrentSubtask();
return {
taskId: context.taskId,
phase: this.orchestrator.getCurrentPhase(),
tddPhase: this.orchestrator.getCurrentTDDPhase(),
branchName: context.branchName,
currentSubtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts,
maxAttempts: currentSubtask.maxAttempts || 3
}
: undefined,
progress
};
}
/**
* Get workflow context (for accessing full state details)
*/
getContext(): WorkflowContext {
if (!this.orchestrator) {
throw new Error('No active workflow. Start or resume a workflow first.');
}
return this.orchestrator.getContext();
}
/**
* Get next recommended action for AI agent
*/
getNextAction(): NextAction {
if (!this.orchestrator) {
throw new Error('No active workflow. Start or resume a workflow first.');
}
const phase = this.orchestrator.getCurrentPhase();
const tddPhase = this.orchestrator.getCurrentTDDPhase();
const currentSubtask = this.orchestrator.getCurrentSubtask();
// Determine action based on current phase
if (phase === 'COMPLETE') {
return {
action: 'workflow_complete',
description: 'All subtasks completed',
nextSteps:
'All subtasks completed! Review the entire implementation and merge your branch when ready.',
phase
};
}
if (phase === 'FINALIZE') {
return {
action: 'finalize_workflow',
description: 'Finalize and complete the workflow',
nextSteps:
'All subtasks are complete! Use autopilot_finalize to verify no uncommitted changes remain and mark the workflow as complete.',
phase
};
}
if (phase !== 'SUBTASK_LOOP' || !tddPhase || !currentSubtask) {
return {
action: 'unknown',
description: 'Workflow is not in active state',
nextSteps: 'Use autopilot_status to check workflow state.',
phase
};
}
const baseAction = {
phase,
tddPhase,
subtask: {
id: currentSubtask.id,
title: currentSubtask.title
}
};
switch (tddPhase) {
case 'RED':
return {
...baseAction,
action: 'generate_test',
description: 'Generate failing test for current subtask',
nextSteps: `Write failing tests for subtask ${currentSubtask.id}: "${currentSubtask.title}". Create test file(s) that validate the expected behavior. Run tests and use autopilot_complete_phase with results. Note: If all tests pass (0 failures), the feature is already implemented and the subtask will be auto-completed.`
};
case 'GREEN':
return {
...baseAction,
action: 'implement_code',
description: 'Implement feature to make tests pass',
nextSteps: `Implement code to make tests pass for subtask ${currentSubtask.id}: "${currentSubtask.title}". Write the minimal code needed to pass all tests (GREEN phase), then use autopilot_complete_phase with test results.`
};
case 'COMMIT':
return {
...baseAction,
action: 'commit_changes',
description: 'Commit RED-GREEN cycle changes',
nextSteps: `Review and commit your changes for subtask ${currentSubtask.id}: "${currentSubtask.title}". Use autopilot_commit to create the commit and advance to the next subtask.`
};
default:
return {
...baseAction,
action: 'unknown',
description: 'Unknown TDD phase',
nextSteps: 'Use autopilot_status to check workflow state.'
};
}
}
/**
* Complete current TDD phase with test results
*/
async completePhase(testResults: TestResult): Promise<WorkflowStatus> {
if (!this.orchestrator) {
throw new Error('No active workflow. Start or resume a workflow first.');
}
const tddPhase = this.orchestrator.getCurrentTDDPhase();
if (!tddPhase) {
throw new Error('Not in active TDD phase');
}
// Transition based on current phase
switch (tddPhase) {
case 'RED':
this.orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults
});
break;
case 'GREEN':
this.orchestrator.transition({
type: 'GREEN_PHASE_COMPLETE',
testResults
});
break;
case 'COMMIT':
throw new Error(
'Cannot complete COMMIT phase with test results. Use commit() instead.'
);
default:
throw new Error(`Unknown TDD phase: ${tddPhase}`);
}
return this.getStatus();
}
/**
* Commit current changes and advance workflow
*/
async commit(): Promise<WorkflowStatus> {
if (!this.orchestrator) {
throw new Error('No active workflow. Start or resume a workflow first.');
}
const tddPhase = this.orchestrator.getCurrentTDDPhase();
if (tddPhase !== 'COMMIT') {
throw new Error(
`Cannot commit in ${tddPhase} phase. Complete RED and GREEN phases first.`
);
}
// Transition COMMIT phase complete
this.orchestrator.transition({
type: 'COMMIT_COMPLETE'
});
// Check if should advance to next subtask
const progress = this.orchestrator.getProgress();
if (progress.current < progress.total) {
this.orchestrator.transition({ type: 'SUBTASK_COMPLETE' });
} else {
// All subtasks complete
this.orchestrator.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
}
return this.getStatus();
}
/**
* Finalize and complete the workflow
* Validates working tree is clean before marking complete
*/
async finalizeWorkflow(): Promise<WorkflowStatus> {
if (!this.orchestrator) {
throw new Error('No active workflow. Start or resume a workflow first.');
}
const phase = this.orchestrator.getCurrentPhase();
if (phase !== 'FINALIZE') {
throw new Error(
`Cannot finalize workflow in ${phase} phase. Complete all subtasks first.`
);
}
// Check working tree is clean
const gitAdapter = new GitAdapter(this.projectRoot);
const statusSummary = await gitAdapter.getStatusSummary();
if (!statusSummary.isClean) {
throw new Error(
`Cannot finalize workflow: working tree has uncommitted changes.\n` +
`Staged: ${statusSummary.staged}, Modified: ${statusSummary.modified}, ` +
`Deleted: ${statusSummary.deleted}, Untracked: ${statusSummary.untracked}\n` +
`Please commit all changes before finalizing the workflow.`
);
}
// Transition to COMPLETE
this.orchestrator.transition({ type: 'FINALIZE_COMPLETE' });
return this.getStatus();
}
/**
* Abort current workflow
*/
async abortWorkflow(): Promise<void> {
if (this.orchestrator) {
this.orchestrator.transition({ type: 'ABORT' });
}
// Delete state file
await this.stateManager.delete();
this.orchestrator = undefined;
}
/**
* Generate a descriptive git branch name
* Format: tag-name/task-id-task-title or task-id-task-title
*/
private generateBranchName(
taskId: string,
taskTitle: string,
tag?: string
): string {
// Sanitize task title for branch name
const sanitizedTitle = taskTitle
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with dash
.replace(/^-+|-+$/g, '') // Remove leading/trailing dashes
.substring(0, 50); // Limit length
// Format task ID for branch name
const formattedTaskId = taskId.replace(/\./g, '-');
// Add tag prefix if tag is provided
const tagPrefix = tag ? `${tag}/` : '';
return `${tagPrefix}task-${formattedTaskId}-${sanitizedTitle}`;
}
}

View File

@@ -0,0 +1,182 @@
/**
* Activity.jsonl append-only logging system for workflow tracking.
* Uses newline-delimited JSON (JSONL) format for structured event logging.
*
* @module activity-logger
*/
import fs from 'fs-extra';
import path from 'path';
/**
* Activity log entry structure
*/
export interface ActivityEvent {
timestamp: string;
type: string;
[key: string]: any;
}
/**
* Filter criteria for activity log queries
*/
export interface ActivityFilter {
type?: string;
timestampFrom?: string;
timestampTo?: string;
predicate?: (event: ActivityEvent) => boolean;
}
/**
* Appends an activity event to the log file.
* Uses atomic append operations to ensure data integrity.
*
* @param {string} activityPath - Path to the activity.jsonl file
* @param {Omit<ActivityEvent, 'timestamp'>} event - Event data to log (timestamp added automatically)
* @returns {Promise<void>}
*
* @example
* await logActivity('/path/to/activity.jsonl', {
* type: 'phase-start',
* phase: 'red'
* });
*/
export async function logActivity(
activityPath: string,
event: Omit<ActivityEvent, 'timestamp'>
): Promise<void> {
// Add timestamp to event
const logEntry = {
...event,
timestamp: new Date().toISOString()
} as ActivityEvent;
// Ensure directory exists
await fs.ensureDir(path.dirname(activityPath));
// Convert to JSONL format (single line with newline)
const line = JSON.stringify(logEntry) + '\n';
// Append to file atomically
// Using 'a' flag ensures atomic append on most systems
await fs.appendFile(activityPath, line, 'utf-8');
}
/**
* Reads and parses all events from an activity log file.
* Returns events in chronological order.
*
* @param {string} activityPath - Path to the activity.jsonl file
* @returns {Promise<ActivityEvent[]>} Array of activity events
* @throws {Error} If file contains invalid JSON
*
* @example
* const events = await readActivityLog('/path/to/activity.jsonl');
* console.log(`Found ${events.length} events`);
*/
export async function readActivityLog(
activityPath: string
): Promise<ActivityEvent[]> {
// Return empty array if file doesn't exist
if (!(await fs.pathExists(activityPath))) {
return [];
}
// Read file content
const content = await fs.readFile(activityPath, 'utf-8');
// Parse JSONL (newline-delimited JSON)
const lines = content.trim().split('\n');
const events: ActivityEvent[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip empty lines
if (!line) {
continue;
}
// Parse JSON
try {
const event = JSON.parse(line);
events.push(event);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Invalid JSON at line ${i + 1}: ${errorMessage}`);
}
}
return events;
}
/**
* Filters activity log events based on criteria.
* Supports filtering by event type, timestamp range, and custom predicates.
*
* @param {string} activityPath - Path to the activity.jsonl file
* @param {ActivityFilter} filter - Filter criteria
* @returns {Promise<ActivityEvent[]>} Filtered array of events
*
* @example
* // Filter by event type
* const phaseEvents = await filterActivityLog('/path/to/activity.jsonl', {
* type: 'phase-start'
* });
*
* // Filter by timestamp range
* const recentEvents = await filterActivityLog('/path/to/activity.jsonl', {
* timestampFrom: '2024-01-15T10:00:00.000Z'
* });
*
* // Filter with custom predicate
* const failedTests = await filterActivityLog('/path/to/activity.jsonl', {
* predicate: (event) => event.type === 'test-run' && event.result === 'fail'
* });
*/
export async function filterActivityLog(
activityPath: string,
filter: ActivityFilter & Record<string, any>
): Promise<ActivityEvent[]> {
const events = await readActivityLog(activityPath);
return events.filter((event) => {
// Filter by type
if (filter.type && event.type !== filter.type) {
return false;
}
// Filter by timestamp range
if (filter.timestampFrom && event.timestamp < filter.timestampFrom) {
return false;
}
if (filter.timestampTo && event.timestamp > filter.timestampTo) {
return false;
}
// Filter by custom predicate
if (filter.predicate && !filter.predicate(event)) {
return false;
}
// Filter by other fields (exact match)
for (const [key, value] of Object.entries(filter)) {
if (
key === 'type' ||
key === 'timestampFrom' ||
key === 'timestampTo' ||
key === 'predicate'
) {
continue;
}
if (event[key] !== value) {
return false;
}
}
return true;
});
}

View File

@@ -8,6 +8,15 @@ export { FileStorage } from './file-storage/index.js';
export { ApiStorage, type ApiStorageConfig } from './api-storage.js';
export { StorageFactory } from './storage-factory.js';
// Export activity logger
export {
logActivity,
readActivityLog,
filterActivityLog,
type ActivityEvent,
type ActivityFilter
} from './activity-logger.js';
// Export storage interface and types
export type {
IStorage,

View File

@@ -32,6 +32,21 @@ export {
type GitHubRepoInfo
} from './git-utils.js';
// Export path normalization utilities
export {
normalizeProjectPath,
denormalizeProjectPath,
isValidNormalizedPath
} from './path-normalizer.js';
// Export run ID generation utilities
export {
generateRunId,
isValidRunId,
parseRunId,
compareRunIds
} from './run-id-generator.js';
// Additional utility exports
/**

View File

@@ -0,0 +1,282 @@
import { describe, it, expect } from 'vitest';
import {
normalizeProjectPath,
denormalizeProjectPath,
isValidNormalizedPath
} from './path-normalizer.js';
describe('Path Normalizer (base64url encoding)', () => {
describe('normalizeProjectPath', () => {
it('should encode Unix paths to base64url', () => {
const input = '/Users/test/projects/myapp';
const normalized = normalizeProjectPath(input);
// Should be valid base64url (only A-Z, a-z, 0-9, -, _)
expect(/^[A-Za-z0-9_-]+$/.test(normalized)).toBe(true);
// Should not contain slashes
expect(normalized).not.toContain('/');
expect(normalized).not.toContain('\\');
});
it('should encode Windows paths to base64url', () => {
const input = 'C:\\Users\\test\\projects\\myapp';
const normalized = normalizeProjectPath(input);
// Should be valid base64url
expect(/^[A-Za-z0-9_-]+$/.test(normalized)).toBe(true);
expect(normalized).not.toContain('/');
expect(normalized).not.toContain('\\');
});
it('should encode paths with hyphens (preserving them for round-trip)', () => {
const input = '/projects/my-app';
const normalized = normalizeProjectPath(input);
// Should be valid base64url
expect(/^[A-Za-z0-9_-]+$/.test(normalized)).toBe(true);
// Hyphens in base64url are from encoding, not original path
expect(isValidNormalizedPath(normalized)).toBe(true);
});
it('should encode paths with special characters', () => {
const input = '/projects/myapp (v2)';
const normalized = normalizeProjectPath(input);
// Should be valid base64url
expect(/^[A-Za-z0-9_-]+$/.test(normalized)).toBe(true);
});
it('should encode relative paths', () => {
const input = './projects/app';
const normalized = normalizeProjectPath(input);
// Should be valid base64url
expect(/^[A-Za-z0-9_-]+$/.test(normalized)).toBe(true);
});
it('should handle empty string', () => {
const input = '';
const expected = '';
expect(normalizeProjectPath(input)).toBe(expected);
});
it('should encode single directory', () => {
const input = 'project';
const normalized = normalizeProjectPath(input);
// Should be valid base64url
expect(/^[A-Za-z0-9_-]+$/.test(normalized)).toBe(true);
});
it('should encode paths with multiple consecutive slashes', () => {
const input = '/Users//test///project';
const normalized = normalizeProjectPath(input);
// Should be valid base64url
expect(/^[A-Za-z0-9_-]+$/.test(normalized)).toBe(true);
});
});
describe('denormalizeProjectPath', () => {
it('should decode base64url back to original path', () => {
const original = '/Users/test/projects/myapp';
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
expect(denormalized).toBe(original);
});
it('should decode base64url for Windows paths', () => {
const original = 'C:\\Users\\test\\project';
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
expect(denormalized).toBe(original);
});
it('should handle empty string', () => {
const input = '';
const expected = '';
expect(denormalizeProjectPath(input)).toBe(expected);
});
it('should preserve hyphens in directory names (no longer a limitation!)', () => {
const original = '/projects/my-app';
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
// With base64url, hyphens are preserved correctly
expect(denormalized).toBe(original);
});
it('should handle invalid base64url gracefully', () => {
// Invalid base64url - should return the input as fallback
const invalid = 'not@valid#base64url';
const result = denormalizeProjectPath(invalid);
// Should return input unchanged for backward compatibility
expect(result).toBe(invalid);
});
});
describe('isValidNormalizedPath', () => {
it('should return true for valid base64url strings', () => {
// Valid base64url characters: A-Z, a-z, 0-9, -, _
expect(isValidNormalizedPath('VXNlcnMtdGVzdC1wcm9qZWN0')).toBe(true);
expect(isValidNormalizedPath('abc123_-ABC')).toBe(true);
});
it('should return true for base64url with hyphens and underscores', () => {
expect(isValidNormalizedPath('test-path_encoded')).toBe(true);
});
it('should return false for paths with slashes', () => {
expect(isValidNormalizedPath('Users/test/project')).toBe(false);
});
it('should return false for paths with backslashes', () => {
expect(isValidNormalizedPath('Users\\test\\project')).toBe(false);
});
it('should return true for empty string', () => {
expect(isValidNormalizedPath('')).toBe(true);
});
it('should return false for strings with special characters not in base64url', () => {
// Base64url only allows: A-Z, a-z, 0-9, -, _
expect(isValidNormalizedPath('my-app (v2)')).toBe(false); // parentheses and spaces not allowed
expect(isValidNormalizedPath('test@example')).toBe(false); // @ not allowed
expect(isValidNormalizedPath('test+value')).toBe(false); // + not allowed
});
it('should validate normalized paths correctly', () => {
const path = '/Users/test/my-app';
const normalized = normalizeProjectPath(path);
expect(isValidNormalizedPath(normalized)).toBe(true);
});
});
describe('Round-trip conversion', () => {
it('should perfectly preserve ALL Unix paths (including those with hyphens)', () => {
const originalPaths = [
'/Users/test/projects/myapp',
'/root/deep/nested/path',
'./relative/path',
'/projects/my-app', // Now works correctly!
'/path/with-multiple-hyphens/in-names'
];
for (const original of originalPaths) {
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
// Perfect round-trip with base64url encoding
expect(denormalized).toBe(original);
}
});
it('should perfectly preserve Windows paths (including drive letters)', () => {
const originalPaths = [
'C:\\Users\\test\\project',
'D:\\Projects\\my-app',
'E:\\path\\with-hyphens\\test'
];
for (const original of originalPaths) {
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
// Perfect round-trip - drive letters and colons preserved
expect(denormalized).toBe(original);
}
});
it('should preserve paths with special characters', () => {
const originalPaths = [
'/projects/my app (v2)',
'/path/with spaces/test',
'/path/with-dashes-and_underscores',
'/path/with.dots.and-dashes'
];
for (const original of originalPaths) {
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
// Perfect round-trip for all special characters
expect(denormalized).toBe(original);
}
});
it('should handle mixed slashes and preserve exact path structure', () => {
const original = '/Users/test\\mixed/path';
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
// Exact preservation of mixed slashes
expect(denormalized).toBe(original);
});
it('should preserve multiple consecutive slashes', () => {
const original = '/Users//test///project';
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
// Exact preservation of all slashes
expect(denormalized).toBe(original);
});
});
describe('Cross-platform consistency', () => {
it('should produce filesystem-safe normalized output for all platforms', () => {
const unixPath = '/Users/test/project';
const windowsPath = 'C:\\Users\\test\\project';
const normalizedUnix = normalizeProjectPath(unixPath);
const normalizedWindows = normalizeProjectPath(windowsPath);
// Both should be valid base64url (no slashes or backslashes)
expect(normalizedUnix).not.toContain('/');
expect(normalizedUnix).not.toContain('\\');
expect(normalizedWindows).not.toContain('/');
expect(normalizedWindows).not.toContain('\\');
// Both should be valid base64url format
expect(isValidNormalizedPath(normalizedUnix)).toBe(true);
expect(isValidNormalizedPath(normalizedWindows)).toBe(true);
});
it('should produce different normalized outputs for different paths', () => {
// Unix and Windows paths are different, so should produce different encoded values
const unixPath = '/Users/test/project';
const windowsPath = 'C:\\Users\\test\\project';
const normalizedUnix = normalizeProjectPath(unixPath);
const normalizedWindows = normalizeProjectPath(windowsPath);
// Different inputs should produce different outputs
expect(normalizedUnix).not.toBe(normalizedWindows);
// But both should denormalize back to their originals
expect(denormalizeProjectPath(normalizedUnix)).toBe(unixPath);
expect(denormalizeProjectPath(normalizedWindows)).toBe(windowsPath);
});
it('should handle Unicode characters in paths', () => {
const unicodePaths = [
'/Users/测试/project',
'/Users/test/プロジェクト',
'/Users/тест/project'
];
for (const original of unicodePaths) {
const normalized = normalizeProjectPath(original);
const denormalized = denormalizeProjectPath(normalized);
// Perfect round-trip for Unicode
expect(denormalized).toBe(original);
expect(isValidNormalizedPath(normalized)).toBe(true);
}
});
});
});

View File

@@ -0,0 +1,76 @@
/**
* Path normalization utilities for global storage system.
* Converts project paths to storage-safe directory names using base64url encoding.
*
* This provides a bijective (one-to-one) mapping that preserves all characters
* and supports perfect round-trip conversion between paths and storage names.
*
* @module path-normalizer
*/
/**
* Normalizes a project path to a storage-safe directory name using base64url encoding.
* This encoding is filesystem-safe (no slashes, backslashes, or special characters)
* and fully reversible, preserving hyphens and all other characters in paths.
*
* @param {string} projectPath - The project path to normalize
* @returns {string} The base64url-encoded path safe for use as a directory name
*
* @example
* normalizeProjectPath('/Users/test/project') // returns base64url encoded string
* normalizeProjectPath('C:\\Users\\test') // returns base64url encoded string
* normalizeProjectPath('/projects/my-app') // returns base64url encoded string (hyphens preserved)
*/
export function normalizeProjectPath(projectPath: string): string {
if (!projectPath) {
return '';
}
// Use base64url encoding: filesystem-safe and fully reversible
return Buffer.from(projectPath, 'utf-8').toString('base64url');
}
/**
* Denormalizes a storage directory name back to the original path.
* Decodes base64url-encoded paths with perfect fidelity.
*
* @param {string} normalizedPath - The base64url-encoded path to decode
* @returns {string} The original path with all characters preserved
*
* @example
* denormalizeProjectPath(normalizeProjectPath('/Users/test/project')) // returns '/Users/test/project'
* denormalizeProjectPath(normalizeProjectPath('/projects/my-app')) // returns '/projects/my-app'
*/
export function denormalizeProjectPath(normalizedPath: string): string {
if (!normalizedPath) {
return '';
}
// Validate that input is valid base64url before attempting to decode
if (!isValidNormalizedPath(normalizedPath)) {
// Return original string for backward compatibility with non-base64url inputs
return normalizedPath;
}
return Buffer.from(normalizedPath, 'base64url').toString('utf-8');
}
/**
* Validates whether a path is in normalized (base64url) format.
* Valid base64url strings contain only: A-Z, a-z, 0-9, -, _
*
* @param {string} path - The path to validate
* @returns {boolean} True if the path is in normalized base64url format
*
* @example
* isValidNormalizedPath('VXNlcnMvdGVzdC9wcm9qZWN0') // returns true (valid base64url)
* isValidNormalizedPath('Users/test/project') // returns false (contains slashes)
*/
export function isValidNormalizedPath(path: string): boolean {
if (path === '') {
return true;
}
// Check if path is valid base64url: only A-Z, a-z, 0-9, -, _
return /^[A-Za-z0-9_-]*$/.test(path);
}

View File

@@ -0,0 +1,266 @@
import { describe, it, expect } from 'vitest';
import {
generateRunId,
isValidRunId,
parseRunId,
compareRunIds
} from './run-id-generator.js';
describe('Run ID Generator', () => {
describe('generateRunId', () => {
it('should generate a valid ISO 8601 timestamp-based ID', () => {
const runId = generateRunId();
// Should be in ISO 8601 format with milliseconds
expect(runId).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
it('should generate unique IDs when called multiple times', () => {
const id1 = generateRunId();
const id2 = generateRunId();
const id3 = generateRunId();
expect(id1).not.toBe(id2);
expect(id2).not.toBe(id3);
expect(id1).not.toBe(id3);
});
it('should generate chronologically ordered IDs', () => {
const id1 = generateRunId();
// Small delay to ensure different timestamp
const id2 = generateRunId();
expect(id2 > id1).toBe(true);
});
it('should use current time by default', () => {
const before = new Date().toISOString();
const runId = generateRunId();
const after = new Date().toISOString();
expect(runId >= before).toBe(true);
expect(runId <= after).toBe(true);
});
it('should accept custom Date object', () => {
const customDate = new Date('2024-01-15T10:30:45.123Z');
const runId = generateRunId(customDate);
expect(runId).toBe('2024-01-15T10:30:45.123Z');
});
it('should handle date at year boundary', () => {
const newYear = new Date('2025-01-01T00:00:00.000Z');
const runId = generateRunId(newYear);
expect(runId).toBe('2025-01-01T00:00:00.000Z');
});
it('should handle millisecond precision correctly', () => {
const dateWithMs = new Date('2024-03-15T14:22:33.999Z');
const runId = generateRunId(dateWithMs);
expect(runId).toBe('2024-03-15T14:22:33.999Z');
});
});
describe('isValidRunId', () => {
it('should return true for valid ISO 8601 timestamp', () => {
expect(isValidRunId('2024-01-15T10:30:45.123Z')).toBe(true);
});
it('should return true for generated run IDs', () => {
const runId = generateRunId();
expect(isValidRunId(runId)).toBe(true);
});
it('should return false for invalid format', () => {
expect(isValidRunId('not-a-timestamp')).toBe(false);
expect(isValidRunId('2024-01-15')).toBe(false);
expect(isValidRunId('2024-01-15T10:30:45')).toBe(false); // missing Z
expect(isValidRunId('2024-01-15 10:30:45.123Z')).toBe(false); // space instead of T
});
it('should return false for empty string', () => {
expect(isValidRunId('')).toBe(false);
});
it('should return false for null or undefined', () => {
expect(isValidRunId(null)).toBe(false);
expect(isValidRunId(undefined)).toBe(false);
});
it('should return false for invalid dates', () => {
expect(isValidRunId('2024-13-01T10:30:45.123Z')).toBe(false); // invalid month
expect(isValidRunId('2024-01-32T10:30:45.123Z')).toBe(false); // invalid day
expect(isValidRunId('2024-01-15T25:30:45.123Z')).toBe(false); // invalid hour
});
it('should return true for edge case valid dates', () => {
expect(isValidRunId('2024-02-29T23:59:59.999Z')).toBe(true); // leap year
expect(isValidRunId('2025-01-01T00:00:00.000Z')).toBe(true); // year boundary
});
it('should return false for missing milliseconds', () => {
expect(isValidRunId('2024-01-15T10:30:45Z')).toBe(false);
});
it('should return false for non-UTC timezone', () => {
expect(isValidRunId('2024-01-15T10:30:45.123+01:00')).toBe(false);
});
});
describe('parseRunId', () => {
it('should parse valid run ID to Date object', () => {
const runId = '2024-01-15T10:30:45.123Z';
const date = parseRunId(runId);
expect(date).toBeInstanceOf(Date);
expect(date?.toISOString()).toBe(runId);
});
it('should parse generated run ID', () => {
const originalDate = new Date('2024-03-20T15:45:30.500Z');
const runId = generateRunId(originalDate);
const parsedDate = parseRunId(runId);
expect(parsedDate?.getTime()).toBe(originalDate.getTime());
});
it('should return null for invalid run ID', () => {
expect(parseRunId('invalid')).toBe(null);
expect(parseRunId('')).toBe(null);
expect(parseRunId(null)).toBe(null);
expect(parseRunId(undefined)).toBe(null);
});
it('should handle edge case dates correctly', () => {
const leapYear = '2024-02-29T12:00:00.000Z';
const parsed = parseRunId(leapYear);
expect(parsed?.toISOString()).toBe(leapYear);
});
});
describe('compareRunIds', () => {
it('should return negative when first ID is earlier', () => {
const earlier = '2024-01-15T10:00:00.000Z';
const later = '2024-01-15T11:00:00.000Z';
expect(compareRunIds(earlier, later)).toBeLessThan(0);
});
it('should return positive when first ID is later', () => {
const earlier = '2024-01-15T10:00:00.000Z';
const later = '2024-01-15T11:00:00.000Z';
expect(compareRunIds(later, earlier)).toBeGreaterThan(0);
});
it('should return zero when IDs are equal', () => {
const runId = '2024-01-15T10:00:00.000Z';
expect(compareRunIds(runId, runId)).toBe(0);
});
it('should handle millisecond differences', () => {
const id1 = '2024-01-15T10:00:00.100Z';
const id2 = '2024-01-15T10:00:00.200Z';
expect(compareRunIds(id1, id2)).toBeLessThan(0);
expect(compareRunIds(id2, id1)).toBeGreaterThan(0);
});
it('should handle cross-day comparisons', () => {
const yesterday = '2024-01-14T23:59:59.999Z';
const today = '2024-01-15T00:00:00.000Z';
expect(compareRunIds(yesterday, today)).toBeLessThan(0);
});
it('should handle cross-year comparisons', () => {
const lastYear = '2023-12-31T23:59:59.999Z';
const thisYear = '2024-01-01T00:00:00.000Z';
expect(compareRunIds(lastYear, thisYear)).toBeLessThan(0);
});
it('should throw error for invalid run IDs', () => {
const valid = '2024-01-15T10:00:00.000Z';
expect(() => compareRunIds('invalid', valid)).toThrow();
expect(() => compareRunIds(valid, 'invalid')).toThrow();
expect(() => compareRunIds('invalid', 'invalid')).toThrow();
});
});
describe('Collision detection', () => {
it('should generate different IDs in rapid succession', () => {
const ids = new Set();
const count = 100;
for (let i = 0; i < count; i++) {
ids.add(generateRunId());
}
// All IDs should be unique
expect(ids.size).toBe(count);
});
it('should handle high-frequency generation', () => {
const ids = [];
const iterations = 1000;
for (let i = 0; i < iterations; i++) {
ids.push(generateRunId());
}
// Check uniqueness
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(iterations);
// Check chronological order
for (let i = 1; i < ids.length; i++) {
expect(compareRunIds(ids[i - 1], ids[i])).toBeLessThanOrEqual(0);
}
});
});
describe('Chronological ordering', () => {
it('should allow sorting run IDs chronologically', () => {
const ids = [
'2024-01-15T14:00:00.000Z',
'2024-01-15T10:00:00.000Z',
'2024-01-15T12:00:00.000Z',
'2024-01-14T23:00:00.000Z',
'2024-01-16T08:00:00.000Z'
];
const sorted = [...ids].sort(compareRunIds);
expect(sorted).toEqual([
'2024-01-14T23:00:00.000Z',
'2024-01-15T10:00:00.000Z',
'2024-01-15T12:00:00.000Z',
'2024-01-15T14:00:00.000Z',
'2024-01-16T08:00:00.000Z'
]);
});
it('should handle reverse chronological sorting', () => {
const ids = [
'2024-01-15T10:00:00.000Z',
'2024-01-15T14:00:00.000Z',
'2024-01-15T12:00:00.000Z'
];
const sorted = [...ids].sort((a, b) => compareRunIds(b, a));
expect(sorted).toEqual([
'2024-01-15T14:00:00.000Z',
'2024-01-15T12:00:00.000Z',
'2024-01-15T10:00:00.000Z'
]);
});
});
});

View File

@@ -0,0 +1,129 @@
/**
* Run ID generation and validation utilities for the global storage system.
* Uses ISO 8601 timestamps with millisecond precision for unique, chronologically-ordered run IDs.
*
* @module run-id-generator
*/
// Collision detection state
let lastTimestamp = 0;
let counter = 0;
/**
* Generates a unique run ID using ISO 8601 timestamp format with millisecond precision.
* The ID is guaranteed to be chronologically sortable and URL-safe.
* Includes collision detection to ensure uniqueness even when called in rapid succession.
*
* @param {Date} [date=new Date()] - Optional date to use for the run ID. Defaults to current time.
* @returns {string} ISO 8601 formatted timestamp (e.g., '2024-01-15T10:30:45.123Z')
*
* @example
* generateRunId() // returns '2024-01-15T10:30:45.123Z'
* generateRunId(new Date('2024-01-15T10:00:00.000Z')) // returns '2024-01-15T10:00:00.000Z'
*/
export function generateRunId(date: Date = new Date()): string {
const timestamp = date.getTime();
// Collision detection: if same millisecond, wait for next millisecond
if (timestamp === lastTimestamp) {
counter++;
// Wait for next millisecond to ensure uniqueness
let newTimestamp = timestamp;
while (newTimestamp === timestamp) {
newTimestamp = Date.now();
}
date = new Date(newTimestamp);
lastTimestamp = newTimestamp;
counter = 0;
} else {
lastTimestamp = timestamp;
counter = 0;
}
return date.toISOString();
}
/**
* Validates whether a string is a valid run ID.
* A valid run ID must be:
* - In ISO 8601 format with milliseconds
* - In UTC timezone (ends with 'Z')
* - A valid date when parsed
*
* @param {any} runId - The value to validate
* @returns {boolean} True if the value is a valid run ID
*
* @example
* isValidRunId('2024-01-15T10:30:45.123Z') // returns true
* isValidRunId('invalid') // returns false
* isValidRunId('2024-01-15T10:30:45Z') // returns false (missing milliseconds)
*/
export function isValidRunId(runId: any): boolean {
if (!runId || typeof runId !== 'string') {
return false;
}
// Check format: YYYY-MM-DDTHH:mm:ss.sssZ
const isoFormatRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
if (!isoFormatRegex.test(runId)) {
return false;
}
// Validate it's a real date
const date = new Date(runId);
if (isNaN(date.getTime())) {
return false;
}
// Ensure the parsed date matches the input (catches invalid dates like 2024-13-01)
return date.toISOString() === runId;
}
/**
* Parses a run ID string into a Date object.
*
* @param {any} runId - The run ID to parse
* @returns {Date | null} Date object if valid, null if invalid
*
* @example
* parseRunId('2024-01-15T10:30:45.123Z') // returns Date object
* parseRunId('invalid') // returns null
*/
export function parseRunId(runId: any): Date | null {
if (!isValidRunId(runId)) {
return null;
}
return new Date(runId);
}
/**
* Compares two run IDs chronologically.
* Returns a negative number if id1 is earlier, positive if id1 is later, or 0 if equal.
* Can be used as a comparator function for Array.sort().
*
* @param {string} id1 - First run ID to compare
* @param {string} id2 - Second run ID to compare
* @returns {number} Negative if id1 < id2, positive if id1 > id2, zero if equal
* @throws {Error} If either run ID is invalid
*
* @example
* compareRunIds('2024-01-15T10:00:00.000Z', '2024-01-15T11:00:00.000Z') // returns negative number
* ['2024-01-15T14:00:00.000Z', '2024-01-15T10:00:00.000Z'].sort(compareRunIds)
* // returns ['2024-01-15T10:00:00.000Z', '2024-01-15T14:00:00.000Z']
*/
export function compareRunIds(id1: string, id2: string): number {
if (!isValidRunId(id1)) {
throw new Error(`Invalid run ID: ${id1}`);
}
if (!isValidRunId(id2)) {
throw new Error(`Invalid run ID: ${id2}`);
}
// String comparison works for ISO 8601 timestamps
// because they are lexicographically sortable
if (id1 < id2) return -1;
if (id1 > id2) return 1;
return 0;
}

View File

@@ -0,0 +1,150 @@
/**
* Workflow phase definitions
*/
export type WorkflowPhase =
| 'PREFLIGHT'
| 'BRANCH_SETUP'
| 'SUBTASK_LOOP'
| 'FINALIZE'
| 'COMPLETE';
/**
* TDD cycle phases within subtask loop
*/
export type TDDPhase = 'RED' | 'GREEN' | 'COMMIT';
/**
* Workflow state context
*/
export interface WorkflowContext {
taskId: string;
subtasks: SubtaskInfo[];
currentSubtaskIndex: number;
currentTDDPhase?: TDDPhase;
branchName?: string;
errors: WorkflowError[];
metadata: Record<string, unknown>;
lastTestResults?: TestResult;
}
/**
* Test result from test execution
*/
export interface TestResult {
total: number;
passed: number;
failed: number;
skipped: number;
phase: 'RED' | 'GREEN';
}
/**
* Subtask information
*/
export interface SubtaskInfo {
id: string;
title: string;
status: 'pending' | 'in-progress' | 'completed' | 'failed';
attempts: number;
maxAttempts?: number;
}
/**
* Workflow error information
*/
export interface WorkflowError {
phase: WorkflowPhase;
message: string;
timestamp: Date;
recoverable: boolean;
}
/**
* State machine state
*/
export interface WorkflowState {
phase: WorkflowPhase;
context: WorkflowContext;
}
/**
* State transition event types
*/
export type WorkflowEvent =
| { type: 'PREFLIGHT_COMPLETE' }
| { type: 'BRANCH_CREATED'; branchName: string }
| { type: 'SUBTASK_START'; subtaskId: string }
| { type: 'RED_PHASE_COMPLETE'; testResults?: TestResult }
| { type: 'GREEN_PHASE_COMPLETE'; testResults?: TestResult }
| { type: 'COMMIT_COMPLETE' }
| { type: 'SUBTASK_COMPLETE' }
| { type: 'ALL_SUBTASKS_COMPLETE' }
| { type: 'FINALIZE_COMPLETE' }
| { type: 'ERROR'; error: WorkflowError }
| { type: 'RETRY' }
| { type: 'ABORT' };
/**
* State transition definition
*/
export interface StateTransition {
from: WorkflowPhase;
to: WorkflowPhase;
event: WorkflowEvent['type'];
guard?: (context: WorkflowContext) => boolean;
}
/**
* State machine configuration
*/
export interface StateMachineConfig {
initialPhase: WorkflowPhase;
transitions: StateTransition[];
}
/**
* Workflow event listener
*/
export type WorkflowEventListener = (event: WorkflowEventData) => void;
/**
* Comprehensive event data for workflow events
*/
export interface WorkflowEventData {
type: WorkflowEventType;
timestamp: Date;
phase: WorkflowPhase;
tddPhase?: TDDPhase;
subtaskId?: string;
data?: Record<string, unknown>;
}
/**
* All possible workflow event types
*/
export type WorkflowEventType =
| 'workflow:started'
| 'workflow:completed'
| 'workflow:error'
| 'workflow:resumed'
| 'phase:entered'
| 'phase:exited'
| 'tdd:feature-already-implemented'
| 'tdd:red:started'
| 'tdd:red:completed'
| 'tdd:green:started'
| 'tdd:green:completed'
| 'tdd:commit:started'
| 'tdd:commit:completed'
| 'subtask:started'
| 'subtask:completed'
| 'subtask:failed'
| 'test:run'
| 'test:passed'
| 'test:failed'
| 'git:branch:created'
| 'git:commit:created'
| 'error:occurred'
| 'state:persisted'
| 'progress:updated'
| 'adapter:configured';

View File

@@ -0,0 +1,152 @@
/**
* @fileoverview WorkflowActivityLogger - Logs all workflow events to activity.jsonl
*
* Subscribes to all WorkflowOrchestrator events and persists them to a JSONL file
* for debugging, auditing, and workflow analysis.
*/
import type { WorkflowOrchestrator } from './workflow-orchestrator.js';
import type { WorkflowEventData, WorkflowEventType } from './types.js';
import { logActivity, type ActivityEvent } from '../storage/activity-logger.js';
import { getLogger } from '../logger/index.js';
/**
* All workflow event types that should be logged
*/
const WORKFLOW_EVENT_TYPES: WorkflowEventType[] = [
'workflow:started',
'workflow:completed',
'workflow:error',
'workflow:resumed',
'phase:entered',
'phase:exited',
'tdd:feature-already-implemented',
'tdd:red:started',
'tdd:red:completed',
'tdd:green:started',
'tdd:green:completed',
'tdd:commit:started',
'tdd:commit:completed',
'subtask:started',
'subtask:completed',
'subtask:failed',
'test:run',
'test:passed',
'test:failed',
'git:branch:created',
'git:commit:created',
'error:occurred',
'state:persisted',
'progress:updated',
'adapter:configured'
];
/**
* Logs all workflow events to an activity.jsonl file
*/
export class WorkflowActivityLogger {
private readonly activityLogPath: string;
private readonly orchestrator: WorkflowOrchestrator;
private readonly logger = getLogger('WorkflowActivityLogger');
private readonly listenerMap: Map<
WorkflowEventType,
(event: WorkflowEventData) => void
> = new Map();
private isActive = false;
constructor(orchestrator: WorkflowOrchestrator, activityLogPath: string) {
this.orchestrator = orchestrator;
this.activityLogPath = activityLogPath;
}
/**
* Start logging workflow events
*/
start(): void {
if (this.isActive) {
this.logger.warn('Activity logger is already active');
return;
}
// Subscribe to all workflow events, storing listener references for cleanup
WORKFLOW_EVENT_TYPES.forEach((eventType) => {
const listener = (event: WorkflowEventData) => this.logEvent(event);
this.listenerMap.set(eventType, listener);
this.orchestrator.on(eventType, listener);
});
this.isActive = true;
this.logger.debug(
`Activity logger started, logging to: ${this.activityLogPath}`
);
}
/**
* Stop logging workflow events and remove all listeners
*/
stop(): void {
if (!this.isActive) {
return;
}
// Remove all registered listeners
this.listenerMap.forEach((listener, eventType) => {
this.orchestrator.off(eventType, listener);
});
// Clear the listener map
this.listenerMap.clear();
this.isActive = false;
this.logger.debug('Activity logger stopped and listeners removed');
}
/**
* Log a workflow event to the activity log
*/
private async logEvent(event: WorkflowEventData): Promise<void> {
if (!this.isActive) {
return;
}
try {
// Convert timestamp to ISO string, handling both Date objects and string/number timestamps
const ts =
(event.timestamp as any) instanceof Date
? (event.timestamp as Date).toISOString()
: new Date(event.timestamp as any).toISOString();
// Convert WorkflowEventData to ActivityEvent format
const activityEvent: Omit<ActivityEvent, 'timestamp'> = {
type: event.type,
phase: event.phase,
tddPhase: event.tddPhase,
subtaskId: event.subtaskId,
// Event timestamp kept as ISO for readability; storage layer adds its own "timestamp"
eventTimestamp: ts,
...(event.data || {})
};
await logActivity(this.activityLogPath, activityEvent);
} catch (error: any) {
// Log errors but don't throw - we don't want activity logging to break the workflow
this.logger.error(
`Failed to log activity event ${event.type}: ${error.message}`
);
}
}
/**
* Get the path to the activity log file
*/
getActivityLogPath(): string {
return this.activityLogPath;
}
/**
* Check if the logger is currently active
*/
isLogging(): boolean {
return this.isActive;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,698 @@
import type {
WorkflowPhase,
TDDPhase,
WorkflowContext,
WorkflowEvent,
WorkflowState,
StateTransition,
WorkflowEventType,
WorkflowEventData,
WorkflowEventListener,
SubtaskInfo
} from './types.js';
import type { TestResultValidator } from '../services/test-result-validator.js';
/**
* Lightweight state machine for TDD workflow orchestration
*/
export class WorkflowOrchestrator {
private currentPhase: WorkflowPhase;
private context: WorkflowContext;
private readonly transitions: StateTransition[];
private readonly eventListeners: Map<
WorkflowEventType,
Set<WorkflowEventListener>
>;
private persistCallback?: (state: WorkflowState) => void | Promise<void>;
private autoPersistEnabled: boolean = false;
private readonly phaseGuards: Map<
WorkflowPhase,
(context: WorkflowContext) => boolean
>;
private aborted: boolean = false;
private testResultValidator?: TestResultValidator;
private gitOperationHook?: (operation: string, data?: unknown) => void;
private executeHook?: (command: string, context: WorkflowContext) => void;
constructor(initialContext: WorkflowContext) {
this.currentPhase = 'PREFLIGHT';
this.context = { ...initialContext };
this.transitions = this.defineTransitions();
this.eventListeners = new Map();
this.phaseGuards = new Map();
}
/**
* Define valid state transitions
*/
private defineTransitions(): StateTransition[] {
return [
{
from: 'PREFLIGHT',
to: 'BRANCH_SETUP',
event: 'PREFLIGHT_COMPLETE'
},
{
from: 'BRANCH_SETUP',
to: 'SUBTASK_LOOP',
event: 'BRANCH_CREATED'
},
{
from: 'SUBTASK_LOOP',
to: 'FINALIZE',
event: 'ALL_SUBTASKS_COMPLETE'
},
{
from: 'FINALIZE',
to: 'COMPLETE',
event: 'FINALIZE_COMPLETE'
}
];
}
/**
* Get current workflow phase
*/
getCurrentPhase(): WorkflowPhase {
return this.currentPhase;
}
/**
* Get current TDD phase (only valid in SUBTASK_LOOP)
*/
getCurrentTDDPhase(): TDDPhase | undefined {
if (this.currentPhase === 'SUBTASK_LOOP') {
return this.context.currentTDDPhase || 'RED';
}
return undefined;
}
/**
* Get workflow context
*/
getContext(): WorkflowContext {
return { ...this.context };
}
/**
* Transition to next state based on event
*/
transition(event: WorkflowEvent): void {
// Check if workflow is aborted
if (this.aborted && event.type !== 'ABORT') {
throw new Error('Workflow has been aborted');
}
// Handle special events that work across all phases
if (event.type === 'ERROR') {
this.handleError(event.error);
void this.triggerAutoPersist();
return;
}
if (event.type === 'ABORT') {
this.aborted = true;
void this.triggerAutoPersist();
return;
}
if (event.type === 'RETRY') {
this.handleRetry();
void this.triggerAutoPersist();
return;
}
// Handle TDD phase transitions within SUBTASK_LOOP
if (this.currentPhase === 'SUBTASK_LOOP') {
this.handleTDDPhaseTransition(event);
void this.triggerAutoPersist();
return;
}
// Handle main workflow phase transitions
const validTransition = this.transitions.find(
(t) => t.from === this.currentPhase && t.event === event.type
);
if (!validTransition) {
throw new Error(
`Invalid transition: ${event.type} from ${this.currentPhase}`
);
}
// Execute transition
this.executeTransition(validTransition, event);
void this.triggerAutoPersist();
}
/**
* Handle TDD phase transitions (RED -> GREEN -> COMMIT)
*/
private handleTDDPhaseTransition(event: WorkflowEvent): void {
const currentTDD = this.context.currentTDDPhase || 'RED';
switch (event.type) {
case 'RED_PHASE_COMPLETE':
if (currentTDD !== 'RED') {
throw new Error(
'Invalid transition: RED_PHASE_COMPLETE from non-RED phase'
);
}
// Validate test results are provided
if (!event.testResults) {
throw new Error('Test results required for RED phase transition');
}
// Store test results in context
this.context.lastTestResults = event.testResults;
// Special case: All tests passing in RED phase means feature already implemented
if (event.testResults.failed === 0) {
this.emit('tdd:red:completed');
this.emit('tdd:feature-already-implemented', {
subtaskId: this.getCurrentSubtaskId(),
testResults: event.testResults
});
// Mark subtask as complete and move to next one
const subtask =
this.context.subtasks[this.context.currentSubtaskIndex];
if (subtask) {
subtask.status = 'completed';
}
this.emit('subtask:completed');
this.context.currentSubtaskIndex++;
// Emit progress update
const progress = this.getProgress();
this.emit('progress:updated', {
completed: progress.completed,
total: progress.total,
percentage: progress.percentage
});
// Start next subtask or complete workflow
if (this.context.currentSubtaskIndex < this.context.subtasks.length) {
this.context.currentTDDPhase = 'RED';
this.emit('tdd:red:started');
this.emit('subtask:started');
} else {
// All subtasks complete, transition to FINALIZE
this.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
}
break;
}
// Normal RED phase: has failing tests, proceed to GREEN
this.emit('tdd:red:completed');
this.context.currentTDDPhase = 'GREEN';
this.emit('tdd:green:started');
break;
case 'GREEN_PHASE_COMPLETE':
if (currentTDD !== 'GREEN') {
throw new Error(
'Invalid transition: GREEN_PHASE_COMPLETE from non-GREEN phase'
);
}
// Validate test results are provided
if (!event.testResults) {
throw new Error('Test results required for GREEN phase transition');
}
// Validate GREEN phase has no failures
if (event.testResults.failed !== 0) {
throw new Error('GREEN phase must have zero failures');
}
// Store test results in context
this.context.lastTestResults = event.testResults;
this.emit('tdd:green:completed');
this.context.currentTDDPhase = 'COMMIT';
this.emit('tdd:commit:started');
break;
case 'COMMIT_COMPLETE':
if (currentTDD !== 'COMMIT') {
throw new Error(
'Invalid transition: COMMIT_COMPLETE from non-COMMIT phase'
);
}
this.emit('tdd:commit:completed');
// Mark current subtask as completed
const currentSubtask =
this.context.subtasks[this.context.currentSubtaskIndex];
if (currentSubtask) {
currentSubtask.status = 'completed';
}
break;
case 'SUBTASK_COMPLETE':
this.emit('subtask:completed');
// Move to next subtask
this.context.currentSubtaskIndex++;
// Emit progress update
const progress = this.getProgress();
this.emit('progress:updated', {
completed: progress.completed,
total: progress.total,
percentage: progress.percentage
});
if (this.context.currentSubtaskIndex < this.context.subtasks.length) {
// Start next subtask with RED phase
this.context.currentTDDPhase = 'RED';
this.emit('tdd:red:started');
this.emit('subtask:started');
} else {
// All subtasks complete, transition to FINALIZE
this.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
}
break;
case 'ALL_SUBTASKS_COMPLETE':
// Transition to FINALIZE phase
this.emit('phase:exited');
this.currentPhase = 'FINALIZE';
this.context.currentTDDPhase = undefined;
this.emit('phase:entered');
// Note: Don't auto-transition to COMPLETE - requires explicit finalize call
break;
default:
throw new Error(`Invalid transition: ${event.type} in SUBTASK_LOOP`);
}
}
/**
* Execute a state transition
*/
private executeTransition(
transition: StateTransition,
event: WorkflowEvent
): void {
// Check guard condition if present
if (transition.guard && !transition.guard(this.context)) {
throw new Error(
`Guard condition failed for transition to ${transition.to}`
);
}
// Check phase-specific guard if present
const phaseGuard = this.phaseGuards.get(transition.to);
if (phaseGuard && !phaseGuard(this.context)) {
throw new Error('Guard condition failed');
}
// Emit phase exit event
this.emit('phase:exited');
// Update context based on event
this.updateContext(event);
// Transition to new phase
this.currentPhase = transition.to;
// Emit phase entry event
this.emit('phase:entered');
// Initialize TDD phase if entering SUBTASK_LOOP
if (this.currentPhase === 'SUBTASK_LOOP') {
this.context.currentTDDPhase = 'RED';
this.emit('tdd:red:started');
this.emit('subtask:started');
}
}
/**
* Update context based on event
*/
private updateContext(event: WorkflowEvent): void {
switch (event.type) {
case 'BRANCH_CREATED':
this.context.branchName = event.branchName;
this.emit('git:branch:created', { branchName: event.branchName });
// Trigger git operation hook
if (this.gitOperationHook) {
this.gitOperationHook('branch:created', {
branchName: event.branchName
});
}
break;
case 'ERROR':
this.context.errors.push(event.error);
this.emit('error:occurred', { error: event.error });
break;
// Add more context updates as needed
}
}
/**
* Get current state for serialization
*/
getState(): WorkflowState {
return {
phase: this.currentPhase,
context: { ...this.context }
};
}
/**
* Restore state from checkpoint
*/
restoreState(state: WorkflowState): void {
this.currentPhase = state.phase;
this.context = { ...state.context };
// Emit workflow:resumed event
this.emit('workflow:resumed', {
phase: this.currentPhase,
progress: this.getProgress()
});
}
/**
* Add event listener
*/
on(eventType: WorkflowEventType, listener: WorkflowEventListener): void {
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, new Set());
}
this.eventListeners.get(eventType)!.add(listener);
}
/**
* Remove event listener
*/
off(eventType: WorkflowEventType, listener: WorkflowEventListener): void {
const listeners = this.eventListeners.get(eventType);
if (listeners) {
listeners.delete(listener);
}
}
/**
* Emit workflow event
*/
private emit(
eventType: WorkflowEventType,
data?: Record<string, unknown>
): void {
const eventData: WorkflowEventData = {
type: eventType,
timestamp: new Date(),
phase: this.currentPhase,
tddPhase: this.context.currentTDDPhase,
subtaskId: this.getCurrentSubtaskId(),
data: {
...data,
adapters: {
testValidator: !!this.testResultValidator,
gitHook: !!this.gitOperationHook,
executeHook: !!this.executeHook
}
}
};
const listeners = this.eventListeners.get(eventType);
if (listeners) {
listeners.forEach((listener) => listener(eventData));
}
}
/**
* Get current subtask ID
*/
private getCurrentSubtaskId(): string | undefined {
const currentSubtask =
this.context.subtasks[this.context.currentSubtaskIndex];
return currentSubtask?.id;
}
/**
* Register callback for state persistence
*/
onStatePersist(
callback: (state: WorkflowState) => void | Promise<void>
): void {
this.persistCallback = callback;
}
/**
* Enable auto-persistence after each transition
*/
enableAutoPersist(
callback: (state: WorkflowState) => void | Promise<void>
): void {
this.persistCallback = callback;
this.autoPersistEnabled = true;
}
/**
* Disable auto-persistence
*/
disableAutoPersist(): void {
this.autoPersistEnabled = false;
}
/**
* Manually persist current state
*/
async persistState(): Promise<void> {
if (this.persistCallback) {
await this.persistCallback(this.getState());
}
this.emit('state:persisted');
}
/**
* Trigger auto-persistence if enabled
*/
private async triggerAutoPersist(): Promise<void> {
if (this.autoPersistEnabled && this.persistCallback) {
await this.persistCallback(this.getState());
}
}
/**
* Add a guard condition for a specific phase
*/
addGuard(
phase: WorkflowPhase,
guard: (context: WorkflowContext) => boolean
): void {
this.phaseGuards.set(phase, guard);
}
/**
* Remove a guard condition for a specific phase
*/
removeGuard(phase: WorkflowPhase): void {
this.phaseGuards.delete(phase);
}
/**
* Get current subtask being worked on
*/
getCurrentSubtask(): SubtaskInfo | undefined {
return this.context.subtasks[this.context.currentSubtaskIndex];
}
/**
* Get workflow progress information
*/
getProgress(): {
completed: number;
total: number;
current: number;
percentage: number;
} {
const completed = this.context.subtasks.filter(
(st) => st.status === 'completed'
).length;
const total = this.context.subtasks.length;
const current = this.context.currentSubtaskIndex + 1;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return { completed, total, current, percentage };
}
/**
* Check if can proceed to next subtask or phase
*/
canProceed(): boolean {
if (this.currentPhase !== 'SUBTASK_LOOP') {
return false;
}
const currentSubtask = this.getCurrentSubtask();
// Can proceed if current subtask is completed (after COMMIT phase)
return currentSubtask?.status === 'completed';
}
/**
* Increment attempts for current subtask
*/
incrementAttempts(): void {
const currentSubtask = this.getCurrentSubtask();
if (currentSubtask) {
currentSubtask.attempts++;
}
}
/**
* Check if current subtask has exceeded max attempts
*/
hasExceededMaxAttempts(): boolean {
const currentSubtask = this.getCurrentSubtask();
if (!currentSubtask || !currentSubtask.maxAttempts) {
return false;
}
return currentSubtask.attempts > currentSubtask.maxAttempts;
}
/**
* Handle error event
*/
private handleError(error: import('./types.js').WorkflowError): void {
this.context.errors.push(error);
this.emit('error:occurred', { error });
}
/**
* Handle retry event
*/
private handleRetry(): void {
if (this.currentPhase === 'SUBTASK_LOOP') {
// Reset to RED phase to retry current subtask
this.context.currentTDDPhase = 'RED';
this.emit('tdd:red:started');
}
}
/**
* Retry current subtask (resets to RED phase)
*/
retryCurrentSubtask(): void {
if (this.currentPhase === 'SUBTASK_LOOP') {
this.context.currentTDDPhase = 'RED';
this.emit('tdd:red:started');
}
}
/**
* Handle max attempts exceeded for current subtask
*/
handleMaxAttemptsExceeded(): void {
const currentSubtask = this.getCurrentSubtask();
if (currentSubtask) {
currentSubtask.status = 'failed';
this.emit('subtask:failed', {
subtaskId: currentSubtask.id,
attempts: currentSubtask.attempts,
maxAttempts: currentSubtask.maxAttempts
});
}
}
/**
* Check if workflow has been aborted
*/
isAborted(): boolean {
return this.aborted;
}
/**
* Validate if a state can be resumed from
*/
canResumeFromState(state: WorkflowState): boolean {
// Validate phase is valid
const validPhases: WorkflowPhase[] = [
'PREFLIGHT',
'BRANCH_SETUP',
'SUBTASK_LOOP',
'FINALIZE',
'COMPLETE'
];
if (!validPhases.includes(state.phase)) {
return false;
}
// Validate context structure
if (!state.context || typeof state.context !== 'object') {
return false;
}
// Validate required context fields
if (!state.context.taskId || !Array.isArray(state.context.subtasks)) {
return false;
}
if (typeof state.context.currentSubtaskIndex !== 'number') {
return false;
}
if (!Array.isArray(state.context.errors)) {
return false;
}
// All validations passed
return true;
}
/**
* Set TestResultValidator adapter
*/
setTestResultValidator(validator: TestResultValidator): void {
this.testResultValidator = validator;
this.emit('adapter:configured', { adapterType: 'test-validator' });
}
/**
* Check if TestResultValidator is configured
*/
hasTestResultValidator(): boolean {
return !!this.testResultValidator;
}
/**
* Remove TestResultValidator adapter
*/
removeTestResultValidator(): void {
this.testResultValidator = undefined;
}
/**
* Register git operation hook
*/
onGitOperation(hook: (operation: string, data?: unknown) => void): void {
this.gitOperationHook = hook;
}
/**
* Register execute command hook
*/
onExecute(hook: (command: string, context: WorkflowContext) => void): void {
this.executeHook = hook;
}
/**
* Execute a command (triggers execute hook)
*/
executeCommand(command: string): void {
if (this.executeHook) {
this.executeHook(command, this.context);
}
}
}

View File

@@ -0,0 +1,146 @@
/**
* @fileoverview Tests for WorkflowStateManager path sanitization
*/
import { describe, it, expect } from 'vitest';
import { WorkflowStateManager } from './workflow-state-manager.js';
import os from 'node:os';
import path from 'node:path';
describe('WorkflowStateManager', () => {
describe('getProjectIdentifier', () => {
it('should sanitize paths like Claude Code', () => {
const projectRoot =
'/Volumes/Workspace/workspace/contrib/task-master/demos/nextjs-todo-tdd';
const manager = new WorkflowStateManager(projectRoot);
const sessionDir = manager.getSessionDir();
const homeDir = os.homedir();
// Expected structure: ~/.taskmaster/{project-id}/sessions/
const expectedPath = path.join(
homeDir,
'.taskmaster',
'-Volumes-Workspace-workspace-contrib-task-master-demos-nextjs-todo-tdd',
'sessions'
);
expect(sessionDir).toBe(expectedPath);
});
it('should preserve case in paths', () => {
const projectRoot = '/Users/Alice/Projects/MyApp';
const manager = new WorkflowStateManager(projectRoot);
const sessionDir = manager.getSessionDir();
// Extract project ID from: ~/.taskmaster/{project-id}/sessions/
const projectId = sessionDir.split(path.sep).slice(-2, -1)[0];
// Case should be preserved
expect(projectId).toContain('Users');
expect(projectId).toContain('Alice');
expect(projectId).toContain('Projects');
expect(projectId).toContain('MyApp');
});
it('should handle paths with special characters', () => {
const projectRoot = '/tmp/my-project_v2.0/test';
const manager = new WorkflowStateManager(projectRoot);
const sessionDir = manager.getSessionDir();
// Extract project ID from: ~/.taskmaster/{project-id}/sessions/
const projectId = sessionDir.split(path.sep).slice(-2, -1)[0];
// Special chars should be replaced with dashes
expect(projectId).toBe('-tmp-my-project-v2-0-test');
});
it('should create unique identifiers for different paths', () => {
const project1 = '/Users/alice/task-master';
const project2 = '/Users/bob/task-master';
const manager1 = new WorkflowStateManager(project1);
const manager2 = new WorkflowStateManager(project2);
// Extract project IDs from: ~/.taskmaster/{project-id}/sessions/
const id1 = manager1.getSessionDir().split(path.sep).slice(-2, -1)[0];
const id2 = manager2.getSessionDir().split(path.sep).slice(-2, -1)[0];
// Same basename but different full paths should be unique
expect(id1).not.toBe(id2);
expect(id1).toContain('alice');
expect(id2).toContain('bob');
});
it('should collapse multiple dashes', () => {
const projectRoot = '/path//with///multiple////slashes';
const manager = new WorkflowStateManager(projectRoot);
const sessionDir = manager.getSessionDir();
// Extract project ID from: ~/.taskmaster/{project-id}/sessions/
const projectId = sessionDir.split(path.sep).slice(-2, -1)[0];
// Multiple dashes should be collapsed to single dash
expect(projectId).not.toContain('--');
expect(projectId).toBe('-path-with-multiple-slashes');
});
it('should not have trailing dashes', () => {
const projectRoot = '/path/to/project';
const manager = new WorkflowStateManager(projectRoot);
const sessionDir = manager.getSessionDir();
// Extract project ID from: ~/.taskmaster/{project-id}/sessions/
const projectId = sessionDir.split(path.sep).slice(-2, -1)[0];
// Should not end with dash
expect(projectId).not.toMatch(/-$/);
});
it('should start with a dash like Claude Code', () => {
const projectRoot = '/any/path';
const manager = new WorkflowStateManager(projectRoot);
const sessionDir = manager.getSessionDir();
// Extract project ID from: ~/.taskmaster/{project-id}/sessions/
const projectId = sessionDir.split(path.sep).slice(-2, -1)[0];
// Should start with dash like Claude Code's pattern
expect(projectId).toMatch(/^-/);
});
});
describe('session paths', () => {
it('should place sessions in global ~/.taskmaster/{project-id}/sessions/', () => {
const projectRoot = '/some/project';
const manager = new WorkflowStateManager(projectRoot);
const sessionDir = manager.getSessionDir();
const homeDir = os.homedir();
// Should be: ~/.taskmaster/{project-id}/sessions/
expect(sessionDir).toContain(path.join(homeDir, '.taskmaster'));
expect(sessionDir).toMatch(/\.taskmaster\/.*\/sessions$/);
});
it('should include workflow-state.json in session dir', () => {
const projectRoot = '/some/project';
const manager = new WorkflowStateManager(projectRoot);
const statePath = manager.getStatePath();
const sessionDir = manager.getSessionDir();
expect(statePath).toBe(path.join(sessionDir, 'workflow-state.json'));
});
it('should include backups dir in session dir', () => {
const projectRoot = '/some/project';
const manager = new WorkflowStateManager(projectRoot);
const backupDir = manager.getBackupDir();
const sessionDir = manager.getSessionDir();
expect(backupDir).toBe(path.join(sessionDir, 'backups'));
});
});
});

View File

@@ -0,0 +1,263 @@
/**
* @fileoverview WorkflowStateManager - Manages persistence of TDD workflow state
*
* Stores workflow state in global user directory (~/.taskmaster/{project-id}/sessions/)
* to avoid git conflicts and support multiple worktrees.
* Each project gets its own directory for organizing workflow-related data.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import type { WorkflowState } from './types.js';
import { getLogger } from '../logger/index.js';
export interface WorkflowStateBackup {
timestamp: string;
state: WorkflowState;
}
/**
* Manages workflow state persistence with backup support
* Stores state in global user directory to avoid git noise
*/
export class WorkflowStateManager {
private readonly projectRoot: string;
private readonly statePath: string;
private readonly backupDir: string;
private readonly sessionDir: string;
private maxBackups: number;
private readonly logger = getLogger('WorkflowStateManager');
constructor(projectRoot: string, maxBackups = 5) {
this.projectRoot = path.resolve(projectRoot);
this.maxBackups = maxBackups;
// Create project-specific directory in global .taskmaster
// Structure: ~/.taskmaster/{project-id}/sessions/
const projectId = this.getProjectIdentifier(this.projectRoot);
const homeDir = os.homedir();
const projectDir = path.join(homeDir, '.taskmaster', projectId);
this.sessionDir = path.join(projectDir, 'sessions');
this.statePath = path.join(this.sessionDir, 'workflow-state.json');
this.backupDir = path.join(this.sessionDir, 'backups');
}
/**
* Generate a unique identifier for the project using full sanitized path
* Uses Claude Code's pattern: leading dash + full path with case preserved
* Example: /Volumes/Workspace/... -> -Volumes-Workspace-...
*/
private getProjectIdentifier(projectRoot: string): string {
// Resolve to absolute path
const absolutePath = path.resolve(projectRoot);
// Sanitize path like Claude Code does:
// - Add leading dash
// - Replace path separators and non-alphanumeric chars with dashes
// - Preserve case for readability
// - Collapse multiple dashes
const sanitized =
'-' +
absolutePath
.replace(/^\//, '') // Remove leading slash before adding dash
.replace(/[^a-zA-Z0-9]+/g, '-') // Replace sequences of non-alphanumeric with single dash
.replace(/-+/g, '-') // Collapse multiple dashes
.replace(/-+$/, ''); // Remove trailing dashes
return sanitized;
}
/**
* Check if workflow state exists
*/
async exists(): Promise<boolean> {
try {
await fs.access(this.statePath);
return true;
} catch {
return false;
}
}
/**
* Load workflow state from disk
*/
async load(): Promise<WorkflowState> {
try {
const content = await fs.readFile(this.statePath, 'utf-8');
return JSON.parse(content) as WorkflowState;
} catch (error: any) {
if (error.code === 'ENOENT') {
throw new Error(`Workflow state file not found at ${this.statePath}`);
}
throw new Error(`Failed to load workflow state: ${error.message}`);
}
}
/**
* Save workflow state to disk
*/
async save(state: WorkflowState): Promise<void> {
try {
// Ensure session directory exists
await fs.mkdir(this.sessionDir, { recursive: true });
// Serialize and validate JSON
const jsonContent = JSON.stringify(state, null, 2);
// Validate that the JSON is well-formed by parsing it back
try {
JSON.parse(jsonContent);
} catch (parseError) {
this.logger.error('Generated invalid JSON:', jsonContent);
throw new Error('Failed to generate valid JSON from workflow state');
}
// Write state atomically with newline at end
const tempPath = `${this.statePath}.tmp`;
await fs.writeFile(tempPath, jsonContent + '\n', 'utf-8');
await fs.rename(tempPath, this.statePath);
this.logger.debug(`Saved workflow state (${jsonContent.length} bytes)`);
} catch (error: any) {
throw new Error(`Failed to save workflow state: ${error.message}`);
}
}
/**
* Create a backup of current state
*/
async createBackup(): Promise<void> {
try {
const exists = await this.exists();
if (!exists) {
return;
}
const state = await this.load();
await fs.mkdir(this.backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(
this.backupDir,
`workflow-state-${timestamp}.json`
);
const backup: WorkflowStateBackup = {
timestamp: new Date().toISOString(),
state
};
await fs.writeFile(backupPath, JSON.stringify(backup, null, 2), 'utf-8');
// Clean up old backups
await this.pruneBackups();
} catch (error: any) {
throw new Error(`Failed to create backup: ${error.message}`);
}
}
/**
* Delete workflow state file
*/
async delete(): Promise<void> {
try {
await fs.unlink(this.statePath);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw new Error(`Failed to delete workflow state: ${error.message}`);
}
}
}
/**
* List available backups
*/
async listBackups(): Promise<string[]> {
try {
const files = await fs.readdir(this.backupDir);
return files
.filter((f) => f.startsWith('workflow-state-') && f.endsWith('.json'))
.sort()
.reverse();
} catch (error: any) {
if (error.code === 'ENOENT') {
return [];
}
throw new Error(`Failed to list backups: ${error.message}`);
}
}
/**
* Restore from a backup
*/
async restoreBackup(backupFileName: string): Promise<void> {
try {
const backupPath = path.join(this.backupDir, backupFileName);
const content = await fs.readFile(backupPath, 'utf-8');
const backup: WorkflowStateBackup = JSON.parse(content);
await this.save(backup.state);
} catch (error: any) {
throw new Error(`Failed to restore backup: ${error.message}`);
}
}
/**
* Prune old backups to maintain max backup count
*/
private async pruneBackups(): Promise<void> {
try {
const backups = await this.listBackups();
if (backups.length > this.maxBackups) {
const toDelete = backups.slice(this.maxBackups);
for (const backup of toDelete) {
await fs.unlink(path.join(this.backupDir, backup));
}
}
} catch (error: any) {
// Non-critical error, log but don't throw
this.logger.warn(`Failed to prune backups: ${error.message}`);
}
}
/**
* Get the path to the state file (for debugging/testing)
*/
getStatePath(): string {
return this.statePath;
}
/**
* Get the path to the backup directory (for debugging/testing)
*/
getBackupDir(): string {
return this.backupDir;
}
/**
* Get the session directory path (for debugging/testing)
*/
getSessionDir(): string {
return this.sessionDir;
}
/**
* Get the project root this manager is for
*/
getProjectRoot(): string {
return this.projectRoot;
}
/**
* Get the path to the activity log file
* Activity log is stored next to workflow-state.json for correlation
*/
getActivityLogPath(): string {
return path.join(this.sessionDir, 'activity.jsonl');
}
}

View File

@@ -0,0 +1,401 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import {
logActivity,
readActivityLog,
filterActivityLog
} from '../../../src/storage/activity-logger.js';
describe('Activity Logger', () => {
let testDir: string;
let activityPath: string;
beforeEach(async () => {
// Create a unique temporary test directory
const prefix = path.join(os.tmpdir(), 'activity-test-');
testDir = await fs.mkdtemp(prefix);
activityPath = path.join(testDir, 'activity.jsonl');
});
afterEach(async () => {
// Clean up test directory
await fs.remove(testDir);
});
describe('logActivity', () => {
it('should create activity log file on first write', async () => {
await logActivity(activityPath, {
type: 'phase-start',
phase: 'red',
data: {}
});
const exists = await fs.pathExists(activityPath);
expect(exists).toBe(true);
});
it('should append event to log file', async () => {
await logActivity(activityPath, {
type: 'phase-start',
phase: 'red'
});
const content = await fs.readFile(activityPath, 'utf-8');
const lines = content.trim().split(/\r?\n/);
expect(lines.length).toBe(1);
});
it('should write valid JSONL format', async () => {
await logActivity(activityPath, {
type: 'test-run',
result: 'pass'
});
const content = await fs.readFile(activityPath, 'utf-8');
const line = content.trim();
const parsed = JSON.parse(line);
expect(parsed).toBeDefined();
expect(parsed.type).toBe('test-run');
});
it('should include timestamp in log entry', async () => {
const before = new Date().toISOString();
await logActivity(activityPath, {
type: 'phase-start',
phase: 'red'
});
const after = new Date().toISOString();
const logs = await readActivityLog(activityPath);
expect(logs[0].timestamp).toBeDefined();
expect(logs[0].timestamp >= before).toBe(true);
expect(logs[0].timestamp <= after).toBe(true);
});
it('should append multiple events', async () => {
await logActivity(activityPath, { type: 'event1' });
await logActivity(activityPath, { type: 'event2' });
await logActivity(activityPath, { type: 'event3' });
const logs = await readActivityLog(activityPath);
expect(logs.length).toBe(3);
expect(logs[0].type).toBe('event1');
expect(logs[1].type).toBe('event2');
expect(logs[2].type).toBe('event3');
});
it('should preserve event data', async () => {
const eventData = {
type: 'git-commit',
hash: 'abc123',
message: 'test commit',
files: ['file1.ts', 'file2.ts']
};
await logActivity(activityPath, eventData);
const logs = await readActivityLog(activityPath);
expect(logs[0].type).toBe('git-commit');
expect(logs[0].hash).toBe('abc123');
expect(logs[0].message).toBe('test commit');
expect(logs[0].files).toEqual(['file1.ts', 'file2.ts']);
});
it('should handle nested objects in event data', async () => {
await logActivity(activityPath, {
type: 'test-results',
results: {
passed: 10,
failed: 2,
details: { coverage: 85 }
}
});
const logs = await readActivityLog(activityPath);
expect(logs[0].results.details.coverage).toBe(85);
});
it('should handle special characters in event data', async () => {
await logActivity(activityPath, {
type: 'error',
message: 'Error: "Something went wrong"\nLine 2'
});
const logs = await readActivityLog(activityPath);
expect(logs[0].message).toBe('Error: "Something went wrong"\nLine 2');
});
it('should create parent directory if it does not exist', async () => {
const nestedPath = path.join(testDir, 'nested', 'dir', 'activity.jsonl');
await logActivity(nestedPath, { type: 'test' });
const exists = await fs.pathExists(nestedPath);
expect(exists).toBe(true);
});
});
describe('readActivityLog', () => {
it('should read all events from log', async () => {
await logActivity(activityPath, { type: 'event1' });
await logActivity(activityPath, { type: 'event2' });
const logs = await readActivityLog(activityPath);
expect(logs.length).toBe(2);
expect(logs[0].type).toBe('event1');
expect(logs[1].type).toBe('event2');
});
it('should return empty array for non-existent file', async () => {
const logs = await readActivityLog(activityPath);
expect(logs).toEqual([]);
});
it('should parse JSONL correctly', async () => {
await logActivity(activityPath, { type: 'event1', data: 'test1' });
await logActivity(activityPath, { type: 'event2', data: 'test2' });
const logs = await readActivityLog(activityPath);
expect(logs[0].data).toBe('test1');
expect(logs[1].data).toBe('test2');
});
it('should handle empty lines', async () => {
await fs.writeFile(
activityPath,
'{"type":"event1"}\n\n{"type":"event2"}\n'
);
const logs = await readActivityLog(activityPath);
expect(logs.length).toBe(2);
expect(logs[0].type).toBe('event1');
expect(logs[1].type).toBe('event2');
});
it('should throw error for invalid JSON line', async () => {
await fs.writeFile(activityPath, '{"type":"event1"}\ninvalid json\n');
await expect(readActivityLog(activityPath)).rejects.toThrow(
/Invalid JSON/i
);
});
it('should preserve chronological order', async () => {
for (let i = 0; i < 10; i++) {
await logActivity(activityPath, { type: 'event', index: i });
}
const logs = await readActivityLog(activityPath);
for (let i = 0; i < 10; i++) {
expect(logs[i].index).toBe(i);
}
});
});
describe('filterActivityLog', () => {
beforeEach(async () => {
// Create sample log entries
await logActivity(activityPath, { type: 'phase-start', phase: 'red' });
await logActivity(activityPath, { type: 'test-run', result: 'fail' });
await logActivity(activityPath, { type: 'phase-start', phase: 'green' });
await logActivity(activityPath, { type: 'test-run', result: 'pass' });
await logActivity(activityPath, { type: 'git-commit', hash: 'abc123' });
});
it('should filter by event type', async () => {
const filtered = await filterActivityLog(activityPath, {
type: 'phase-start'
});
expect(filtered.length).toBe(2);
expect(filtered[0].type).toBe('phase-start');
expect(filtered[1].type).toBe('phase-start');
});
it('should filter by multiple criteria', async () => {
const filtered = await filterActivityLog(activityPath, {
type: 'test-run',
result: 'pass'
});
expect(filtered.length).toBe(1);
expect(filtered[0].result).toBe('pass');
});
it('should return all events when no filter provided', async () => {
const filtered = await filterActivityLog(activityPath, {});
expect(filtered.length).toBe(5);
});
it('should filter by timestamp range', async () => {
const logs = await readActivityLog(activityPath);
const midpoint = logs[2].timestamp;
const filtered = await filterActivityLog(activityPath, {
timestampFrom: midpoint
});
// Should get events from midpoint onwards (inclusive)
// Expect at least 3 events, may be more due to timestamp collisions
expect(filtered.length).toBeGreaterThanOrEqual(3);
expect(filtered.length).toBeLessThanOrEqual(5);
});
it('should filter by custom predicate', async () => {
const filtered = await filterActivityLog(activityPath, {
predicate: (event: any) => event.phase === 'red'
});
expect(filtered.length).toBe(1);
expect(filtered[0].phase).toBe('red');
});
it('should return empty array for non-matching filter', async () => {
const filtered = await filterActivityLog(activityPath, {
type: 'non-existent'
});
expect(filtered).toEqual([]);
});
it('should handle nested property filters', async () => {
await logActivity(activityPath, {
type: 'test-results',
results: { coverage: 85 }
});
const filtered = await filterActivityLog(activityPath, {
predicate: (event: any) => event.results?.coverage > 80
});
expect(filtered.length).toBe(1);
expect(filtered[0].results.coverage).toBe(85);
});
});
describe('Event types', () => {
it('should support phase-transition events', async () => {
await logActivity(activityPath, {
type: 'phase-transition',
from: 'red',
to: 'green'
});
const logs = await readActivityLog(activityPath);
expect(logs[0].type).toBe('phase-transition');
expect(logs[0].from).toBe('red');
expect(logs[0].to).toBe('green');
});
it('should support test-run events', async () => {
await logActivity(activityPath, {
type: 'test-run',
result: 'pass',
testsRun: 50,
testsPassed: 50,
testsFailed: 0,
coverage: 85.5
});
const logs = await readActivityLog(activityPath);
expect(logs[0].testsRun).toBe(50);
expect(logs[0].coverage).toBe(85.5);
});
it('should support git-operation events', async () => {
await logActivity(activityPath, {
type: 'git-commit',
hash: 'abc123def456',
message: 'feat: add new feature',
files: ['file1.ts', 'file2.ts']
});
const logs = await readActivityLog(activityPath);
expect(logs[0].hash).toBe('abc123def456');
expect(logs[0].files.length).toBe(2);
});
it('should support error events', async () => {
await logActivity(activityPath, {
type: 'error',
phase: 'red',
error: 'Test failed',
stack: 'Error stack trace...'
});
const logs = await readActivityLog(activityPath);
expect(logs[0].type).toBe('error');
expect(logs[0].error).toBe('Test failed');
});
});
describe('Concurrency handling', () => {
it('should handle rapid concurrent writes', async () => {
const writes: Promise<void>[] = [];
for (let i = 0; i < 50; i++) {
writes.push(logActivity(activityPath, { type: 'event', index: i }));
}
await Promise.all(writes);
const logs = await readActivityLog(activityPath);
expect(logs.length).toBe(50);
});
it('should maintain data integrity with concurrent writes', async () => {
const writes: Promise<void>[] = [];
for (let i = 0; i < 20; i++) {
writes.push(
logActivity(activityPath, {
type: 'concurrent-test',
id: i,
data: `data-${i}`
})
);
}
await Promise.all(writes);
const logs = await readActivityLog(activityPath);
// All events should be present
expect(logs.length).toBe(20);
// Validate ids set
const ids = new Set(logs.map((l) => l.id));
expect([...ids].sort((a, b) => a - b)).toEqual([...Array(20).keys()]);
// Validate shape
for (const log of logs) {
expect(log.type).toBe('concurrent-test');
expect(typeof log.id).toBe('number');
expect(log.data).toMatch(/^data-\d+$/);
}
});
});
describe('File integrity', () => {
it('should maintain valid JSONL after many operations', async () => {
for (let i = 0; i < 100; i++) {
await logActivity(activityPath, { type: 'test', iteration: i });
}
const content = await fs.readFile(activityPath, 'utf-8');
const lines = content.trim().split(/\r?\n/);
expect(lines.length).toBe(100);
// All lines should be valid JSON
for (const line of lines) {
expect(() => JSON.parse(line)).not.toThrow();
}
});
});
});

View File

@@ -22,12 +22,15 @@ export default defineConfig({
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules',
'dist',
'tests',
'node_modules/',
'dist/',
'tests/',
'**/*.test.ts',
'**/*.spec.ts',
'**/*.d.ts',
'**/mocks/**',
'**/fixtures/**',
'vitest.config.ts',
'src/index.ts'
],
thresholds: {

View File

@@ -18,6 +18,8 @@
"@tm/core/*": ["./packages/tm-core/src/*"],
"@tm/cli": ["./apps/cli/src/index.ts"],
"@tm/cli/*": ["./apps/cli/src/*"],
"@tm/mcp": ["./apps/mcp/src/index.ts"],
"@tm/mcp/*": ["./apps/mcp/src/*"],
"@tm/build-config": ["./packages/build-config/src/index.ts"],
"@tm/build-config/*": ["./packages/build-config/src/*"],
"@tm/ai-sdk-provider-grok-cli": [