diff --git a/.changeset/three-webs-drive.md b/.changeset/three-webs-drive.md new file mode 100644 index 00000000..7a55d91a --- /dev/null +++ b/.changeset/three-webs-drive.md @@ -0,0 +1,8 @@ +--- +"task-master-ai": patch +--- + +Add `tm/` prefix to autopilot branch names + +- Team mode branches now follow the `tm//task-` naming convention for better organization. +- Solves issue some users were having regarding not being able to start workflow on master Taskmaster tag diff --git a/.gitignore b/.gitignore index 16343a29..f4274777 100644 --- a/.gitignore +++ b/.gitignore @@ -104,7 +104,3 @@ apps/extension/vsix-build/ .scannerwork # OS specific - -# Task files -tasks.json -tasks/ diff --git a/apps/cli/src/commands/autopilot/abort.command.ts b/apps/cli/src/commands/autopilot/abort.command.ts index b52b25af..bf2e5415 100644 --- a/apps/cli/src/commands/autopilot/abort.command.ts +++ b/apps/cli/src/commands/autopilot/abort.command.ts @@ -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.' diff --git a/apps/cli/src/commands/autopilot/commit.command.ts b/apps/cli/src/commands/autopilot/commit.command.ts index eeb5521a..a081a624 100644 --- a/apps/cli/src/commands/autopilot/commit.command.ts +++ b/apps/cli/src/commands/autopilot/commit.command.ts @@ -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 ' }); 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'; diff --git a/apps/cli/src/commands/autopilot/complete.command.ts b/apps/cli/src/commands/autopilot/complete.command.ts index ed971d85..3e3f5fc9 100644 --- a/apps/cli/src/commands/autopilot/complete.command.ts +++ b/apps/cli/src/commands/autopilot/complete.command.ts @@ -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 ' }); 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' diff --git a/apps/cli/src/commands/autopilot/finalize.command.ts b/apps/cli/src/commands/autopilot/finalize.command.ts new file mode 100644 index 00000000..68a52add --- /dev/null +++ b/apps/cli/src/commands/autopilot/finalize.command.ts @@ -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 { + // 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 ' + }); + 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); + } + } +} diff --git a/apps/cli/src/commands/autopilot/index.ts b/apps/cli/src/commands/autopilot/index.ts index c8fcda58..2ce935b0 100644 --- a/apps/cli/src/commands/autopilot/index.ts +++ b/apps/cli/src/commands/autopilot/index.ts @@ -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()); } diff --git a/apps/cli/src/commands/autopilot/next.command.ts b/apps/cli/src/commands/autopilot/next.command.ts index a081c3f3..01295d65 100644 --- a/apps/cli/src/commands/autopilot/next.command.ts +++ b/apps/cli/src/commands/autopilot/next.command.ts @@ -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 ' }); 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 = {}; + // 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) { diff --git a/apps/cli/src/commands/autopilot/resume.command.ts b/apps/cli/src/commands/autopilot/resume.command.ts index 66e68409..9e29963b 100644 --- a/apps/cli/src/commands/autopilot/resume.command.ts +++ b/apps/cli/src/commands/autopilot/resume.command.ts @@ -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 ' }); 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 }); diff --git a/apps/cli/src/commands/autopilot/shared.ts b/apps/cli/src/commands/autopilot/shared.ts index cfc265a3..c9f13f85 100644 --- a/apps/cli/src/commands/autopilot/shared.ts +++ b/apps/cli/src/commands/autopilot/shared.ts @@ -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 { - 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 { - 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 { - const stateManager = new WorkflowStateManager(projectRoot); - await stateManager.delete(); -} - -/** - * Check if workflow state exists using WorkflowStateManager - */ -export async function hasWorkflowState(projectRoot: string): Promise { - 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 - })); -} diff --git a/apps/cli/src/commands/autopilot/start.command.ts b/apps/cli/src/commands/autopilot/start.command.ts index 95078ffb..20797a30 100644 --- a/apps/cli/src/commands/autopilot/start.command.ts +++ b/apps/cli/src/commands/autopilot/start.command.ts @@ -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 { + private async execute( + rawTaskId: string, + options: StartOptions + ): Promise { // 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) { diff --git a/apps/cli/src/commands/autopilot/status.command.ts b/apps/cli/src/commands/autopilot/status.command.ts index bea6ce21..428ebd29 100644 --- a/apps/cli/src/commands/autopilot/status.command.ts +++ b/apps/cli/src/commands/autopilot/status.command.ts @@ -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 ' - }); - 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 '); + } + 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) { diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts index 553debbc..e196cca4 100644 --- a/apps/cli/src/commands/set-status.command.ts +++ b/apps/cli/src/commands/set-status.command.ts @@ -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<{ diff --git a/apps/cli/src/commands/show.command.ts b/apps/cli/src/commands/show.command.ts index 824a7435..2e7db9c7 100644 --- a/apps/cli/src/commands/show.command.ts +++ b/apps/cli/src/commands/show.command.ts @@ -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 = diff --git a/apps/cli/tests/integration/commands/autopilot/workflow.test.ts b/apps/cli/tests/integration/commands/autopilot/workflow.test.ts deleted file mode 100644 index 89ca9de8..00000000 --- a/apps/cli/tests/integration/commands/autopilot/workflow.test.ts +++ /dev/null @@ -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(); - - 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(); - }); - }); -}); diff --git a/apps/cli/tests/unit/commands/autopilot/shared.test.ts b/apps/cli/tests/unit/commands/autopilot/shared.test.ts index becaa8ec..eefa46aa 100644 --- a/apps/cli/tests/unit/commands/autopilot/shared.test.ts +++ b/apps/cli/tests/unit/commands/autopilot/shared.test.ts @@ -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'); diff --git a/apps/mcp/src/tools/autopilot/abort.tool.ts b/apps/mcp/src/tools/autopilot/abort.tool.ts index 22d9c66f..29753d0e 100644 --- a/apps/mcp/src/tools/autopilot/abort.tool.ts +++ b/apps/mcp/src/tools/autopilot/abort.tool.ts @@ -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'); diff --git a/apps/mcp/src/tools/autopilot/commit.tool.ts b/apps/mcp/src/tools/autopilot/commit.tool.ts index 938159b0..a320f2ed 100644 --- a/apps/mcp/src/tools/autopilot/commit.tool.ts +++ b/apps/mcp/src/tools/autopilot/commit.tool.ts @@ -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: { diff --git a/apps/mcp/src/tools/autopilot/complete.tool.ts b/apps/mcp/src/tools/autopilot/complete.tool.ts index 85236359..d0f9977e 100644 --- a/apps/mcp/src/tools/autopilot/complete.tool.ts +++ b/apps/mcp/src/tools/autopilot/complete.tool.ts @@ -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}` diff --git a/apps/mcp/src/tools/autopilot/finalize.tool.ts b/apps/mcp/src/tools/autopilot/finalize.tool.ts index e0a5a969..e7060158 100644 --- a/apps/mcp/src/tools/autopilot/finalize.tool.ts +++ b/apps/mcp/src/tools/autopilot/finalize.tool.ts @@ -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: { diff --git a/apps/mcp/src/tools/autopilot/next.tool.ts b/apps/mcp/src/tools/autopilot/next.tool.ts index e48328fb..346dd9fc 100644 --- a/apps/mcp/src/tools/autopilot/next.tool.ts +++ b/apps/mcp/src/tools/autopilot/next.tool.ts @@ -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}`); diff --git a/apps/mcp/src/tools/autopilot/resume.tool.ts b/apps/mcp/src/tools/autopilot/resume.tool.ts index 220ac9e5..6f866834 100644 --- a/apps/mcp/src/tools/autopilot/resume.tool.ts +++ b/apps/mcp/src/tools/autopilot/resume.tool.ts @@ -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}`); diff --git a/apps/mcp/src/tools/autopilot/start.tool.ts b/apps/mcp/src/tools/autopilot/start.tool.ts index ebe83a91..0ae17e84 100644 --- a/apps/mcp/src/tools/autopilot/start.tool.ts +++ b/apps/mcp/src/tools/autopilot/start.tool.ts @@ -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; -/** - * 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: { diff --git a/apps/mcp/src/tools/autopilot/status.tool.ts b/apps/mcp/src/tools/autopilot/status.tool.ts index 202e08d4..be7fbf1c 100644 --- a/apps/mcp/src/tools/autopilot/status.tool.ts +++ b/apps/mcp/src/tools/autopilot/status.tool.ts @@ -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}`); diff --git a/package-lock.json b/package-lock.json index 0c95b9de..8976330d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 85f2a1d4..bd4cdd90 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/tm-core/src/common/schemas/index.ts b/packages/tm-core/src/common/schemas/index.ts new file mode 100644 index 00000000..dbd2d641 --- /dev/null +++ b/packages/tm-core/src/common/schemas/index.ts @@ -0,0 +1,13 @@ +/** + * @fileoverview Zod schemas for validation + */ + +export { + TaskIdSchema, + MainTaskIdSchema, + TaskIdSchemaForMcp, + MainTaskIdSchemaForMcp, + normalizeDisplayId, + type TaskId, + type MainTaskId +} from './task-id.schema.js'; diff --git a/packages/tm-core/src/common/schemas/task-id.schema.spec.ts b/packages/tm-core/src/common/schemas/task-id.schema.spec.ts new file mode 100644 index 00000000..7a348257 --- /dev/null +++ b/packages/tm-core/src/common/schemas/task-id.schema.spec.ts @@ -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'); + } + }); + }); +}); diff --git a/packages/tm-core/src/common/schemas/task-id.schema.ts b/packages/tm-core/src/common/schemas/task-id.schema.ts new file mode 100644 index 00000000..9f6db819 --- /dev/null +++ b/packages/tm-core/src/common/schemas/task-id.schema.ts @@ -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; + +/** + * Type for a validated and normalized main task ID + */ +export type MainTaskId = z.output; diff --git a/packages/tm-core/src/common/utils/index.ts b/packages/tm-core/src/common/utils/index.ts index 663d38b5..202aa32a 100644 --- a/packages/tm-core/src/common/utils/index.ts +++ b/packages/tm-core/src/common/utils/index.ts @@ -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 diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 46b4083f..6be86067 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -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 diff --git a/packages/tm-core/src/modules/git/services/template-engine.test.ts b/packages/tm-core/src/modules/git/services/template-engine.spec.ts similarity index 92% rename from packages/tm-core/src/modules/git/services/template-engine.test.ts rename to packages/tm-core/src/modules/git/services/template-engine.spec.ts index 88d1876c..14b5ada2 100644 --- a/packages/tm-core/src/modules/git/services/template-engine.test.ts +++ b/packages/tm-core/src/modules/git/services/template-engine.spec.ts @@ -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', () => { diff --git a/packages/tm-core/src/modules/tasks/validation/task-id.ts b/packages/tm-core/src/modules/tasks/validation/task-id.ts index d78d0326..af29ddff 100644 --- a/packages/tm-core/src/modules/tasks/validation/task-id.ts +++ b/packages/tm-core/src/modules/tasks/validation/task-id.ts @@ -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); } /** diff --git a/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.test.ts b/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.spec.ts similarity index 94% rename from packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.test.ts rename to packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.spec.ts index 426d03d0..7c633396 100644 --- a/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.test.ts +++ b/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.spec.ts @@ -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', () => { diff --git a/packages/tm-core/src/modules/workflow/services/workflow.service.ts b/packages/tm-core/src/modules/workflow/services/workflow.service.ts index 46039314..70f1e819 100644 --- a/packages/tm-core/src/modules/workflow/services/workflow.service.ts +++ b/packages/tm-core/src/modules/workflow/services/workflow.service.ts @@ -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; +} + +/** + * 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 { + 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 { 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//task-- 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}`; } } diff --git a/packages/tm-core/src/modules/workflow/types.ts b/packages/tm-core/src/modules/workflow/types.ts index 8bb35d9c..3d7798b6 100644 --- a/packages/tm-core/src/modules/workflow/types.ts +++ b/packages/tm-core/src/modules/workflow/types.ts @@ -22,6 +22,7 @@ export interface WorkflowContext { currentSubtaskIndex: number; currentTDDPhase?: TDDPhase; branchName?: string; + tag?: string; errors: WorkflowError[]; metadata: Record<string, unknown>; lastTestResults?: TestResult; diff --git a/packages/tm-core/src/modules/workflow/workflow-domain.ts b/packages/tm-core/src/modules/workflow/workflow-domain.ts index ab7d27d1..5a4bd895 100644 --- a/packages/tm-core/src/modules/workflow/workflow-domain.ts +++ b/packages/tm-core/src/modules/workflow/workflow-domain.ts @@ -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(); } } diff --git a/packages/tm-core/src/tm-core.ts b/packages/tm-core/src/tm-core.ts index 4511c1c5..510da421 100644 --- a/packages/tm-core/src/tm-core.ts +++ b/packages/tm-core/src/tm-core.ts @@ -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) { diff --git a/packages/tm-core/tests/integration/workflow/autopilot-workflow.test.ts b/packages/tm-core/tests/integration/workflow/autopilot-workflow.test.ts new file mode 100644 index 00000000..ff2d1019 --- /dev/null +++ b/packages/tm-core/tests/integration/workflow/autopilot-workflow.test.ts @@ -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'); + }); + }); +});