fix: improve branch naming logic for workflow commands (#1491)

This commit is contained in:
Ralph Khreish
2025-12-07 19:04:40 +01:00
committed by GitHub
parent bf4515b6de
commit 0e908be43a
39 changed files with 2164 additions and 1356 deletions

View File

@@ -0,0 +1,8 @@
---
"task-master-ai": patch
---
Add `tm/` prefix to autopilot branch names
- Team mode branches now follow the `tm/<org-slug>/task-<id>` naming convention for better organization.
- Solves issue some users were having regarding not being able to start workflow on master Taskmaster tag

4
.gitignore vendored
View File

@@ -104,7 +104,3 @@ apps/extension/vsix-build/
.scannerwork
# OS specific
# Task files
tasks.json
tasks/

View File

@@ -2,17 +2,11 @@
* @fileoverview Abort Command - Safely terminate workflow
*/
import { WorkflowOrchestrator } from '@tm/core';
import { createTmCore } from '@tm/core';
import { Command } from 'commander';
import inquirer from 'inquirer';
import { getProjectRoot } from '../../utils/project-root.js';
import {
AutopilotBaseOptions,
OutputFormatter,
deleteWorkflowState,
hasWorkflowState,
loadWorkflowState
} from './shared.js';
import { AutopilotBaseOptions, OutputFormatter } from './shared.js';
interface AbortOptions extends AutopilotBaseOptions {
force?: boolean;
@@ -58,27 +52,19 @@ export class AbortCommand extends Command {
...mergedOptions,
projectRoot
};
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
// Initialize TmCore facade
const tmCore = await createTmCore({ projectPath: projectRoot });
// Check if workflow exists
if (!(await tmCore.workflow.hasWorkflow())) {
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();
// Resume workflow to get status
await tmCore.workflow.resume();
const status = tmCore.workflow.getStatus();
// Confirm abort if not forced or in JSON mode
if (!mergedOptions.force && !mergedOptions.json) {
@@ -87,8 +73,8 @@ export class AbortCommand extends Command {
type: 'confirm',
name: 'confirmed',
message:
`This will abort the workflow for task ${state.context.taskId}. ` +
`Progress: ${progress.completed}/${progress.total} subtasks completed. ` +
`This will abort the workflow for task ${status.taskId}. ` +
`Progress: ${status.progress?.completed || 0}/${status.progress?.total || 0} subtasks completed. ` +
`Continue?`,
default: false
}
@@ -100,24 +86,18 @@ export class AbortCommand extends Command {
}
}
// Trigger abort in orchestrator
orchestrator.transition({ type: 'ABORT' });
// Delete workflow state
await deleteWorkflowState(mergedOptions.projectRoot!);
// Abort workflow (cleans up state internally)
await tmCore.workflow.abort();
// Output result
formatter.success('Workflow aborted', {
taskId: state.context.taskId,
branchName: state.context.branchName,
progress: {
completed: progress.completed,
total: progress.total
},
lastSubtask: currentSubtask
taskId: status.taskId,
branchName: status.branchName,
progress: status.progress,
lastSubtask: status.currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title
id: status.currentSubtask.id,
title: status.currentSubtask.title
}
: null,
note: 'Branch and commits remain. Clean up manually if needed.'

View File

@@ -2,7 +2,7 @@
* @fileoverview Commit Command - Create commit with enhanced message generation
*/
import { CommitMessageGenerator, GitAdapter, WorkflowService } from '@tm/core';
import { CommitMessageGenerator, GitAdapter, createTmCore } from '@tm/core';
import { Command } from 'commander';
import { getProjectRoot } from '../../utils/project-root.js';
import { AutopilotBaseOptions, OutputFormatter } from './shared.js';
@@ -39,21 +39,21 @@ export class CommitCommand extends Command {
try {
const projectRoot = mergedOptions.projectRoot!;
// Create workflow service (manages WorkflowStateManager internally)
const workflowService = new WorkflowService(projectRoot);
// Initialize TmCore facade
const tmCore = await createTmCore({ projectPath: projectRoot });
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
if (!(await tmCore.workflow.hasWorkflow())) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Resume workflow (loads state with single WorkflowStateManager instance)
await workflowService.resumeWorkflow();
const status = workflowService.getStatus();
const workflowContext = workflowService.getContext();
// Resume workflow
await tmCore.workflow.resume();
const status = tmCore.workflow.getStatus();
const workflowContext = tmCore.workflow.getContext();
// Verify in COMMIT phase
if (status.tddPhase !== 'COMMIT') {
@@ -116,8 +116,8 @@ export class CommitCommand extends Command {
const lastCommit = await gitAdapter.getLastCommit();
// Complete COMMIT phase and advance workflow
// This handles all transitions internally with a single WorkflowStateManager
const newStatus = await workflowService.commit();
// Status updates (subtask → done) are handled internally by WorkflowService
const newStatus = await tmCore.workflow.commit();
const isComplete = newStatus.phase === 'COMPLETE';

View File

@@ -2,15 +2,10 @@
* @fileoverview Complete Command - Complete current TDD phase with validation
*/
import { type TestResult, WorkflowOrchestrator } from '@tm/core';
import { type TestResult, createTmCore } from '@tm/core';
import { Command } from 'commander';
import { getProjectRoot } from '../../utils/project-root.js';
import {
type AutopilotBaseOptions,
OutputFormatter,
hasWorkflowState,
loadWorkflowState
} from './shared.js';
import { type AutopilotBaseOptions, OutputFormatter } from './shared.js';
interface CompleteOptions extends AutopilotBaseOptions {
results?: string;
@@ -49,37 +44,30 @@ export class CompleteCommand extends Command {
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
const projectRoot = mergedOptions.projectRoot!;
// Initialize TmCore facade
const tmCore = await createTmCore({ projectPath: projectRoot });
// Check if workflow exists
if (!(await tmCore.workflow.hasWorkflow())) {
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);
});
// Resume workflow
await tmCore.workflow.resume();
const status = tmCore.workflow.getStatus();
// Get current phase
const tddPhase = orchestrator.getCurrentTDDPhase();
const currentSubtask = orchestrator.getCurrentSubtask();
const tddPhase = status.tddPhase;
const currentSubtask = status.currentSubtask;
if (!tddPhase) {
formatter.error('Not in a TDD phase', {
phase: orchestrator.getCurrentPhase()
phase: status.phase
});
process.exit(1);
}
@@ -136,24 +124,18 @@ export class CompleteCommand extends Command {
process.exit(1);
}
// Complete phase with test results
// Complete phase with test results using tmCore facade
const newStatus = await tmCore.workflow.completePhase(testResults);
if (tddPhase === 'RED') {
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults
});
formatter.success('RED phase completed', {
nextPhase: 'GREEN',
nextPhase: newStatus.tddPhase || 'GREEN',
testResults,
subtask: currentSubtask?.title
});
} else {
orchestrator.transition({
type: 'GREEN_PHASE_COMPLETE',
testResults
});
formatter.success('GREEN phase completed', {
nextPhase: 'COMMIT',
nextPhase: newStatus.tddPhase || 'COMMIT',
testResults,
subtask: currentSubtask?.title,
suggestion: 'Run: autopilot commit'

View File

@@ -0,0 +1,98 @@
/**
* @fileoverview Finalize Command - Complete the TDD workflow
*/
import { createTmCore } from '@tm/core';
import { Command } from 'commander';
import { getProjectRoot } from '../../utils/project-root.js';
import { AutopilotBaseOptions, OutputFormatter } from './shared.js';
type FinalizeOptions = AutopilotBaseOptions;
/**
* Finalize Command - Complete the workflow after all subtasks are done
*/
export class FinalizeCommand extends Command {
constructor() {
super('finalize');
this.description(
'Finalize and complete the workflow. Validates working tree is clean.'
).action(async (options: FinalizeOptions) => {
await this.execute(options);
});
}
private async execute(options: FinalizeOptions): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
// Initialize mergedOptions with defaults (projectRoot will be set in try block)
let mergedOptions: FinalizeOptions = {
...parentOpts,
...options,
projectRoot: '' // Will be set in try block
};
const formatter = new OutputFormatter(
options.json || parentOpts?.json || false
);
try {
// Resolve project root inside try block to catch any errors
const projectRoot = getProjectRoot(
options.projectRoot || parentOpts?.projectRoot
);
// Update mergedOptions with resolved project root
mergedOptions = {
...mergedOptions,
projectRoot
};
// Initialize TmCore facade
const tmCore = await createTmCore({ projectPath: projectRoot });
// Check if workflow exists
if (!(await tmCore.workflow.hasWorkflow())) {
formatter.error('No active workflow', {
suggestion: 'Start a workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Resume workflow to get status
await tmCore.workflow.resume();
const currentStatus = tmCore.workflow.getStatus();
// Verify we're in FINALIZE phase
if (currentStatus.phase !== 'FINALIZE') {
formatter.error(
`Cannot finalize: workflow is in ${currentStatus.phase} phase`,
{
suggestion: 'Complete all subtasks first'
}
);
process.exit(1);
}
// Finalize workflow
formatter.info('Validating working tree and finalizing workflow...');
const newStatus = await tmCore.workflow.finalize();
// Output result
formatter.success('Workflow completed', {
taskId: newStatus.taskId,
phase: newStatus.phase,
branchName: newStatus.branchName,
progress: newStatus.progress
});
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
}
}

View File

@@ -8,6 +8,7 @@ import { Command } from 'commander';
import { AbortCommand } from './abort.command.js';
import { CommitCommand } from './commit.command.js';
import { CompleteCommand } from './complete.command.js';
import { FinalizeCommand } from './finalize.command.js';
import { NextCommand } from './next.command.js';
import { ResumeCommand } from './resume.command.js';
import { StartCommand } from './start.command.js';
@@ -66,6 +67,9 @@ export class AutopilotCommand extends Command {
// Show status
this.addCommand(new StatusCommand());
// Finalize workflow
this.addCommand(new FinalizeCommand());
// Abort workflow
this.addCommand(new AbortCommand());
}

View File

@@ -2,15 +2,10 @@
* @fileoverview Next Command - Get next action in TDD workflow
*/
import { WorkflowOrchestrator } from '@tm/core';
import { createTmCore } from '@tm/core';
import { Command } from 'commander';
import { getProjectRoot } from '../../utils/project-root.js';
import {
type AutopilotBaseOptions,
OutputFormatter,
hasWorkflowState,
loadWorkflowState
} from './shared.js';
import { type AutopilotBaseOptions, OutputFormatter } from './shared.js';
type NextOptions = AutopilotBaseOptions;
@@ -54,112 +49,54 @@ export class NextCommand extends Command {
...mergedOptions,
projectRoot
};
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
// Initialize TmCore facade
const tmCore = await createTmCore({ projectPath: projectRoot });
// Check if workflow exists
if (!(await tmCore.workflow.hasWorkflow())) {
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);
}
// Resume workflow and get next action
await tmCore.workflow.resume();
const status = tmCore.workflow.getStatus();
const nextAction = tmCore.workflow.getNextAction();
const context = tmCore.workflow.getContext();
// 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> = {};
// Get current phase info
const phase = status.phase;
const tddPhase = status.tddPhase;
const currentSubtask = status.currentSubtask;
if (phase === 'COMPLETE') {
formatter.success('Workflow complete', {
message: 'All subtasks have been completed',
taskId: state.context.taskId
taskId: status.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
// Output next action using the facade's guidance
const output = {
action: actionType,
description: actionDescription,
action: nextAction.action,
description: nextAction.description,
phase,
tddPhase,
taskId: state.context.taskId,
branchName: state.context.branchName,
...actionDetails
taskId: status.taskId,
branchName: status.branchName,
subtask: currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
}
: null,
nextSteps: nextAction.nextSteps,
lastTestResults: context.lastTestResults
};
if (mergedOptions.json) {

View File

@@ -2,15 +2,10 @@
* @fileoverview Resume Command - Restore and resume TDD workflow
*/
import { WorkflowOrchestrator } from '@tm/core';
import { createTmCore } from '@tm/core';
import { Command } from 'commander';
import { getProjectRoot } from '../../utils/project-root.js';
import {
AutopilotBaseOptions,
OutputFormatter,
hasWorkflowState,
loadWorkflowState
} from './shared.js';
import { AutopilotBaseOptions, OutputFormatter } from './shared.js';
type ResumeOptions = AutopilotBaseOptions;
@@ -42,63 +37,35 @@ export class ResumeCommand extends Command {
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Check for workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
if (!hasState) {
const projectRoot = mergedOptions.projectRoot!;
// Initialize TmCore facade
const tmCore = await createTmCore({ projectPath: projectRoot });
// Check if workflow exists
if (!(await tmCore.workflow.hasWorkflow())) {
formatter.error('No workflow state found', {
suggestion: 'Start a new workflow with: autopilot start <taskId>'
});
process.exit(1);
}
// Load state
// Resume workflow
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();
const status = await tmCore.workflow.resume();
// 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
taskId: status.taskId,
phase: status.phase,
tddPhase: status.tddPhase,
branchName: status.branchName,
progress: status.progress,
currentSubtask: status.currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
attempts: currentSubtask.attempts
id: status.currentSubtask.id,
title: status.currentSubtask.title,
attempts: status.currentSubtask.attempts
}
: null
});

View File

@@ -2,13 +2,6 @@
* @fileoverview Shared utilities for autopilot commands
*/
import {
CommitMessageGenerator,
GitAdapter,
WorkflowOrchestrator,
WorkflowStateManager
} from '@tm/core';
import type { SubtaskInfo, WorkflowContext, WorkflowState } from '@tm/core';
import chalk from 'chalk';
/**
@@ -20,93 +13,6 @@ export interface AutopilotBaseOptions {
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
*/
@@ -231,32 +137,3 @@ export class OutputFormatter {
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

@@ -2,18 +2,10 @@
* @fileoverview Start Command - Initialize and start TDD workflow
*/
import { type WorkflowContext, createTmCore } from '@tm/core';
import { createTmCore, MainTaskIdSchema } from '@tm/core';
import { Command } from 'commander';
import { getProjectRoot } from '../../utils/project-root.js';
import {
AutopilotBaseOptions,
OutputFormatter,
createGitAdapter,
createOrchestrator,
hasWorkflowState,
parseSubtasks,
validateTaskId
} from './shared.js';
import { AutopilotBaseOptions, OutputFormatter } from './shared.js';
interface StartOptions extends AutopilotBaseOptions {
force?: boolean;
@@ -36,7 +28,10 @@ export class StartCommand extends Command {
});
}
private async execute(taskId: string, options: StartOptions): Promise<void> {
private async execute(
rawTaskId: string,
options: StartOptions
): Promise<void> {
// Inherit parent options
const parentOpts = this.parent?.opts() as AutopilotBaseOptions;
const mergedOptions: StartOptions = {
@@ -50,17 +45,24 @@ export class StartCommand extends Command {
const formatter = new OutputFormatter(mergedOptions.json || false);
try {
// Validate task ID
if (!validateTaskId(taskId)) {
// Validate and normalize task ID
const parseResult = MainTaskIdSchema.safeParse(rawTaskId);
if (!parseResult.success) {
formatter.error('Invalid task ID format', {
taskId,
expected: 'Format: number or number.number (e.g., "1" or "1.2")'
taskId: rawTaskId,
error: parseResult.error.issues[0]?.message
});
process.exit(1);
}
const taskId = parseResult.data;
const projectRoot = mergedOptions.projectRoot!;
// Initialize TmCore facade
const tmCore = await createTmCore({ projectPath: projectRoot });
// Check for existing workflow state
const hasState = await hasWorkflowState(mergedOptions.projectRoot!);
const hasState = await tmCore.workflow.hasWorkflow();
if (hasState && !mergedOptions.force) {
formatter.error(
'Workflow state already exists. Use --force to overwrite or resume with "autopilot resume"'
@@ -68,14 +70,13 @@ export class StartCommand extends Command {
process.exit(1);
}
// Initialize Task Master Core
const tmCore = await createTmCore({
projectPath: mergedOptions.projectRoot!
});
// Get current tag from ConfigManager
const currentTag = tmCore.config.getActiveTag();
// Get org slug from auth context (for API storage mode)
const authContext = tmCore.auth.getContext();
const orgSlug = authContext?.orgSlug;
// Load task
formatter.info(`Loading task ${taskId}...`);
const { task } = await tmCore.tasks.get(taskId);
@@ -94,68 +95,36 @@ export class StartCommand extends Command {
process.exit(1);
}
// Initialize Git adapter
const gitAdapter = createGitAdapter(mergedOptions.projectRoot!);
await gitAdapter.ensureGitRepository();
await gitAdapter.ensureCleanWorkingTree();
// Parse subtasks
// Parse max attempts
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
// Start workflow via tmCore facade (handles git, orchestrator, status updates internally)
formatter.info('Starting TDD workflow...');
const status = await tmCore.workflow.start({
taskId,
taskTitle: task.title,
subtasks: task.subtasks.map((st: any) => ({
id: st.id,
title: st.title,
status: st.status,
maxAttempts
})),
maxAttempts,
force: mergedOptions.force,
tag: currentTag,
orgSlug
});
// Output success
formatter.success('TDD workflow started', {
taskId: task.id,
taskId: status.taskId,
title: task.title,
phase: orchestrator.getCurrentPhase(),
tddPhase: orchestrator.getCurrentTDDPhase(),
branchName,
subtasks: subtasks.length,
currentSubtask: subtasks[0]?.title
phase: status.phase,
tddPhase: status.tddPhase,
branchName: status.branchName,
subtasks: task.subtasks.length,
currentSubtask: status.currentSubtask?.title
});
// Clean up
} catch (error) {
formatter.error((error as Error).message);
if (mergedOptions.verbose) {

View File

@@ -2,15 +2,10 @@
* @fileoverview Status Command - Show workflow progress
*/
import { WorkflowOrchestrator } from '@tm/core';
import { createTmCore } from '@tm/core';
import { Command } from 'commander';
import { getProjectRoot } from '../../utils/project-root.js';
import {
AutopilotBaseOptions,
OutputFormatter,
hasWorkflowState,
loadWorkflowState
} from './shared.js';
import { AutopilotBaseOptions, OutputFormatter } from './shared.js';
type StatusOptions = AutopilotBaseOptions;
@@ -42,62 +37,56 @@ export class StatusCommand extends Command {
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);
const projectRoot = mergedOptions.projectRoot!;
// Initialize TmCore facade
const tmCore = await createTmCore({ projectPath: projectRoot });
// Check if workflow exists
if (!(await tmCore.workflow.hasWorkflow())) {
if (mergedOptions.json) {
formatter.output({
active: false,
message: 'No active workflow'
});
} else {
formatter.info('No active workflow');
console.log('Start a workflow with: autopilot start <taskId>');
}
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 status information
const phase = orchestrator.getCurrentPhase();
const tddPhase = orchestrator.getCurrentTDDPhase();
const progress = orchestrator.getProgress();
const currentSubtask = orchestrator.getCurrentSubtask();
const errors = state.context.errors ?? [];
// Resume workflow and get status
await tmCore.workflow.resume();
const workflowStatus = tmCore.workflow.getStatus();
const context = tmCore.workflow.getContext();
// 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
taskId: workflowStatus.taskId,
phase: workflowStatus.phase,
tddPhase: workflowStatus.tddPhase,
branchName: workflowStatus.branchName,
progress: workflowStatus.progress,
currentSubtask: workflowStatus.currentSubtask
? {
id: currentSubtask.id,
title: currentSubtask.title,
status: currentSubtask.status,
attempts: currentSubtask.attempts,
maxAttempts: currentSubtask.maxAttempts
id: workflowStatus.currentSubtask.id,
title: workflowStatus.currentSubtask.title,
attempts: workflowStatus.currentSubtask.attempts,
maxAttempts: workflowStatus.currentSubtask.maxAttempts
}
: null,
subtasks: state.context.subtasks.map((st) => ({
subtasks: 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
errors:
context.errors && context.errors.length > 0
? context.errors
: undefined,
metadata: context.metadata
};
if (mergedOptions.json) {

View File

@@ -7,7 +7,7 @@ import {
type TaskStatus,
type TmCore,
createTmCore,
normalizeDisplayId
TaskIdSchema
} from '@tm/core';
import type { StorageType } from '@tm/core';
import boxen from 'boxen';
@@ -150,11 +150,21 @@ export class SetStatusCommand extends Command {
projectPath: getProjectRoot(options.project)
});
// Parse task IDs (handle comma-separated values)
// Normalize display IDs (e.g., "ham31" → "HAM-31")
const taskIds = options.id
.split(',')
.map((id) => normalizeDisplayId(id.trim()));
// Parse and validate task IDs (handle comma-separated values)
const rawIds = options.id.split(',').map((id) => id.trim());
const taskIds: string[] = [];
for (const rawId of rawIds) {
const parseResult = TaskIdSchema.safeParse(rawId);
if (!parseResult.success) {
console.error(
chalk.red(`Invalid task ID: ${rawId}`),
chalk.gray(`- ${parseResult.error.issues[0]?.message}`)
);
process.exit(1);
}
taskIds.push(parseResult.data);
}
// Update each task
const updatedTasks: Array<{

View File

@@ -3,12 +3,7 @@
* Extends Commander.Command for better integration with the framework
*/
import {
type Task,
type TmCore,
createTmCore,
normalizeDisplayId
} from '@tm/core';
import { type Task, type TmCore, createTmCore, TaskIdSchema } from '@tm/core';
import type { StorageType, Subtask } from '@tm/core';
import boxen from 'boxen';
import chalk from 'chalk';
@@ -106,12 +101,24 @@ export class ShowCommand extends Command {
process.exit(1);
}
// Check if multiple IDs are provided (comma-separated)
// Normalize display IDs (e.g., "ham31" → "HAM-31")
const taskIds = idArg
// Parse and validate task IDs (handle comma-separated values)
const rawIds = idArg
.split(',')
.map((id) => normalizeDisplayId(id.trim()))
.map((id) => id.trim())
.filter((id) => id.length > 0);
const taskIds: string[] = [];
for (const rawId of rawIds) {
const parseResult = TaskIdSchema.safeParse(rawId);
if (!parseResult.success) {
console.error(
chalk.red(`Invalid task ID: ${rawId}`),
chalk.gray(`- ${parseResult.error.issues[0]?.message}`)
);
process.exit(1);
}
taskIds.push(parseResult.data);
}
// Get tasks from core
const result =

View File

@@ -1,540 +0,0 @@
/**
* @fileoverview Integration tests for autopilot workflow commands
*/
import type { WorkflowState } from '@tm/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// 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

@@ -3,27 +3,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
OutputFormatter,
parseSubtasks,
validateTaskId
} 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()
}));
import { OutputFormatter } from '../../../../src/commands/autopilot/shared.js';
describe('Autopilot Shared Utilities', () => {
beforeEach(() => {
@@ -34,82 +14,6 @@ describe('Autopilot Shared Utilities', () => {
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;
@@ -183,11 +87,22 @@ describe('Autopilot Shared Utilities', () => {
formatter.warning('Warning message');
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('⚠ Warning message')
expect.stringContaining('⚠ Warning message')
);
consoleWarnSpy.mockRestore();
});
it('should output formatted text for info', () => {
const formatter = new OutputFormatter(false);
formatter.info('Info message');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining(' Info message')
);
});
});
describe('info suppression', () => {
it('should not output info in JSON mode', () => {
const formatter = new OutputFormatter(true);
formatter.info('Info message');

View File

@@ -3,7 +3,6 @@
* Abort a running TDD workflow and clean up state
*/
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import type { ToolContext } from '../../shared/types.js';
@@ -28,16 +27,14 @@ export function registerAutopilotAbortTool(server: FastMCP) {
parameters: AbortSchema,
execute: withToolContext(
'autopilot-abort',
async (args: AbortArgs, { log }: ToolContext) => {
async (args: AbortArgs, { log, tmCore }: ToolContext) => {
const { projectRoot } = args;
try {
log.info(`Aborting autopilot workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
const hasWorkflow = await workflowService.hasWorkflow();
const hasWorkflow = await tmCore.workflow.hasWorkflow();
if (!hasWorkflow) {
log.warn('No active workflow to abort');
@@ -55,11 +52,11 @@ export function registerAutopilotAbortTool(server: FastMCP) {
}
// Get info before aborting
await workflowService.resumeWorkflow();
const status = workflowService.getStatus();
await tmCore.workflow.resume();
const status = tmCore.workflow.getStatus();
// Abort workflow
await workflowService.abortWorkflow();
await tmCore.workflow.abort();
log.info('Workflow state deleted');

View File

@@ -3,7 +3,7 @@
* Create a git commit with automatic staging and message generation
*/
import { CommitMessageGenerator, GitAdapter, WorkflowService } from '@tm/core';
import { CommitMessageGenerator, GitAdapter } from '@tm/core';
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import type { ToolContext } from '../../shared/types.js';
@@ -38,16 +38,14 @@ export function registerAutopilotCommitTool(server: FastMCP) {
parameters: CommitSchema,
execute: withToolContext(
'autopilot-commit',
async (args: CommitArgs, { log }: ToolContext) => {
async (args: CommitArgs, { log, tmCore }: ToolContext) => {
const { projectRoot, files, customMessage } = args;
try {
log.info(`Creating commit for workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
if (!(await tmCore.workflow.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
@@ -62,9 +60,9 @@ export function registerAutopilotCommitTool(server: FastMCP) {
}
// Resume workflow
await workflowService.resumeWorkflow();
const status = workflowService.getStatus();
const workflowContext = workflowService.getContext();
await tmCore.workflow.resume();
const status = tmCore.workflow.getStatus();
const workflowContext = tmCore.workflow.getContext();
// Verify we're in COMMIT phase
if (status.tddPhase !== 'COMMIT') {
@@ -187,7 +185,8 @@ export function registerAutopilotCommitTool(server: FastMCP) {
const lastCommit = await gitAdapter.getLastCommit();
// Complete COMMIT phase and advance workflow
const newStatus = await workflowService.commit();
// Status updates (subtask → done) are handled internally by tmCore.workflow
const newStatus = await tmCore.workflow.commit();
log.info(
`Commit completed. Current phase: ${newStatus.tddPhase || newStatus.phase}`
@@ -196,7 +195,7 @@ export function registerAutopilotCommitTool(server: FastMCP) {
const isComplete = newStatus.phase === 'COMPLETE';
// Get next action with guidance
const nextAction = workflowService.getNextAction();
const nextAction = tmCore.workflow.getNextAction();
return handleApiResult({
result: {

View File

@@ -3,7 +3,6 @@
* Complete the current TDD phase with test result validation
*/
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import type { ToolContext } from '../../shared/types.js';
@@ -36,16 +35,14 @@ export function registerAutopilotCompleteTool(server: FastMCP) {
parameters: CompletePhaseSchema,
execute: withToolContext(
'autopilot-complete-phase',
async (args: CompletePhaseArgs, { log }: ToolContext) => {
async (args: CompletePhaseArgs, { log, tmCore }: ToolContext) => {
const { projectRoot, testResults } = args;
try {
log.info(`Completing current phase in workflow for ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
if (!(await tmCore.workflow.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
@@ -60,8 +57,8 @@ export function registerAutopilotCompleteTool(server: FastMCP) {
}
// Resume workflow to get current state
await workflowService.resumeWorkflow();
const currentStatus = workflowService.getStatus();
await tmCore.workflow.resume();
const currentStatus = tmCore.workflow.getStatus();
// Validate that we're in a TDD phase (RED or GREEN)
if (!currentStatus.tddPhase) {
@@ -105,8 +102,8 @@ export function registerAutopilotCompleteTool(server: FastMCP) {
};
// Complete phase with test results
const status = await workflowService.completePhase(fullTestResults);
const nextAction = workflowService.getNextAction();
const status = await tmCore.workflow.completePhase(fullTestResults);
const nextAction = tmCore.workflow.getNextAction();
log.info(
`Phase completed. New phase: ${status.tddPhase || status.phase}`

View File

@@ -3,7 +3,6 @@
* Finalize and complete the workflow with working tree validation
*/
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import type { ToolContext } from '../../shared/types.js';
@@ -28,16 +27,14 @@ export function registerAutopilotFinalizeTool(server: FastMCP) {
parameters: FinalizeSchema,
execute: withToolContext(
'autopilot-finalize',
async (args: FinalizeArgs, { log }: ToolContext) => {
async (args: FinalizeArgs, { log, tmCore }: ToolContext) => {
const { projectRoot } = args;
try {
log.info(`Finalizing workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
if (!(await tmCore.workflow.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
@@ -52,8 +49,8 @@ export function registerAutopilotFinalizeTool(server: FastMCP) {
}
// Resume workflow
await workflowService.resumeWorkflow();
const currentStatus = workflowService.getStatus();
await tmCore.workflow.resume();
const currentStatus = tmCore.workflow.getStatus();
// Verify we're in FINALIZE phase
if (currentStatus.phase !== 'FINALIZE') {
@@ -70,12 +67,13 @@ export function registerAutopilotFinalizeTool(server: FastMCP) {
}
// Finalize workflow (validates clean working tree)
const newStatus = await workflowService.finalizeWorkflow();
// Status updates (main task → done) are handled internally by tmCore.workflow
const newStatus = await tmCore.workflow.finalize();
log.info('Workflow finalized successfully');
// Get next action
const nextAction = workflowService.getNextAction();
const nextAction = tmCore.workflow.getNextAction();
return handleApiResult({
result: {

View File

@@ -3,7 +3,6 @@
* Get the next action to perform in the TDD workflow
*/
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import type { ToolContext } from '../../shared/types.js';
@@ -28,16 +27,14 @@ export function registerAutopilotNextTool(server: FastMCP) {
parameters: NextActionSchema,
execute: withToolContext(
'autopilot-next',
async (args: NextActionArgs, { log }: ToolContext) => {
async (args: NextActionArgs, { log, tmCore }: ToolContext) => {
const { projectRoot } = args;
try {
log.info(`Getting next action for workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
if (!(await tmCore.workflow.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
@@ -52,11 +49,11 @@ export function registerAutopilotNextTool(server: FastMCP) {
}
// Resume to load state
await workflowService.resumeWorkflow();
await tmCore.workflow.resume();
// Get next action
const nextAction = workflowService.getNextAction();
const status = workflowService.getStatus();
const nextAction = tmCore.workflow.getNextAction();
const status = tmCore.workflow.getStatus();
log.info(`Next action determined: ${nextAction.action}`);

View File

@@ -3,7 +3,6 @@
* Resume a previously started TDD workflow from saved state
*/
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import type { ToolContext } from '../../shared/types.js';
@@ -28,16 +27,14 @@ export function registerAutopilotResumeTool(server: FastMCP) {
parameters: ResumeWorkflowSchema,
execute: withToolContext(
'autopilot-resume',
async (args: ResumeWorkflowArgs, { log }: ToolContext) => {
async (args: ResumeWorkflowArgs, { log, tmCore }: ToolContext) => {
const { projectRoot } = args;
try {
log.info(`Resuming autopilot workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
if (!(await tmCore.workflow.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
@@ -52,8 +49,8 @@ export function registerAutopilotResumeTool(server: FastMCP) {
}
// Resume workflow
const status = await workflowService.resumeWorkflow();
const nextAction = workflowService.getNextAction();
const status = await tmCore.workflow.resume();
const nextAction = tmCore.workflow.getNextAction();
log.info(`Workflow resumed successfully for task ${status.taskId}`);

View File

@@ -3,18 +3,16 @@
* Initialize and start a new TDD workflow for a task
*/
import { WorkflowService } from '@tm/core';
import { MainTaskIdSchemaForMcp, normalizeDisplayId } from '@tm/core';
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import type { ToolContext } from '../../shared/types.js';
import { handleApiResult, withToolContext } from '../../shared/utils.js';
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.'
),
taskId: MainTaskIdSchemaForMcp.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'),
@@ -32,18 +30,6 @@ const StartWorkflowSchema = z.object({
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
*/
@@ -56,30 +42,22 @@ export function registerAutopilotStartTool(server: FastMCP) {
execute: withToolContext(
'autopilot-start',
async (args: StartWorkflowArgs, { log, tmCore }: ToolContext) => {
const { taskId, projectRoot, maxAttempts, force } = args;
const { taskId: rawTaskId, projectRoot, maxAttempts, force } = args;
// Normalize task ID (e.g., "ham1" → "HAM-1")
const taskId = normalizeDisplayId(rawTaskId);
try {
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,
projectRoot
});
}
// Get current tag from ConfigManager
const currentTag = tmCore.config.getActiveTag();
// Get org slug from auth context (for API storage mode)
const authContext = tmCore.auth.getContext();
const orgSlug = authContext?.orgSlug;
const taskResult = await tmCore.tasks.get(taskId);
if (!taskResult || !taskResult.task) {
@@ -109,11 +87,8 @@ export function registerAutopilotStartTool(server: FastMCP) {
});
}
// Initialize workflow service
const workflowService = new WorkflowService(projectRoot);
// Check for existing workflow
const hasWorkflow = await workflowService.hasWorkflow();
const hasWorkflow = await tmCore.workflow.hasWorkflow();
if (hasWorkflow && !force) {
log.warn('Workflow state already exists');
return handleApiResult({
@@ -129,8 +104,8 @@ export function registerAutopilotStartTool(server: FastMCP) {
});
}
// Start workflow
const status = await workflowService.startWorkflow({
// Start workflow via tmCore facade (handles status updates internally)
const status = await tmCore.workflow.start({
taskId,
taskTitle: task.title,
subtasks: task.subtasks.map((st: any) => ({
@@ -141,13 +116,14 @@ export function registerAutopilotStartTool(server: FastMCP) {
})),
maxAttempts,
force,
tag: currentTag // Pass current tag for branch naming
tag: currentTag, // Pass current tag for branch naming (local storage)
orgSlug // Pass org slug for branch naming (API storage, takes precedence)
});
log.info(`Workflow started successfully for task ${taskId}`);
// Get next action with guidance from WorkflowService
const nextAction = workflowService.getNextAction();
// Get next action with guidance
const nextAction = tmCore.workflow.getNextAction();
return handleApiResult({
result: {

View File

@@ -3,7 +3,6 @@
* Get comprehensive workflow status and progress information
*/
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import type { ToolContext } from '../../shared/types.js';
@@ -28,16 +27,14 @@ export function registerAutopilotStatusTool(server: FastMCP) {
parameters: StatusSchema,
execute: withToolContext(
'autopilot-status',
async (args: StatusArgs, { log }: ToolContext) => {
async (args: StatusArgs, { log, tmCore }: ToolContext) => {
const { projectRoot } = args;
try {
log.info(`Getting workflow status for ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
if (!(await tmCore.workflow.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
@@ -52,10 +49,10 @@ export function registerAutopilotStatusTool(server: FastMCP) {
}
// Resume to load state
await workflowService.resumeWorkflow();
await tmCore.workflow.resume();
// Get status
const status = workflowService.getStatus();
const status = tmCore.workflow.getStatus();
log.info(`Workflow status retrieved for task ${status.taskId}`);

174
package-lock.json generated
View File

@@ -18,7 +18,7 @@
"@ai-sdk/anthropic": "^2.0.18",
"@ai-sdk/azure": "^2.0.34",
"@ai-sdk/google": "^2.0.16",
"@ai-sdk/google-vertex": "^3.0.29",
"@ai-sdk/google-vertex": "^3.0.86",
"@ai-sdk/groq": "^2.0.21",
"@ai-sdk/mistral": "^2.0.16",
"@ai-sdk/openai": "^2.0.34",
@@ -109,7 +109,7 @@
"node": ">=20.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-code": "^1.0.88",
"@anthropic-ai/claude-code": "^2.0.58",
"@biomejs/cli-linux-x64": "^1.9.4"
}
},
@@ -441,16 +441,16 @@
}
},
"node_modules/@ai-sdk/google-vertex": {
"version": "3.0.81",
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-3.0.81.tgz",
"integrity": "sha512-yrl5Ug0Mqwo9ya45oxczgy2RWgpEA/XQQCSFYP+3NZMQ4yA3Iim1vkOjVCsGaZZ8rjVk395abi1ZMZV0/6rqVA==",
"version": "3.0.86",
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-3.0.86.tgz",
"integrity": "sha512-rdXVXURmmb8A6ma8aud0xqVujbZ9E7Gt68xj6sMw9erZ+HXpBZHJRcx+LYuoLvLoWw9bSLwQJ0QbaXFM68xG4g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "2.0.50",
"@ai-sdk/anthropic": "2.0.53",
"@ai-sdk/google": "2.0.44",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"google-auth-library": "^9.15.0"
"google-auth-library": "^10.5.0"
},
"engines": {
"node": ">=18"
@@ -459,6 +459,91 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/anthropic": {
"version": "2.0.53",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.53.tgz",
"integrity": "sha512-ih7NV+OFSNWZCF+tYYD7ovvvM+gv7TRKQblpVohg2ipIwC9Y0TirzocJVREzZa/v9luxUwFbsPji++DUDWWxsg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@ai-sdk/google-vertex/node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/groq": {
"version": "2.0.32",
"resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-2.0.32.tgz",
@@ -642,9 +727,9 @@
}
},
"node_modules/@anthropic-ai/claude-code": {
"version": "1.0.128",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.128.tgz",
"integrity": "sha512-uUg5cFMJfeQetQzFw76Vpbro6DAXst2Lpu8aoZWRFSoQVYu5ZSAnbBoxaWmW/IgnHSqIIvtMwzCoqmcA9j9rNQ==",
"version": "2.0.58",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.0.58.tgz",
"integrity": "sha512-6/n+PrMrU6QuA0rV23oimJK6R3BxefXeBLnxTumVabmzRX5oYjZLGLIdP0PCTA6rKuSeXUjjGd1yb55B0clO+w==",
"license": "SEE LICENSE IN README.md",
"optional": true,
"bin": {
@@ -659,6 +744,8 @@
"@img/sharp-linux-arm": "^0.33.5",
"@img/sharp-linux-arm64": "^0.33.5",
"@img/sharp-linux-x64": "^0.33.5",
"@img/sharp-linuxmusl-arm64": "^0.33.5",
"@img/sharp-linuxmusl-x64": "^0.33.5",
"@img/sharp-win32-x64": "^0.33.5"
}
},
@@ -9941,7 +10028,6 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -31305,6 +31391,72 @@
"node": ">=0.10.0"
}
},
"node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/rimraf/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rolldown": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz",

View File

@@ -57,7 +57,7 @@
"@ai-sdk/anthropic": "^2.0.18",
"@ai-sdk/azure": "^2.0.34",
"@ai-sdk/google": "^2.0.16",
"@ai-sdk/google-vertex": "^3.0.29",
"@ai-sdk/google-vertex": "^3.0.86",
"@ai-sdk/groq": "^2.0.21",
"@ai-sdk/mistral": "^2.0.16",
"@ai-sdk/openai": "^2.0.34",
@@ -115,7 +115,7 @@
"zod": "^4.1.12"
},
"optionalDependencies": {
"@anthropic-ai/claude-code": "^1.0.88",
"@anthropic-ai/claude-code": "^2.0.59",
"@biomejs/cli-linux-x64": "^1.9.4"
},
"engines": {

View File

@@ -0,0 +1,13 @@
/**
* @fileoverview Zod schemas for validation
*/
export {
TaskIdSchema,
MainTaskIdSchema,
TaskIdSchemaForMcp,
MainTaskIdSchemaForMcp,
normalizeDisplayId,
type TaskId,
type MainTaskId
} from './task-id.schema.js';

View File

@@ -0,0 +1,201 @@
/**
* @fileoverview Unit tests for task ID schemas
*/
import { describe, expect, it } from 'vitest';
import {
MainTaskIdSchema,
TaskIdSchema,
normalizeDisplayId
} from './task-id.schema.js';
describe('normalizeDisplayId', () => {
describe('file storage IDs (numeric)', () => {
it('should return numeric main task IDs unchanged', () => {
expect(normalizeDisplayId('1')).toBe('1');
expect(normalizeDisplayId('123')).toBe('123');
});
it('should return numeric subtask IDs unchanged', () => {
expect(normalizeDisplayId('1.1')).toBe('1.1');
expect(normalizeDisplayId('123.45')).toBe('123.45');
});
it('should trim whitespace', () => {
expect(normalizeDisplayId(' 1 ')).toBe('1');
expect(normalizeDisplayId(' 1.2 ')).toBe('1.2');
});
});
describe('API storage IDs (prefixed)', () => {
it('should normalize lowercase without hyphen', () => {
expect(normalizeDisplayId('ham1')).toBe('HAM-1');
expect(normalizeDisplayId('ham123')).toBe('HAM-123');
});
it('should normalize uppercase without hyphen', () => {
expect(normalizeDisplayId('HAM1')).toBe('HAM-1');
expect(normalizeDisplayId('HAM123')).toBe('HAM-123');
});
it('should normalize lowercase with hyphen', () => {
expect(normalizeDisplayId('ham-1')).toBe('HAM-1');
expect(normalizeDisplayId('ham-123')).toBe('HAM-123');
});
it('should keep uppercase with hyphen unchanged', () => {
expect(normalizeDisplayId('HAM-1')).toBe('HAM-1');
expect(normalizeDisplayId('HAM-123')).toBe('HAM-123');
});
it('should normalize mixed case', () => {
expect(normalizeDisplayId('Ham-1')).toBe('HAM-1');
expect(normalizeDisplayId('hAm1')).toBe('HAM-1');
});
it('should trim whitespace', () => {
expect(normalizeDisplayId(' ham1 ')).toBe('HAM-1');
expect(normalizeDisplayId(' HAM-1 ')).toBe('HAM-1');
});
});
describe('edge cases', () => {
it('should return empty string for empty input', () => {
expect(normalizeDisplayId('')).toBe('');
});
it('should return null/undefined as-is', () => {
expect(normalizeDisplayId(null as any)).toBe(null);
expect(normalizeDisplayId(undefined as any)).toBe(undefined);
});
it('should return unmatched patterns as-is', () => {
expect(normalizeDisplayId('abc')).toBe('abc');
expect(normalizeDisplayId('HAMSTER-1')).toBe('HAMSTER-1'); // 7 letters, not 3
expect(normalizeDisplayId('AB-1')).toBe('AB-1'); // 2 letters, not 3
});
});
});
describe('TaskIdSchema', () => {
describe('file storage IDs', () => {
it('should accept numeric main task IDs', () => {
expect(TaskIdSchema.safeParse('1').success).toBe(true);
expect(TaskIdSchema.safeParse('123').success).toBe(true);
});
it('should accept numeric subtask IDs (one level)', () => {
expect(TaskIdSchema.safeParse('1.1').success).toBe(true);
expect(TaskIdSchema.safeParse('123.45').success).toBe(true);
});
it('should reject deeply nested IDs', () => {
expect(TaskIdSchema.safeParse('1.2.3').success).toBe(false);
expect(TaskIdSchema.safeParse('1.2.3.4').success).toBe(false);
});
it('should return normalized value', () => {
const result = TaskIdSchema.safeParse(' 1 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('1');
}
});
});
describe('API storage IDs', () => {
it('should accept prefixed IDs with hyphen', () => {
expect(TaskIdSchema.safeParse('HAM-1').success).toBe(true);
expect(TaskIdSchema.safeParse('ham-1').success).toBe(true);
});
it('should accept prefixed IDs without hyphen', () => {
expect(TaskIdSchema.safeParse('HAM1').success).toBe(true);
expect(TaskIdSchema.safeParse('ham1').success).toBe(true);
});
it('should reject prefixed subtask IDs', () => {
expect(TaskIdSchema.safeParse('HAM-1.2').success).toBe(false);
expect(TaskIdSchema.safeParse('ham1.2').success).toBe(false);
});
it('should normalize to uppercase with hyphen', () => {
const result = TaskIdSchema.safeParse('ham1');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('HAM-1');
}
});
});
describe('invalid inputs', () => {
it('should reject empty string', () => {
expect(TaskIdSchema.safeParse('').success).toBe(false);
});
it('should reject whitespace only', () => {
expect(TaskIdSchema.safeParse(' ').success).toBe(false);
});
it('should reject invalid formats', () => {
expect(TaskIdSchema.safeParse('abc').success).toBe(false);
expect(TaskIdSchema.safeParse('HAMSTER-1').success).toBe(false);
expect(TaskIdSchema.safeParse('AB-1').success).toBe(false);
});
});
});
describe('MainTaskIdSchema', () => {
describe('valid main tasks', () => {
it('should accept numeric main task IDs', () => {
expect(MainTaskIdSchema.safeParse('1').success).toBe(true);
expect(MainTaskIdSchema.safeParse('123').success).toBe(true);
});
it('should accept prefixed main task IDs', () => {
expect(MainTaskIdSchema.safeParse('HAM-1').success).toBe(true);
expect(MainTaskIdSchema.safeParse('ham-1').success).toBe(true);
expect(MainTaskIdSchema.safeParse('ham1').success).toBe(true);
});
it('should normalize prefixed IDs', () => {
const result = MainTaskIdSchema.safeParse('ham1');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('HAM-1');
}
});
});
describe('invalid subtasks', () => {
it('should reject numeric subtask IDs', () => {
const result = MainTaskIdSchema.safeParse('1.2');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('Subtask');
}
});
it('should reject prefixed subtask IDs', () => {
expect(MainTaskIdSchema.safeParse('HAM-1.2').success).toBe(false);
});
});
describe('error messages', () => {
it('should provide helpful error for invalid format', () => {
const result = MainTaskIdSchema.safeParse('invalid');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('Invalid task ID');
}
});
it('should provide helpful error for subtask', () => {
const result = MainTaskIdSchema.safeParse('1.2');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('Subtask');
}
});
});
});

View File

@@ -0,0 +1,217 @@
/**
* @fileoverview Zod schemas for task ID validation
* Provides type-safe validation and normalization of task IDs
*
* Task ID Formats:
*
* FILE STORAGE (local):
* - Main tasks: "1", "2", "3" (numeric only)
* - Subtasks: "1.1", "1.2" (one level only, no "1.2.3")
*
* API STORAGE (Hamster):
* - Main tasks only: "HAM-1", "HAM-2" (3 letters + hyphen + number)
* - Input accepts: "ham-1", "HAM-1", "ham1", "HAM1" (permissive input)
* - Output always: "HAM-1" format (uppercase with hyphen)
* - No subtasks: Never "HAM-1.2"
*/
import { z } from 'zod';
/**
* Normalizes a display ID to the standard format
*
* API Storage IDs: Always uppercase with hyphen (HAM-1)
* - "ham1" → "HAM-1"
* - "HAM1" → "HAM-1"
* - "ham-1" → "HAM-1"
* - "HAM-1" → "HAM-1"
*
* File Storage IDs: Unchanged
* - "1" → "1"
* - "1.1" → "1.1"
*
* @param id - The display ID to normalize
* @returns The normalized display ID
*/
export function normalizeDisplayId(id: string): string {
if (!id) return id;
const trimmed = id.trim();
// File storage: numeric (main or subtask) - return as-is
if (/^\d+(\.\d+)?$/.test(trimmed)) {
return trimmed;
}
// API storage: 3 letters + optional hyphen + number
// e.g., "ham1", "HAM1", "ham-1", "HAM-1"
const apiPattern = /^([a-zA-Z]{3})-?(\d+)$/;
const apiMatch = trimmed.match(apiPattern);
if (apiMatch) {
const prefix = apiMatch[1].toUpperCase();
const number = apiMatch[2];
return `${prefix}-${number}`;
}
// No pattern matched, return as-is
return trimmed;
}
/**
* Pattern for file storage main task: "1", "2", "123"
*/
const FILE_MAIN_PATTERN = /^\d+$/;
/**
* Pattern for file storage subtask: "1.1", "2.3" (exactly one dot, one level)
*/
const FILE_SUBTASK_PATTERN = /^\d+\.\d+$/;
/**
* Pattern for API storage main task: "HAM-1", "ham-1", "HAM1", "ham1"
* Accepts with or without hyphen, normalizes to "HAM-1" format
*/
const API_MAIN_PATTERN = /^[a-zA-Z]{3}-?\d+$/;
/**
* Check if a task ID format is valid
* Accepts: "1", "1.1", "HAM-1", "ham-1", "HAM1", "ham1"
* Rejects: "1.2.3", "HAM-1.2"
* Note: All API IDs normalize to "HAM-1" format (uppercase with hyphen)
*/
function isValidTaskIdFormat(id: string): boolean {
if (!id) return false;
const trimmed = id.trim();
// File storage: numeric main or subtask (one level only)
if (FILE_MAIN_PATTERN.test(trimmed) || FILE_SUBTASK_PATTERN.test(trimmed)) {
return true;
}
// API storage: prefixed main task only (no subtasks in API storage)
if (API_MAIN_PATTERN.test(trimmed)) {
return true;
}
return false;
}
/**
* Check if a task ID is a main task (not a subtask)
* Main tasks: "1", "2", "HAM-1"
* Subtasks: "1.1", "2.3" (file storage only)
*/
function isMainTask(taskId: string): boolean {
if (!taskId) return false;
const trimmed = taskId.trim();
// File storage main task
if (FILE_MAIN_PATTERN.test(trimmed)) {
return true;
}
// API storage main task (always main, no subtasks in API)
if (API_MAIN_PATTERN.test(trimmed)) {
return true;
}
return false;
}
/**
* Base schema for any task ID (main task or subtask) - validation only
* Use this for MCP tool schemas (JSON Schema can't represent transforms)
* Call normalizeDisplayId() manually after validation if needed
*/
const taskIdBaseSchema = z.string().trim().refine(isValidTaskIdFormat, {
message:
'Invalid task ID format. Expected: numeric ("1", "1.2") or prefixed with hyphen ("HAM-1")'
});
/**
* Base schema for main task IDs only - validation only
* Use this for MCP tool schemas (JSON Schema can't represent transforms)
* Call normalizeDisplayId() manually after validation if needed
*/
const mainTaskIdBaseSchema = z
.string()
.trim()
.refine(isValidTaskIdFormat, {
message:
'Invalid task ID format. Expected: numeric ("1") or prefixed with hyphen ("HAM-1")'
})
.refine(isMainTask, {
message:
'Subtask IDs are not allowed. Please provide a main task ID (e.g., "1", "HAM-1")'
});
/**
* Zod schema for any task ID (main task or subtask)
* Validates format and transforms to normalized form
*
* NOTE: For MCP tools, use TaskIdSchemaForMcp instead (no transform)
*
* @example
* ```typescript
* // File storage
* TaskIdSchema.safeParse('1'); // { success: true, data: '1' }
* TaskIdSchema.safeParse('1.2'); // { success: true, data: '1.2' }
*
* // API storage
* TaskIdSchema.safeParse('ham-1'); // { success: true, data: 'HAM-1' }
* TaskIdSchema.safeParse('HAM-1'); // { success: true, data: 'HAM-1' }
*
* // Permissive input, normalized output
* TaskIdSchema.safeParse('ham1'); // { success: true, data: 'HAM-1' }
* TaskIdSchema.safeParse('HAM1'); // { success: true, data: 'HAM-1' }
*
* // Invalid
* TaskIdSchema.safeParse('1.2.3'); // { success: false } - too deep
* TaskIdSchema.safeParse('HAM-1.2'); // { success: false } - no API subtasks
* ```
*/
export const TaskIdSchema = taskIdBaseSchema.transform(normalizeDisplayId);
/**
* Zod schema for main task IDs only (no subtasks)
* Validates format, ensures no subtask part, and transforms to normalized form
*
* NOTE: For MCP tools, use MainTaskIdSchemaForMcp instead (no transform)
*
* @example
* ```typescript
* // Valid main tasks
* MainTaskIdSchema.safeParse('1'); // { success: true, data: '1' }
* MainTaskIdSchema.safeParse('ham-1'); // { success: true, data: 'HAM-1' }
* MainTaskIdSchema.safeParse('ham1'); // { success: true, data: 'HAM-1' }
*
* // Invalid (subtasks)
* MainTaskIdSchema.safeParse('1.2'); // { success: false }
* ```
*/
export const MainTaskIdSchema =
mainTaskIdBaseSchema.transform(normalizeDisplayId);
/**
* Zod schema for any task ID - validation only, no transform
* Use this for MCP tool parameter schemas (JSON Schema can't represent transforms)
* Call normalizeDisplayId() manually after validation
*/
export const TaskIdSchemaForMcp = taskIdBaseSchema;
/**
* Zod schema for main task IDs - validation only, no transform
* Use this for MCP tool parameter schemas (JSON Schema can't represent transforms)
* Call normalizeDisplayId() manually after validation
*/
export const MainTaskIdSchemaForMcp = mainTaskIdBaseSchema;
/**
* Type for a validated and normalized task ID
*/
export type TaskId = z.output<typeof TaskIdSchema>;
/**
* Type for a validated and normalized main task ID
*/
export type MainTaskId = z.output<typeof MainTaskIdSchema>;

View File

@@ -4,14 +4,14 @@
*/
// Export ID generation utilities
// Note: normalizeDisplayId is now exported from common/schemas/task-id.schema.ts
export {
generateTaskId as generateId, // Alias for backward compatibility
generateTaskId,
generateSubtaskId,
isValidTaskId,
isValidSubtaskId,
getParentTaskId,
normalizeDisplayId
getParentTaskId
} from './id-generator.js';
// Export git utilities

View File

@@ -58,6 +58,9 @@ export * from './utils/time.utils.js';
// Task validation schemas
export * from './modules/tasks/validation/index.js';
// Zod schemas for validation
export * from './common/schemas/index.js';
// ========== Domain-Specific Type Exports ==========
// Task types
@@ -168,7 +171,11 @@ export { BriefService } from './modules/briefs/services/brief-service.js';
// Workflow - Advanced
export { WorkflowOrchestrator } from './modules/workflow/orchestrators/workflow-orchestrator.js';
export { WorkflowStateManager } from './modules/workflow/managers/workflow-state-manager.js';
export { WorkflowService } from './modules/workflow/services/workflow.service.js';
export {
WorkflowService,
type TaskStatusUpdater,
type WorkflowServiceOptions
} from './modules/workflow/services/workflow.service.js';
export type { SubtaskInfo } from './modules/workflow/types.js';
// Git - Advanced

View File

@@ -51,10 +51,24 @@ describe('TemplateEngine', () => {
});
it('should handle missing variables by leaving placeholder', () => {
const engineWithPreserve = new TemplateEngine({
preservePlaceholders: true
});
const template = 'Hello {{name}} from {{location}}';
const result = engineWithPreserve.render(
'test',
{ name: 'Alice' },
template
);
expect(result).toBe('Hello Alice from {{location}}');
});
it('should replace missing variables with empty string by default', () => {
const template = 'Hello {{name}} from {{location}}';
const result = templateEngine.render('test', { name: 'Alice' }, template);
expect(result).toBe('Hello Alice from {{location}}');
expect(result).toBe('Hello Alice from ');
});
it('should handle empty variable values', () => {
@@ -215,11 +229,21 @@ describe('TemplateEngine', () => {
expect(result).toBe('Static text');
});
it('should handle empty variables object with preservePlaceholders', () => {
const engineWithPreserve = new TemplateEngine({
preservePlaceholders: true
});
const template = 'Hello {{name}}';
const result = engineWithPreserve.render('test', {}, template);
expect(result).toBe('Hello {{name}}');
});
it('should handle empty variables object', () => {
const template = 'Hello {{name}}';
const result = templateEngine.render('test', {}, template);
expect(result).toBe('Hello {{name}}');
expect(result).toBe('Hello ');
});
it('should handle special characters in values', () => {

View File

@@ -3,27 +3,31 @@
* Provides validation for task IDs used in MCP tools and CLI
*
* Supported formats:
* - Simple numeric: "1", "2", "15" (local file storage)
* - Numeric subtask: "1.2", "15.3" (local file storage, dot notation)
* - Numeric sub-subtask: "1.2.3", "15.3.1" (local file storage, dot notation)
* - Alphanumeric display IDs: "HAM-123", "PROJ-456" (remote API storage)
* Note: In remote mode, subtasks also use alphanumeric IDs (HAM-2, HAM-3),
* they don't use dot notation like local storage.
*
* FILE STORAGE (local):
* - Main tasks: "1", "2", "15"
* - Subtasks: "1.2", "15.3" (one level only)
*
* API STORAGE (Hamster):
* - Main tasks: "HAM-1", "ham-1", "HAM1", "ham1" (all normalized to "HAM-1")
* - No subtasks (API doesn't use dot notation)
*
* NOT supported:
* - Alphanumeric with dot notation: "HAM-123.2" (doesn't exist in any mode)
* - Deep nesting: "1.2.3" (file storage only has one subtask level)
* - API subtasks: "HAM-1.2" (doesn't exist)
*/
import { z } from 'zod';
import { normalizeDisplayId } from '../../../common/schemas/task-id.schema.js';
/**
* Pattern for validating a single task ID
* Supports:
* Permissive input - accepts with or without hyphen for API IDs
* - Numeric: "1", "15", "999"
* - Numeric subtasks: "1.2", "15.3.1"
* - Alphanumeric display IDs: "HAM-123", "PROJ-456" (main tasks only, no subtask notation)
* - Numeric subtasks: "1.2" (one level only)
* - API display IDs: "HAM-1", "ham-1", "HAM1", "ham1"
*/
export const TASK_ID_PATTERN = /^(\d+(\.\d+)*|[A-Za-z]+-\d+)$/;
export const TASK_ID_PATTERN = /^(\d+(\.\d+)?|[A-Za-z]{3}-?\d+)$/;
/**
* Validates a single task ID string
@@ -34,12 +38,12 @@ export const TASK_ID_PATTERN = /^(\d+(\.\d+)*|[A-Za-z]+-\d+)$/;
* @example
* ```typescript
* isValidTaskIdFormat("1"); // true
* isValidTaskIdFormat("15.2"); // true
* isValidTaskIdFormat("1.2.3"); // true
* isValidTaskIdFormat("HAM-123"); // true
* isValidTaskIdFormat("HAM-123.2"); // false (alphanumeric subtasks not supported)
* isValidTaskIdFormat("1.2"); // true
* isValidTaskIdFormat("HAM-1"); // true
* isValidTaskIdFormat("ham1"); // true (permissive input)
* isValidTaskIdFormat("1.2.3"); // false (too deep)
* isValidTaskIdFormat("HAM-1.2"); // false (no API subtasks)
* isValidTaskIdFormat("abc"); // false
* isValidTaskIdFormat(""); // false
* ```
*/
export function isValidTaskIdFormat(id: string): boolean {
@@ -49,6 +53,8 @@ export function isValidTaskIdFormat(id: string): boolean {
/**
* Zod schema for a single task ID
* Validates format: numeric, alphanumeric display ID, or numeric subtask
* Note: Use parseTaskIds() for normalization (e.g., "ham1" → "HAM-1")
* This schema is used in MCP tool definitions which can't have transforms.
*/
export const taskIdSchema = z
.string()
@@ -61,6 +67,7 @@ export const taskIdSchema = z
/**
* Zod schema for comma-separated task IDs
* Validates that each ID in the comma-separated list is valid
* Permissive input - accepts "ham1", "HAM1", "ham-1" etc.
*
* @example
* ```typescript
@@ -68,8 +75,9 @@ export const taskIdSchema = z
* taskIdsSchema.parse("1,2,3"); // valid
* taskIdsSchema.parse("1.2, 3.4"); // valid (spaces trimmed)
* taskIdsSchema.parse("HAM-123"); // valid
* taskIdsSchema.parse("ham1"); // valid (permissive input)
* taskIdsSchema.parse("abc"); // throws
* taskIdsSchema.parse("HAM-123.2"); // throws (alphanumeric subtasks not supported)
* taskIdsSchema.parse("HAM-1.2"); // throws (API subtasks not supported)
* ```
*/
export const taskIdsSchema = z
@@ -91,9 +99,10 @@ export const taskIdsSchema = z
/**
* Parse and validate comma-separated task IDs
* Returns normalized IDs (e.g., "ham1" → "HAM-1")
*
* @param input - Comma-separated task ID string
* @returns Array of validated task IDs
* @returns Array of validated and normalized task IDs
* @throws Error if any ID is invalid
*
* @example
@@ -101,8 +110,9 @@ export const taskIdsSchema = z
* parseTaskIds("1, 2, 3"); // ["1", "2", "3"]
* parseTaskIds("1.2,3.4"); // ["1.2", "3.4"]
* parseTaskIds("HAM-123"); // ["HAM-123"]
* parseTaskIds("ham1,ham2"); // ["HAM-1", "HAM-2"] (normalized)
* parseTaskIds("invalid"); // throws Error
* parseTaskIds("HAM-123.2"); // throws Error (alphanumeric subtasks not supported)
* parseTaskIds("HAM-1.2"); // throws Error (API subtasks not supported)
* ```
*/
export function parseTaskIds(input: string): string[] {
@@ -122,7 +132,8 @@ export function parseTaskIds(input: string): string[] {
);
}
return ids;
// Normalize all IDs (e.g., "ham1" → "HAM-1")
return ids.map(normalizeDisplayId);
}
/**

View File

@@ -89,10 +89,10 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(orchestrator.getCurrentPhase()).toBe('COMPLETE');
});
it('should reject invalid transitions', () => {
expect(() => {
orchestrator.transition({ type: 'FINALIZE_COMPLETE' });
}).toThrow('Invalid transition');
it('should reject invalid transitions', async () => {
await expect(
orchestrator.transition({ type: 'FINALIZE_COMPLETE' })
).rejects.toThrow('Invalid transition');
});
});
@@ -433,7 +433,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
});
describe('Phase Transition Guards and Validation', () => {
it('should enforce guard conditions on transitions', () => {
it('should enforce guard conditions on transitions', async () => {
// Create orchestrator with guard condition that should fail
const guardedContext: WorkflowContext = {
taskId: 'task-1',
@@ -450,14 +450,14 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
return context.subtasks.length > 0;
});
guardedOrchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await guardedOrchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
expect(() => {
await expect(
guardedOrchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
}).toThrow('Guard condition failed');
})
).rejects.toThrow('Guard condition failed');
});
it('should allow transition when guard condition passes', () => {
@@ -486,28 +486,31 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(guardedOrchestrator.getCurrentPhase()).toBe('SUBTASK_LOOP');
});
it('should validate test results before GREEN phase transition', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
it('should validate test results before GREEN phase transition', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
// Attempt to transition to GREEN without test results
expect(() => {
orchestrator.transition({ type: 'RED_PHASE_COMPLETE' });
}).toThrow('Test results required');
await expect(
orchestrator.transition({ type: 'RED_PHASE_COMPLETE' })
).rejects.toThrow('Test results required');
});
it('should validate RED phase test results have failures', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
// Note: When all tests pass in RED phase, the orchestrator auto-completes
// the subtask (feature already implemented) instead of throwing.
// This test is skipped as the behavior has changed.
it.skip('should validate RED phase test results have failures', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
// Provide passing test results (should fail RED phase validation)
expect(() => {
await expect(
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
@@ -517,8 +520,8 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'RED'
}
});
}).toThrow('RED phase must have at least one failing test');
})
).rejects.toThrow('RED phase must have at least one failing test');
});
it('should allow RED to GREEN transition with valid failing tests', () => {
@@ -542,14 +545,14 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(orchestrator.getCurrentTDDPhase()).toBe('GREEN');
});
it('should validate GREEN phase test results have no failures', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
it('should validate GREEN phase test results have no failures', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
orchestrator.transition({
await orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
total: 5,
@@ -561,7 +564,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
});
// Provide test results with failures (should fail GREEN phase validation)
expect(() => {
await expect(
orchestrator.transition({
type: 'GREEN_PHASE_COMPLETE',
testResults: {
@@ -571,8 +574,8 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'GREEN'
}
});
}).toThrow('GREEN phase must have zero failures');
})
).rejects.toThrow('GREEN phase must have zero failures');
});
it('should allow GREEN to COMMIT transition with all tests passing', () => {
@@ -631,7 +634,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(context.lastTestResults).toEqual(redResults);
});
it('should validate git repository state before BRANCH_SETUP', () => {
it('should validate git repository state before BRANCH_SETUP', async () => {
// Set up orchestrator with git validation enabled
const gitContext: WorkflowContext = {
taskId: 'task-1',
@@ -650,9 +653,9 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
return context.metadata.requireGit === true;
});
expect(() => {
gitOrchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
}).toThrow('Guard condition failed');
await expect(
gitOrchestrator.transition({ type: 'PREFLIGHT_COMPLETE' })
).rejects.toThrow('Guard condition failed');
});
});
@@ -1067,10 +1070,10 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(orchestrator.isAborted()).toBe(true);
});
it('should prevent transitions after abort', () => {
orchestrator.transition({ type: 'ABORT' });
it('should prevent transitions after abort', async () => {
await orchestrator.transition({ type: 'ABORT' });
expect(() => {
await expect(
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
@@ -1080,8 +1083,8 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'RED'
}
});
}).toThrow('Workflow has been aborted');
})
).rejects.toThrow('Workflow has been aborted');
});
it('should allow retry after recoverable error', () => {
@@ -1395,9 +1398,10 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
expect(orchestrator.hasTestResultValidator()).toBe(true);
});
it('should use TestResultValidator to validate RED phase', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
// Skip: Behavior changed - RED phase with 0 failures now auto-completes subtask instead of throwing
it.skip('should use TestResultValidator to validate RED phase', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
@@ -1405,7 +1409,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
orchestrator.setTestResultValidator(testValidator);
// Should reject passing tests in RED phase
expect(() => {
await expect(
orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
@@ -1415,20 +1419,20 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'RED'
}
});
}).toThrow('RED phase must have at least one failing test');
})
).rejects.toThrow('RED phase must have at least one failing test');
});
it('should use TestResultValidator to validate GREEN phase', () => {
orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
orchestrator.transition({
it('should use TestResultValidator to validate GREEN phase', async () => {
await orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
await orchestrator.transition({
type: 'BRANCH_CREATED',
branchName: 'feature/test'
});
orchestrator.setTestResultValidator(testValidator);
orchestrator.transition({
await orchestrator.transition({
type: 'RED_PHASE_COMPLETE',
testResults: {
total: 5,
@@ -1440,7 +1444,7 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
});
// Should reject failing tests in GREEN phase
expect(() => {
await expect(
orchestrator.transition({
type: 'GREEN_PHASE_COMPLETE',
testResults: {
@@ -1450,8 +1454,8 @@ describe('WorkflowOrchestrator - State Machine Structure', () => {
skipped: 0,
phase: 'GREEN'
}
});
}).toThrow('GREEN phase must have zero failures');
})
).rejects.toThrow('GREEN phase must have zero failures');
});
it('should support git adapter hooks', () => {

View File

@@ -3,6 +3,8 @@
* Provides a simplified API for MCP tools while delegating to WorkflowOrchestrator
*/
import { getLogger } from '../../../common/logger/index.js';
import type { TaskStatus } from '../../../common/types/index.js';
import { GitAdapter } from '../../git/adapters/git-adapter.js';
import { WorkflowStateManager } from '../managers/workflow-state-manager.js';
import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
@@ -30,7 +32,8 @@ export interface StartWorkflowOptions {
}>;
maxAttempts?: number;
force?: boolean;
tag?: string; // Optional tag for branch naming
tag?: string; // Optional tag for branch naming (local storage)
orgSlug?: string; // Optional org slug for branch naming (API storage, takes precedence over tag)
}
/**
@@ -70,6 +73,23 @@ export interface NextAction {
};
}
/**
* Interface for updating task statuses
* Allows WorkflowService to update task statuses without direct dependency on TasksDomain
*/
export interface TaskStatusUpdater {
updateStatus(taskId: string, status: TaskStatus, tag?: string): Promise<void>;
}
/**
* Options for WorkflowService constructor
*/
export interface WorkflowServiceOptions {
projectRoot: string;
taskStatusUpdater?: TaskStatusUpdater;
tag?: string;
}
/**
* WorkflowService - Facade for workflow operations
* Manages WorkflowOrchestrator lifecycle and state persistence
@@ -77,12 +97,52 @@ export interface NextAction {
export class WorkflowService {
private readonly projectRoot: string;
private readonly stateManager: WorkflowStateManager;
private readonly taskStatusUpdater?: TaskStatusUpdater;
private readonly tag?: string;
private readonly logger = getLogger('WorkflowService');
private orchestrator?: WorkflowOrchestrator;
private activityLogger?: WorkflowActivityLogger;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.stateManager = new WorkflowStateManager(projectRoot);
constructor(projectRootOrOptions: string | WorkflowServiceOptions) {
if (typeof projectRootOrOptions === 'string') {
// Legacy constructor: just projectRoot
this.projectRoot = projectRootOrOptions;
} else {
// New constructor with options
this.projectRoot = projectRootOrOptions.projectRoot;
this.taskStatusUpdater = projectRootOrOptions.taskStatusUpdater;
this.tag = projectRootOrOptions.tag;
}
this.stateManager = new WorkflowStateManager(this.projectRoot);
}
/**
* Update task status if updater is available
* Logs warning but doesn't throw if update fails
* @param taskId - Task ID to update
* @param status - New status
* @param tag - Optional tag override (uses constructor tag if not provided)
*/
private async updateTaskStatus(
taskId: string,
status: TaskStatus,
tag?: string
): Promise<void> {
if (!this.taskStatusUpdater) {
return;
}
try {
await this.taskStatusUpdater.updateStatus(
taskId,
status,
tag ?? this.tag
);
} catch (error: unknown) {
// Log but don't fail the workflow operation
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to update task ${taskId} status: ${message}`);
}
}
/**
@@ -102,7 +162,8 @@ export class WorkflowService {
subtasks,
maxAttempts = 3,
force,
tag
tag,
orgSlug
} = options;
// Check for existing workflow
@@ -143,6 +204,7 @@ export class WorkflowService {
taskId,
subtasks: workflowSubtasks,
currentSubtaskIndex: firstIncompleteIndex,
tag,
errors: [],
metadata: {
startedAt: new Date().toISOString(),
@@ -171,7 +233,7 @@ export class WorkflowService {
await this.orchestrator.transition({ type: 'PREFLIGHT_COMPLETE' });
// Create git branch with descriptive name
const branchName = this.generateBranchName(taskId, taskTitle, tag);
const branchName = this.generateBranchName(taskId, taskTitle, tag, orgSlug);
// Check if we're already on the target branch
const currentBranch = await gitAdapter.getCurrentBranch();
@@ -186,6 +248,9 @@ export class WorkflowService {
branchName
});
// Set main task status to in-progress
await this.updateTaskStatus(taskId, 'in-progress', tag);
return this.getStatus();
}
@@ -401,6 +466,10 @@ export class WorkflowService {
);
}
// Capture current subtask before transitioning
const currentSubtask = this.orchestrator.getCurrentSubtask();
const completedSubtaskId = currentSubtask?.id;
// Transition COMMIT phase complete
await this.orchestrator.transition({
type: 'COMMIT_COMPLETE'
@@ -415,12 +484,19 @@ export class WorkflowService {
await this.orchestrator.transition({ type: 'ALL_SUBTASKS_COMPLETE' });
}
// Mark completed subtask as done (use workflow's tag from context)
if (completedSubtaskId) {
const context = this.orchestrator.getContext();
await this.updateTaskStatus(completedSubtaskId, 'done', context.tag);
}
return this.getStatus();
}
/**
* Finalize and complete the workflow
* Validates working tree is clean before marking complete
* Cleans up workflow state file after successful completion
*/
async finalizeWorkflow(): Promise<WorkflowStatus> {
if (!this.orchestrator) {
@@ -447,10 +523,24 @@ export class WorkflowService {
);
}
// Capture task ID before transitioning
const context = this.orchestrator.getContext();
const taskId = context.taskId;
// Transition to COMPLETE
await this.orchestrator.transition({ type: 'FINALIZE_COMPLETE' });
return this.getStatus();
// Get final status before cleanup
const finalStatus = this.getStatus();
// Mark main task as done (use workflow's tag from context)
await this.updateTaskStatus(taskId, 'done', context.tag);
// Clean up workflow state file so new workflows can start without force
await this.stateManager.delete();
this.orchestrator = undefined;
return finalStatus;
}
/**
@@ -469,12 +559,14 @@ export class WorkflowService {
/**
* Generate a descriptive git branch name
* Format: tag-name/task-id-task-title or task-id-task-title
* Format: tm/<namespace>/task-<id>-<title> where namespace is orgSlug (API) or tag (local)
* All branches are prefixed with 'tm/' to avoid conflicts with existing branches
*/
private generateBranchName(
taskId: string,
taskTitle: string,
tag?: string
tag?: string,
orgSlug?: string
): string {
// Sanitize task title for branch name
const sanitizedTitle = taskTitle
@@ -486,9 +578,10 @@ export class WorkflowService {
// Format task ID for branch name
const formattedTaskId = taskId.replace(/\./g, '-');
// Add tag prefix if tag is provided
const tagPrefix = tag ? `${tag}/` : '';
// Priority: orgSlug (API storage) > tag (local storage) > none
const namespace = orgSlug || tag;
const prefix = namespace ? `tm/${namespace}` : 'tm';
return `${tagPrefix}task-${formattedTaskId}-${sanitizedTitle}`;
return `${prefix}/task-${formattedTaskId}-${sanitizedTitle}`;
}
}

View File

@@ -22,6 +22,7 @@ export interface WorkflowContext {
currentSubtaskIndex: number;
currentTDDPhase?: TDDPhase;
branchName?: string;
tag?: string;
errors: WorkflowError[];
metadata: Record<string, unknown>;
lastTestResults?: TestResult;

View File

@@ -4,7 +4,11 @@
*/
import type { ConfigManager } from '../config/managers/config-manager.js';
import { WorkflowService } from './services/workflow.service.js';
import type { TasksDomain } from '../tasks/tasks-domain.js';
import {
type TaskStatusUpdater,
WorkflowService
} from './services/workflow.service.js';
import type {
NextAction,
StartWorkflowOptions,
@@ -14,12 +18,57 @@ import type { TestResult, WorkflowContext } from './types.js';
/**
* Workflow Domain - Unified API for TDD workflow operations
* Automatically handles task status updates through dependency injection
*/
export class WorkflowDomain {
private workflowService: WorkflowService;
private workflowService: WorkflowService | null = null;
private readonly projectRoot: string;
private readonly configManager: ConfigManager;
private tasksDomain: TasksDomain | null = null;
constructor(configManager: ConfigManager) {
this.workflowService = new WorkflowService(configManager.getProjectRoot());
this.configManager = configManager;
this.projectRoot = configManager.getProjectRoot();
}
/**
* Set the TasksDomain for status updates
* Called by TmCore after TasksDomain is initialized
*/
setTasksDomain(tasksDomain: TasksDomain): void {
this.tasksDomain = tasksDomain;
}
/**
* Create or get WorkflowService instance with proper DI
*/
private getWorkflowService(): WorkflowService {
if (!this.workflowService) {
const currentTag = this.configManager.getActiveTag();
// Create task status updater if TasksDomain is available
const taskStatusUpdater: TaskStatusUpdater | undefined = this.tasksDomain
? {
updateStatus: async (taskId, status, tag) => {
await this.tasksDomain!.updateStatus(taskId, status, tag);
}
}
: undefined;
this.workflowService = new WorkflowService({
projectRoot: this.projectRoot,
taskStatusUpdater,
tag: currentTag
});
}
return this.workflowService;
}
/**
* Reset workflow service (for when workflow completes or aborts)
*/
private resetWorkflowService(): void {
this.workflowService = null;
}
// ========== Workflow Lifecycle ==========
@@ -28,63 +77,72 @@ export class WorkflowDomain {
* Start a new TDD workflow for a task
*/
async start(options: StartWorkflowOptions): Promise<WorkflowStatus> {
return this.workflowService.startWorkflow(options);
// Reset to get fresh service with current tag
this.resetWorkflowService();
return this.getWorkflowService().startWorkflow(options);
}
/**
* Resume an existing workflow
*/
async resume(): Promise<WorkflowStatus> {
return this.workflowService.resumeWorkflow();
// Reset to get fresh service with current tag
this.resetWorkflowService();
return this.getWorkflowService().resumeWorkflow();
}
/**
* Get current workflow status
*/
getStatus(): WorkflowStatus {
return this.workflowService.getStatus();
return this.getWorkflowService().getStatus();
}
/**
* Get workflow context
*/
getContext(): WorkflowContext {
return this.workflowService.getContext();
return this.getWorkflowService().getContext();
}
/**
* Get next action to perform in workflow
*/
getNextAction(): NextAction {
return this.workflowService.getNextAction();
return this.getWorkflowService().getNextAction();
}
/**
* Complete current phase with test results
*/
async completePhase(testResults: TestResult): Promise<WorkflowStatus> {
return this.workflowService.completePhase(testResults);
return this.getWorkflowService().completePhase(testResults);
}
/**
* Commit changes with auto-generated message
*/
async commit(): Promise<WorkflowStatus> {
return this.workflowService.commit();
return this.getWorkflowService().commit();
}
/**
* Finalize and complete the workflow
* Resets workflow service after completion
*/
async finalize(): Promise<WorkflowStatus> {
return this.workflowService.finalizeWorkflow();
const result = await this.getWorkflowService().finalizeWorkflow();
this.resetWorkflowService();
return result;
}
/**
* Abort the current workflow
* Resets workflow service after abort
*/
async abort(): Promise<void> {
return this.workflowService.abortWorkflow();
await this.getWorkflowService().abortWorkflow();
this.resetWorkflowService();
}
// ========== Workflow Information ==========
@@ -93,6 +151,6 @@ export class WorkflowDomain {
* Check if a workflow currently exists
*/
async hasWorkflow(): Promise<boolean> {
return this.workflowService.hasWorkflow();
return this.getWorkflowService().hasWorkflow();
}
}

View File

@@ -180,6 +180,10 @@ export class TmCore {
// Initialize domains that need async setup
await this._tasks.initialize();
// Wire up cross-domain dependencies
// WorkflowDomain needs TasksDomain for status updates
this._workflow.setTasksDomain(this._tasks);
// Log successful initialization
this._logger.info('TmCore initialized successfully');
} catch (error) {

View File

@@ -0,0 +1,866 @@
/**
* @fileoverview Integration tests for autopilot workflow state machine
*
* Tests the full workflow lifecycle through WorkflowService:
* - Start workflow and verify state file creation
* - TDD phase transitions (RED → GREEN → COMMIT)
* - State persistence and resume
* - Auto-complete subtask when RED phase has 0 failures
* - Workflow finalization and abort
*
* These tests create temporary project directories and verify the workflow
* state machine operates correctly with actual file I/O.
*
* NOTE: Workflow state is stored in ~/.taskmaster/{project-id}/sessions/
* based on the project path. Tests clean up their state files in afterEach.
*
* @integration
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the logger to reduce noise in tests
vi.mock('../../../src/common/logger/index.js', () => ({
getLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn().mockReturnThis()
})
}));
import { WorkflowStateManager } from '../../../src/modules/workflow/managers/workflow-state-manager.js';
import { WorkflowService } from '../../../src/modules/workflow/services/workflow.service.js';
import type { WorkflowState } from '../../../src/modules/workflow/types.js';
// Store original HOME to restore after tests
const originalHome = process.env.HOME;
describe('Autopilot Workflow Integration', () => {
let testProjectDir: string;
let testHomeDir: string;
let stateManager: WorkflowStateManager;
let workflowService: WorkflowService;
/**
* Read the workflow state file directly from disk
*/
const readWorkflowState = (): WorkflowState | null => {
const statePath = stateManager.getStatePath();
try {
const content = fs.readFileSync(statePath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
};
/**
* Check if workflow state file exists
*/
const workflowStateExists = (): boolean => {
return fs.existsSync(stateManager.getStatePath());
};
/**
* Get the expected state file path
*/
const getExpectedStatePath = (): string => {
return stateManager.getStatePath();
};
beforeEach(() => {
// Create temp directories for isolation
testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-workflow-home-'));
testProjectDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'tm-workflow-project-')
);
// Override HOME so os.homedir() returns our temp directory
// This prevents tests from polluting the real ~/.taskmaster/
process.env.HOME = testHomeDir;
// Create state manager AFTER setting HOME (uses os.homedir() internally)
stateManager = new WorkflowStateManager(testProjectDir);
// Initialize git in the project directory (required for workflow)
execSync('git init', { cwd: testProjectDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', {
cwd: testProjectDir,
stdio: 'pipe'
});
execSync('git config user.name "Test User"', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Disable GPG/SSH signing to avoid 1Password and other signing tool interference
execSync('git config commit.gpgsign false', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Create an initial commit (git needs at least one commit)
fs.writeFileSync(
path.join(testProjectDir, 'README.md'),
'# Test Project\n'
);
execSync('git add .', { cwd: testProjectDir, stdio: 'pipe' });
execSync('git commit -m "Initial commit"', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Create workflow service
workflowService = new WorkflowService(testProjectDir);
});
afterEach(() => {
// Restore original HOME
process.env.HOME = originalHome;
// Clean up temp directories
if (testProjectDir && fs.existsSync(testProjectDir)) {
fs.rmSync(testProjectDir, { recursive: true, force: true });
}
if (testHomeDir && fs.existsSync(testHomeDir)) {
fs.rmSync(testHomeDir, { recursive: true, force: true });
}
});
describe('Workflow State File Location', () => {
it('should store workflow state in isolated temp home directory', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Test Task',
subtasks: [
{ id: '1.1', title: 'Subtask 1', status: 'pending' },
{ id: '1.2', title: 'Subtask 2', status: 'pending' }
]
});
const statePath = getExpectedStatePath();
// State file should be in temp home directory (not real ~/.taskmaster/)
expect(statePath).toContain(testHomeDir);
expect(statePath).toContain('.taskmaster');
expect(statePath).toContain('sessions');
expect(statePath).toContain('workflow-state.json');
// State file should exist
expect(workflowStateExists()).toBe(true);
});
it('should create project-specific directory based on project path', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Test Task',
subtasks: [{ id: '1.1', title: 'Subtask 1', status: 'pending' }]
});
const statePath = getExpectedStatePath();
// Should contain sanitized project path as identifier
// The path should be like: ~/.taskmaster/-tmp-...-tm-workflow-project-.../sessions/workflow-state.json
expect(statePath).toMatch(
/\.taskmaster\/-[^/]+\/sessions\/workflow-state\.json$/
);
});
});
describe('Start Workflow', () => {
it('should initialize workflow and create state file', async () => {
const status = await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Implement Feature',
subtasks: [
{ id: '1.1', title: 'Write Tests', status: 'pending' },
{ id: '1.2', title: 'Implement Code', status: 'pending' }
]
});
expect(status.taskId).toBe('1');
expect(status.phase).toBe('SUBTASK_LOOP');
expect(status.tddPhase).toBe('RED');
expect(status.currentSubtask?.id).toBe('1.1');
expect(status.progress.total).toBe(2);
expect(status.progress.completed).toBe(0);
// Verify state file
const state = readWorkflowState();
expect(state).not.toBeNull();
expect(state?.phase).toBe('SUBTASK_LOOP');
expect(state?.context.taskId).toBe('1');
expect(state?.context.currentTDDPhase).toBe('RED');
});
it('should create git branch with proper naming', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Add User Authentication',
subtasks: [{ id: '1.1', title: 'Setup auth', status: 'pending' }]
});
const currentBranch = execSync('git branch --show-current', {
cwd: testProjectDir,
encoding: 'utf-8'
}).trim();
expect(currentBranch).toBe('tm/task-1-add-user-authentication');
});
it('should include tag in branch name when provided', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Feature X',
subtasks: [{ id: '1.1', title: 'Do thing', status: 'pending' }],
tag: 'sprint-1'
});
const currentBranch = execSync('git branch --show-current', {
cwd: testProjectDir,
encoding: 'utf-8'
}).trim();
expect(currentBranch).toBe('tm/sprint-1/task-1-feature-x');
});
it('should skip already completed subtasks', async () => {
const status = await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Resume Task',
subtasks: [
{ id: '1.1', title: 'Already Done', status: 'done' },
{ id: '1.2', title: 'Next Up', status: 'pending' }
]
});
// Should start at subtask 1.2 since 1.1 is done
expect(status.currentSubtask?.id).toBe('1.2');
expect(status.progress.completed).toBe(1);
expect(status.progress.current).toBe(2);
});
it('should reject when no subtasks to work on', async () => {
await expect(
workflowService.startWorkflow({
taskId: '1',
taskTitle: 'All Done',
subtasks: [
{ id: '1.1', title: 'Done 1', status: 'done' },
{ id: '1.2', title: 'Done 2', status: 'done' }
]
})
).rejects.toThrow('All subtasks for task 1 are already completed');
});
it('should reject when workflow already exists without force', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'First Workflow',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
// Create new service instance (simulating new command invocation)
const newService = new WorkflowService(testProjectDir);
await expect(
newService.startWorkflow({
taskId: '2',
taskTitle: 'Second Workflow',
subtasks: [{ id: '2.1', title: 'Task', status: 'pending' }]
})
).rejects.toThrow('Workflow already exists');
});
it('should allow force restart when workflow exists', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'First Workflow',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
// Create new service instance and force restart
const newService = new WorkflowService(testProjectDir);
const status = await newService.startWorkflow({
taskId: '2',
taskTitle: 'Second Workflow',
subtasks: [{ id: '2.1', title: 'New Task', status: 'pending' }],
force: true
});
expect(status.taskId).toBe('2');
expect(status.currentSubtask?.id).toBe('2.1');
});
});
describe('TDD Phase Transitions', () => {
beforeEach(async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'TDD Test',
subtasks: [
{ id: '1.1', title: 'First Subtask', status: 'pending' },
{ id: '1.2', title: 'Second Subtask', status: 'pending' }
]
});
});
it('should transition from RED to GREEN phase', async () => {
// Initial state should be RED
let status = workflowService.getStatus();
expect(status.tddPhase).toBe('RED');
// Complete RED phase with failing tests
status = await workflowService.completePhase({
total: 5,
passed: 2,
failed: 3,
skipped: 0,
phase: 'RED'
});
expect(status.tddPhase).toBe('GREEN');
// Verify state file updated
const state = readWorkflowState();
expect(state?.context.currentTDDPhase).toBe('GREEN');
});
it('should transition from GREEN to COMMIT phase', async () => {
// Complete RED phase
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
// Complete GREEN phase with all tests passing
const status = await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
expect(status.tddPhase).toBe('COMMIT');
});
it('should reject GREEN phase with failing tests', async () => {
// Complete RED phase
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
// Try to complete GREEN with failures
await expect(
workflowService.completePhase({
total: 5,
passed: 3,
failed: 2,
skipped: 0,
phase: 'GREEN'
})
).rejects.toThrow('GREEN phase must have zero failures');
});
it('should advance to next subtask after COMMIT', async () => {
// Complete full TDD cycle for first subtask
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
// Complete COMMIT phase
const status = await workflowService.commit();
// Should be on second subtask in RED phase
expect(status.currentSubtask?.id).toBe('1.2');
expect(status.tddPhase).toBe('RED');
expect(status.progress.completed).toBe(1);
expect(status.progress.current).toBe(2);
});
it('should store test results in state', async () => {
const testResults = {
total: 10,
passed: 3,
failed: 7,
skipped: 0,
phase: 'RED' as const
};
await workflowService.completePhase(testResults);
const state = readWorkflowState();
expect(state?.context.lastTestResults).toEqual(testResults);
});
});
describe('Auto-Complete Subtask (RED with 0 failures)', () => {
it('should auto-complete subtask when RED phase has no failures', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Auto-Complete Test',
subtasks: [
{ id: '1.1', title: 'Already Implemented', status: 'pending' },
{ id: '1.2', title: 'Needs Work', status: 'pending' }
]
});
// Complete RED phase with all tests passing (feature already implemented)
const status = await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'RED'
});
// Should have auto-advanced to next subtask
expect(status.currentSubtask?.id).toBe('1.2');
expect(status.tddPhase).toBe('RED');
expect(status.progress.completed).toBe(1);
});
});
describe('Resume Workflow', () => {
it('should resume workflow from saved state', async () => {
// Start workflow and progress through RED phase
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Resume Test',
subtasks: [
{ id: '1.1', title: 'First', status: 'pending' },
{ id: '1.2', title: 'Second', status: 'pending' }
]
});
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
// Verify we're in GREEN phase
expect(workflowService.getStatus().tddPhase).toBe('GREEN');
// Create new service instance (simulating new session)
const newService = new WorkflowService(testProjectDir);
// Resume workflow
const status = await newService.resumeWorkflow();
expect(status.taskId).toBe('1');
expect(status.phase).toBe('SUBTASK_LOOP');
expect(status.tddPhase).toBe('GREEN'); // Should resume in GREEN phase
expect(status.currentSubtask?.id).toBe('1.1');
});
it('should preserve progress when resuming', async () => {
// Start workflow and complete first subtask
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Progress Test',
subtasks: [
{ id: '1.1', title: 'First', status: 'pending' },
{ id: '1.2', title: 'Second', status: 'pending' }
]
});
// Complete first subtask
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
await workflowService.commit();
// Resume in new session
const newService = new WorkflowService(testProjectDir);
const status = await newService.resumeWorkflow();
expect(status.progress.completed).toBe(1);
expect(status.progress.current).toBe(2);
expect(status.currentSubtask?.id).toBe('1.2');
});
it('should error when no workflow exists to resume', async () => {
await expect(workflowService.resumeWorkflow()).rejects.toThrow(
'Workflow state file not found'
);
});
});
describe('Finalize Workflow', () => {
it('should finalize when all subtasks are complete', async () => {
// Start workflow with single subtask
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Finalize Test',
subtasks: [{ id: '1.1', title: 'Only Task', status: 'pending' }]
});
// Complete the subtask
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
// Make a commit in git to clean working tree
fs.writeFileSync(
path.join(testProjectDir, 'feature.ts'),
'export const x = 1;\n'
);
execSync('git add .', { cwd: testProjectDir, stdio: 'pipe' });
execSync('git commit -m "Implement feature"', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Complete commit phase
await workflowService.commit();
// Should now be in FINALIZE phase
let status = workflowService.getStatus();
expect(status.phase).toBe('FINALIZE');
// Finalize workflow
status = await workflowService.finalizeWorkflow();
expect(status.phase).toBe('COMPLETE');
expect(status.progress.percentage).toBe(100);
});
it('should reject finalize with uncommitted changes', async () => {
// Start and complete all subtasks
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Dirty Tree Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
await workflowService.completePhase({
total: 1,
passed: 0,
failed: 1,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 1,
passed: 1,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
await workflowService.commit();
// Create uncommitted changes
fs.writeFileSync(
path.join(testProjectDir, 'uncommitted.ts'),
'const x = 1;\n'
);
// Should fail to finalize
await expect(workflowService.finalizeWorkflow()).rejects.toThrow(
'working tree has uncommitted changes'
);
});
});
describe('Abort Workflow', () => {
it('should abort workflow and delete state file', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Abort Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
expect(workflowStateExists()).toBe(true);
await workflowService.abortWorkflow();
expect(workflowStateExists()).toBe(false);
});
it('should not error when aborting non-existent workflow', async () => {
// Should not throw
await expect(workflowService.abortWorkflow()).resolves.not.toThrow();
});
});
describe('Next Action Recommendations', () => {
it('should recommend correct action for RED phase', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Next Action Test',
subtasks: [{ id: '1.1', title: 'Write auth tests', status: 'pending' }]
});
const nextAction = workflowService.getNextAction();
expect(nextAction.action).toBe('generate_test');
expect(nextAction.tddPhase).toBe('RED');
expect(nextAction.subtask?.id).toBe('1.1');
expect(nextAction.nextSteps).toContain('Write failing tests');
});
it('should recommend correct action for GREEN phase', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Next Action Test',
subtasks: [{ id: '1.1', title: 'Implement feature', status: 'pending' }]
});
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
const nextAction = workflowService.getNextAction();
expect(nextAction.action).toBe('implement_code');
expect(nextAction.tddPhase).toBe('GREEN');
expect(nextAction.nextSteps).toContain('Implement code');
});
it('should recommend correct action for COMMIT phase', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Next Action Test',
subtasks: [{ id: '1.1', title: 'Commit changes', status: 'pending' }]
});
await workflowService.completePhase({
total: 5,
passed: 0,
failed: 5,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 5,
passed: 5,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
const nextAction = workflowService.getNextAction();
expect(nextAction.action).toBe('commit_changes');
expect(nextAction.tddPhase).toBe('COMMIT');
expect(nextAction.nextSteps).toContain('commit');
});
it('should recommend finalize for FINALIZE phase', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Finalize Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
// Make git commit to have clean tree
fs.writeFileSync(
path.join(testProjectDir, 'feature.ts'),
'export const x = 1;\n'
);
execSync('git add .', { cwd: testProjectDir, stdio: 'pipe' });
execSync('git commit -m "Feature"', {
cwd: testProjectDir,
stdio: 'pipe'
});
// Complete the workflow to FINALIZE phase
await workflowService.completePhase({
total: 1,
passed: 0,
failed: 1,
skipped: 0,
phase: 'RED'
});
await workflowService.completePhase({
total: 1,
passed: 1,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
await workflowService.commit();
const nextAction = workflowService.getNextAction();
expect(nextAction.action).toBe('finalize_workflow');
expect(nextAction.phase).toBe('FINALIZE');
});
});
describe('State File Evolution', () => {
it('should track full workflow state evolution', async () => {
// Start workflow
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Evolution Test',
subtasks: [
{ id: '1.1', title: 'First', status: 'pending' },
{ id: '1.2', title: 'Second', status: 'pending' }
]
});
// Verify initial state
let state = readWorkflowState();
expect(state?.phase).toBe('SUBTASK_LOOP');
expect(state?.context.currentSubtaskIndex).toBe(0);
expect(state?.context.currentTDDPhase).toBe('RED');
// Complete RED phase
await workflowService.completePhase({
total: 3,
passed: 0,
failed: 3,
skipped: 0,
phase: 'RED'
});
state = readWorkflowState();
expect(state?.context.currentTDDPhase).toBe('GREEN');
expect(state?.context.lastTestResults?.failed).toBe(3);
// Complete GREEN phase
await workflowService.completePhase({
total: 3,
passed: 3,
failed: 0,
skipped: 0,
phase: 'GREEN'
});
state = readWorkflowState();
expect(state?.context.currentTDDPhase).toBe('COMMIT');
// Complete commit and advance to next subtask
await workflowService.commit();
state = readWorkflowState();
expect(state?.context.currentSubtaskIndex).toBe(1);
expect(state?.context.currentTDDPhase).toBe('RED');
expect(state?.context.subtasks[0].status).toBe('completed');
});
});
describe('hasWorkflow', () => {
it('should return false when no workflow exists', async () => {
const exists = await workflowService.hasWorkflow();
expect(exists).toBe(false);
});
it('should return true when workflow exists', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Exists Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
const exists = await workflowService.hasWorkflow();
expect(exists).toBe(true);
});
it('should return false after workflow is aborted', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Abort Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }]
});
await workflowService.abortWorkflow();
const exists = await workflowService.hasWorkflow();
expect(exists).toBe(false);
});
});
describe('Team/API Storage', () => {
it('should use orgSlug for branch naming when provided (API storage mode)', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Team Feature',
subtasks: [{ id: 'HAM-2', title: 'Implement', status: 'pending' }],
orgSlug: 'acme-corp'
});
const currentBranch = execSync('git branch --show-current', {
cwd: testProjectDir,
encoding: 'utf-8'
}).trim();
expect(currentBranch).toBe('tm/acme-corp/task-1-team-feature');
});
it('should prioritize orgSlug over tag for branch naming', async () => {
await workflowService.startWorkflow({
taskId: '1',
taskTitle: 'Priority Test',
subtasks: [{ id: '1.1', title: 'Task', status: 'pending' }],
tag: 'local-tag',
orgSlug: 'team-slug'
});
const currentBranch = execSync('git branch --show-current', {
cwd: testProjectDir,
encoding: 'utf-8'
}).trim();
// orgSlug should take precedence over tag
expect(currentBranch).toBe('tm/team-slug/task-1-priority-test');
expect(currentBranch).not.toContain('local-tag');
});
});
});