From 79ef8c851079b57fff39cb56cb6890cd85987b34 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 22 Dec 2025 23:25:22 +0100 Subject: [PATCH] refactor: extract auto-mode-service into modular services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce auto-mode-service.ts from 1308 to 516 lines (60% reduction) by extracting reusable functionality into shared packages and services: - Add feature prompt builders to @automaker/prompts (buildFeaturePrompt, buildFollowUpPrompt, buildContinuationPrompt, extractTitleFromDescription) - Add planning prompts and task parsing to @automaker/prompts - Add stream processor utilities to @automaker/utils (sleep, processStream) - Add git commit utilities to @automaker/git-utils (commitAll, hasUncommittedChanges) - Create ProjectAnalyzer service for project analysis - Create FeatureVerificationService for verify/commit operations - Extend FeatureLoader with updateStatus, updatePlanSpec, getPending methods - Expand FeatureStatus type to include all used statuses - Add PlanSpec and ParsedTask types to @automaker/types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/lib/stream-processor.ts | 190 ++ apps/server/src/services/auto-mode-service.ts | 2509 +++-------------- .../auto-mode/feature-verification.ts | 163 ++ apps/server/src/services/auto-mode/index.ts | 28 + .../src/services/auto-mode/output-writer.ts | 154 + .../auto-mode/plan-approval-service.ts | 236 ++ .../services/auto-mode/project-analyzer.ts | 111 + .../src/services/auto-mode/task-executor.ts | 268 ++ apps/server/src/services/auto-mode/types.ts | 121 + .../services/auto-mode/worktree-manager.ts | 157 ++ apps/server/src/services/feature-loader.ts | 114 +- .../auto-mode-service-planning.test.ts | 110 +- .../services/auto-mode-task-parsing.test.ts | 89 +- .../shared/planning-mode-selector.tsx | 6 +- apps/ui/src/store/app-store.ts | 38 +- libs/git-utils/src/commit.ts | 112 + libs/git-utils/src/index.ts | 9 + libs/prompts/src/feature-prompt.ts | 138 + libs/prompts/src/index.ts | 28 +- libs/prompts/src/planning.ts | 411 +++ libs/types/src/feature.ts | 24 +- libs/types/src/index.ts | 12 + libs/types/src/planning.ts | 141 + libs/utils/src/index.ts | 12 + libs/utils/src/stream-processor.ts | 173 ++ 25 files changed, 3048 insertions(+), 2306 deletions(-) create mode 100644 apps/server/src/lib/stream-processor.ts create mode 100644 apps/server/src/services/auto-mode/feature-verification.ts create mode 100644 apps/server/src/services/auto-mode/index.ts create mode 100644 apps/server/src/services/auto-mode/output-writer.ts create mode 100644 apps/server/src/services/auto-mode/plan-approval-service.ts create mode 100644 apps/server/src/services/auto-mode/project-analyzer.ts create mode 100644 apps/server/src/services/auto-mode/task-executor.ts create mode 100644 apps/server/src/services/auto-mode/types.ts create mode 100644 apps/server/src/services/auto-mode/worktree-manager.ts create mode 100644 libs/git-utils/src/commit.ts create mode 100644 libs/prompts/src/feature-prompt.ts create mode 100644 libs/prompts/src/planning.ts create mode 100644 libs/types/src/planning.ts create mode 100644 libs/utils/src/stream-processor.ts diff --git a/apps/server/src/lib/stream-processor.ts b/apps/server/src/lib/stream-processor.ts new file mode 100644 index 00000000..18d66f49 --- /dev/null +++ b/apps/server/src/lib/stream-processor.ts @@ -0,0 +1,190 @@ +/** + * Stream Processor - Unified stream handling for provider messages + * + * Eliminates duplication of the stream processing pattern that was + * repeated 4x in auto-mode-service.ts (main execution, revision, + * task execution, continuation). + */ + +import type { ProviderMessage, ContentBlock } from '@automaker/types'; + +/** + * Callbacks for handling different stream events + */ +export interface StreamHandlers { + /** Called for each text block in the stream */ + onText?: (text: string) => void | Promise; + /** Called for each tool use in the stream */ + onToolUse?: (name: string, input: unknown) => void | Promise; + /** Called when an error occurs in the stream */ + onError?: (error: string) => void | Promise; + /** Called when the stream completes successfully */ + onComplete?: (result: string) => void | Promise; + /** Called for thinking blocks (if present) */ + onThinking?: (thinking: string) => void | Promise; +} + +/** + * Result from processing a stream + */ +export interface StreamResult { + /** All accumulated text from the stream */ + text: string; + /** Whether the stream completed successfully */ + success: boolean; + /** Error message if stream failed */ + error?: string; + /** Final result message if stream completed */ + result?: string; +} + +/** + * Process a provider message stream with unified handling + * + * This eliminates the repeated pattern of: + * ``` + * for await (const msg of stream) { + * if (msg.type === 'assistant' && msg.message?.content) { + * for (const block of msg.message.content) { + * if (block.type === 'text') { ... } + * else if (block.type === 'tool_use') { ... } + * } + * } else if (msg.type === 'error') { ... } + * else if (msg.type === 'result') { ... } + * } + * ``` + * + * @param stream - The async generator from provider.executeQuery() + * @param handlers - Callbacks for different event types + * @returns Accumulated result with text and status + */ +export async function processStream( + stream: AsyncGenerator, + handlers: StreamHandlers +): Promise { + let accumulatedText = ''; + let success = true; + let errorMessage: string | undefined; + let resultMessage: string | undefined; + + try { + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + await processContentBlock(block, handlers, (text) => { + accumulatedText += text; + }); + } + } else if (msg.type === 'error') { + success = false; + errorMessage = msg.error || 'Unknown error'; + if (handlers.onError) { + await handlers.onError(errorMessage); + } + throw new Error(errorMessage); + } else if (msg.type === 'result' && msg.subtype === 'success') { + resultMessage = msg.result || ''; + if (handlers.onComplete) { + await handlers.onComplete(resultMessage); + } + } + } + } catch (error) { + if (!errorMessage) { + success = false; + errorMessage = error instanceof Error ? error.message : String(error); + } + throw error; + } + + return { + text: accumulatedText, + success, + error: errorMessage, + result: resultMessage, + }; +} + +/** + * Process a single content block + */ +async function processContentBlock( + block: ContentBlock, + handlers: StreamHandlers, + appendText: (text: string) => void +): Promise { + switch (block.type) { + case 'text': + if (block.text) { + appendText(block.text); + if (handlers.onText) { + await handlers.onText(block.text); + } + } + break; + + case 'tool_use': + if (block.name && handlers.onToolUse) { + await handlers.onToolUse(block.name, block.input); + } + break; + + case 'thinking': + if (block.thinking && handlers.onThinking) { + await handlers.onThinking(block.thinking); + } + break; + + // tool_result blocks are handled internally by the SDK + case 'tool_result': + break; + } +} + +/** + * Create a simple stream processor that just collects text + * + * Useful for cases where you just need the final text output + * without any side effects during streaming. + */ +export async function collectStreamText(stream: AsyncGenerator): Promise { + const result = await processStream(stream, {}); + return result.text; +} + +/** + * Process stream with progress callback + * + * Simplified interface for the common case of just wanting + * text updates during streaming. + */ +export async function processStreamWithProgress( + stream: AsyncGenerator, + onProgress: (text: string) => void +): Promise { + return processStream(stream, { + onText: onProgress, + }); +} + +/** + * Check if a stream result contains a specific marker + * + * Useful for detecting spec generation markers like [SPEC_GENERATED] + */ +export function hasMarker(result: StreamResult, marker: string): boolean { + return result.text.includes(marker); +} + +/** + * Extract content before a marker + * + * Useful for extracting spec content before [SPEC_GENERATED] marker + */ +export function extractBeforeMarker(text: string, marker: string): string | null { + const index = text.indexOf(marker); + if (index === -1) { + return null; + } + return text.substring(0, index).trim(); +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 1da65e35..66dd5fdb 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1,380 +1,103 @@ /** * Auto Mode Service - Autonomous feature implementation using Claude Agent SDK * - * Manages: - * - Worktree creation for isolated development - * - Feature execution with Claude - * - Concurrent execution with max concurrency limits - * - Progress streaming via events - * - Verification and merge workflows + * This is the main orchestrator for auto-mode feature execution. + * It coordinates: + * - Feature execution lifecycle + * - Worktree management (via WorktreeManager) + * - Plan approval workflow (via PlanApprovalService) + * - Multi-agent task execution (via TaskExecutor) + * - Output persistence (via OutputWriter) + * - Verification & commits (via FeatureVerificationService) + * - Project analysis (via ProjectAnalyzer) */ import { ProviderFactory } from '../providers/provider-factory.js'; -import type { ExecuteOptions, Feature } from '@automaker/types'; +import type { ExecuteOptions, PlanningMode } from '@automaker/types'; import { buildPromptWithImages, - isAbortError, classifyError, loadContextFiles, + createLogger, + sleep, } from '@automaker/utils'; import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; -import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform'; -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPlanningPromptPrefix, + parseTasksFromSpec, + buildFeaturePrompt, + buildFollowUpPrompt, + buildContinuationPrompt, +} from '@automaker/prompts'; import path from 'path'; import * as secureFs from '../lib/secure-fs.js'; import type { EventEmitter } from '../lib/events.js'; import { createAutoModeOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; +import { processStream, extractBeforeMarker } from '../lib/stream-processor.js'; import { FeatureLoader } from './feature-loader.js'; -const execAsync = promisify(exec); - -// Planning mode types for spec-driven development -type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; - -interface ParsedTask { - id: string; // e.g., "T001" - description: string; // e.g., "Create user model" - filePath?: string; // e.g., "src/models/user.ts" - phase?: string; // e.g., "Phase 1: Foundation" (for full mode) - status: 'pending' | 'in_progress' | 'completed' | 'failed'; -} - -interface PlanSpec { - status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; - content?: string; - version: number; - generatedAt?: string; - approvedAt?: string; - reviewedByUser: boolean; - tasksCompleted?: number; - tasksTotal?: number; - currentTaskId?: string; - tasks?: ParsedTask[]; -} - -const PLANNING_PROMPTS = { - lite: `## Planning Phase (Lite Mode) - -IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan. - -Create a brief planning outline: - -1. **Goal**: What are we accomplishing? (1 sentence) -2. **Approach**: How will we do it? (2-3 sentences) -3. **Files to Touch**: List files and what changes -4. **Tasks**: Numbered task list (3-7 items) -5. **Risks**: Any gotchas to watch for - -After generating the outline, output: -"[PLAN_GENERATED] Planning outline complete." - -Then proceed with implementation.`, - - lite_with_approval: `## Planning Phase (Lite Mode) - -IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan. - -Create a brief planning outline: - -1. **Goal**: What are we accomplishing? (1 sentence) -2. **Approach**: How will we do it? (2-3 sentences) -3. **Files to Touch**: List files and what changes -4. **Tasks**: Numbered task list (3-7 items) -5. **Risks**: Any gotchas to watch for - -After generating the outline, output: -"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions." - -DO NOT proceed with implementation until you receive explicit approval.`, - - spec: `## Specification Phase (Spec Mode) - -IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification. - -Generate a specification with an actionable task breakdown. WAIT for approval before implementing. - -### Specification Format - -1. **Problem**: What problem are we solving? (user perspective) - -2. **Solution**: Brief approach (1-2 sentences) - -3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format - - GIVEN [context], WHEN [action], THEN [outcome] - -4. **Files to Modify**: - | File | Purpose | Action | - |------|---------|--------| - | path/to/file | description | create/modify/delete | - -5. **Implementation Tasks**: - Use this EXACT format for each task (the system will parse these): - \`\`\`tasks - - [ ] T001: [Description] | File: [path/to/file] - - [ ] T002: [Description] | File: [path/to/file] - - [ ] T003: [Description] | File: [path/to/file] - \`\`\` - - Task ID rules: - - Sequential: T001, T002, T003, etc. - - Description: Clear action (e.g., "Create user model", "Add API endpoint") - - File: Primary file affected (helps with context) - - Order by dependencies (foundational tasks first) - -6. **Verification**: How to confirm feature works - -After generating the spec, output on its own line: -"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions." - -DO NOT proceed with implementation until you receive explicit approval. - -When approved, execute tasks SEQUENTIALLY in order. For each task: -1. BEFORE starting, output: "[TASK_START] T###: Description" -2. Implement the task -3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary" - -This allows real-time progress tracking during implementation.`, - - full: `## Full Specification Phase (Full SDD Mode) - -IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification. - -Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing. - -### Specification Format - -1. **Problem Statement**: 2-3 sentences from user perspective - -2. **User Story**: As a [user], I want [goal], so that [benefit] - -3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN - - **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome] - - **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling] - - **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response] - -4. **Technical Context**: - | Aspect | Value | - |--------|-------| - | Affected Files | list of files | - | Dependencies | external libs if any | - | Constraints | technical limitations | - | Patterns to Follow | existing patterns in codebase | - -5. **Non-Goals**: What this feature explicitly does NOT include - -6. **Implementation Tasks**: - Use this EXACT format for each task (the system will parse these): - \`\`\`tasks - ## Phase 1: Foundation - - [ ] T001: [Description] | File: [path/to/file] - - [ ] T002: [Description] | File: [path/to/file] - - ## Phase 2: Core Implementation - - [ ] T003: [Description] | File: [path/to/file] - - [ ] T004: [Description] | File: [path/to/file] - - ## Phase 3: Integration & Testing - - [ ] T005: [Description] | File: [path/to/file] - - [ ] T006: [Description] | File: [path/to/file] - \`\`\` - - Task ID rules: - - Sequential across all phases: T001, T002, T003, etc. - - Description: Clear action verb + target - - File: Primary file affected - - Order by dependencies within each phase - - Phase structure helps organize complex work - -7. **Success Metrics**: How we know it's done (measurable criteria) - -8. **Risks & Mitigations**: - | Risk | Mitigation | - |------|------------| - | description | approach | - -After generating the spec, output on its own line: -"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions." - -DO NOT proceed with implementation until you receive explicit approval. - -When approved, execute tasks SEQUENTIALLY by phase. For each task: -1. BEFORE starting, output: "[TASK_START] T###: Description" -2. Implement the task -3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary" - -After completing all tasks in a phase, output: -"[PHASE_COMPLETE] Phase N complete" - -This allows real-time progress tracking during implementation.`, -}; - -/** - * Parse tasks from generated spec content - * Looks for the ```tasks code block and extracts task lines - * Format: - [ ] T###: Description | File: path/to/file - */ -function parseTasksFromSpec(specContent: string): ParsedTask[] { - const tasks: ParsedTask[] = []; - - // Extract content within ```tasks ... ``` block - const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); - if (!tasksBlockMatch) { - // Try fallback: look for task lines anywhere in content - const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); - if (!taskLines) { - return tasks; - } - // Parse fallback task lines - let currentPhase: string | undefined; - for (const line of taskLines) { - const parsed = parseTaskLine(line, currentPhase); - if (parsed) { - tasks.push(parsed); - } - } - return tasks; - } - - const tasksContent = tasksBlockMatch[1]; - const lines = tasksContent.split('\n'); - - let currentPhase: string | undefined; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Check for phase header (e.g., "## Phase 1: Foundation") - const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); - if (phaseMatch) { - currentPhase = phaseMatch[1].trim(); - continue; - } - - // Check for task line - if (trimmedLine.startsWith('- [ ]')) { - const parsed = parseTaskLine(trimmedLine, currentPhase); - if (parsed) { - tasks.push(parsed); - } - } - } - - return tasks; -} - -/** - * Parse a single task line - * Format: - [ ] T###: Description | File: path/to/file - */ -function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { - // Match pattern: - [ ] T###: Description | File: path - const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); - if (!taskMatch) { - // Try simpler pattern without file - const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); - if (simpleMatch) { - return { - id: simpleMatch[1], - description: simpleMatch[2].trim(), - phase: currentPhase, - status: 'pending', - }; - } - return null; - } - - return { - id: taskMatch[1], - description: taskMatch[2].trim(), - filePath: taskMatch[3]?.trim(), - phase: currentPhase, - status: 'pending', - }; -} - -// Feature type is imported from feature-loader.js -// Extended type with planning fields for local use -interface FeatureWithPlanning extends Feature { - planningMode?: PlanningMode; - planSpec?: PlanSpec; - requirePlanApproval?: boolean; -} - -interface RunningFeature { - featureId: string; - projectPath: string; - worktreePath: string | null; - branchName: string | null; - abortController: AbortController; - isAutoMode: boolean; - startTime: number; -} - -interface AutoLoopState { - projectPath: string; - maxConcurrency: number; - abortController: AbortController; - isRunning: boolean; -} - -interface PendingApproval { - resolve: (result: { approved: boolean; editedPlan?: string; feedback?: string }) => void; - reject: (error: Error) => void; - featureId: string; - projectPath: string; -} - -interface AutoModeConfig { - maxConcurrency: number; - useWorktrees: boolean; - projectPath: string; -} +import { + PlanApprovalService, + TaskExecutor, + WorktreeManager, + ProjectAnalyzer, + FeatureVerificationService, + createFeatureOutputWriter, +} from './auto-mode/index.js'; +import type { + RunningFeature, + AutoModeConfig, + FeatureExecutionOptions, + RunAgentOptions, + TaskExecutionContext, +} from './auto-mode/types.js'; + +const logger = createLogger('AutoModeService'); export class AutoModeService { private events: EventEmitter; private runningFeatures = new Map(); - private autoLoop: AutoLoopState | null = null; private featureLoader = new FeatureLoader(); private autoLoopRunning = false; private autoLoopAbortController: AbortController | null = null; private config: AutoModeConfig | null = null; - private pendingApprovals = new Map(); + + // Extracted services + private planApproval: PlanApprovalService; + private taskExecutor: TaskExecutor; + private worktreeManager: WorktreeManager; + private projectAnalyzer: ProjectAnalyzer; + private verification: FeatureVerificationService; constructor(events: EventEmitter) { this.events = events; + this.planApproval = new PlanApprovalService(events); + this.taskExecutor = new TaskExecutor(events); + this.worktreeManager = new WorktreeManager(); + this.projectAnalyzer = new ProjectAnalyzer(events); + this.verification = new FeatureVerificationService(events); } - /** - * Start the auto mode loop - continuously picks and executes pending features - */ + // ============================================================ + // Auto Loop Management + // ============================================================ + async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise { - if (this.autoLoopRunning) { - throw new Error('Auto mode is already running'); - } + if (this.autoLoopRunning) throw new Error('Auto mode is already running'); this.autoLoopRunning = true; this.autoLoopAbortController = new AbortController(); - this.config = { - maxConcurrency, - useWorktrees: true, - projectPath, - }; + this.config = { maxConcurrency, useWorktrees: true, projectPath }; - this.emitAutoModeEvent('auto_mode_started', { + this.emitEvent('auto_mode_started', { message: `Auto mode started with max ${maxConcurrency} concurrent features`, projectPath, }); - - // Run the loop in the background this.runAutoLoop().catch((error) => { - console.error('[AutoMode] Loop error:', error); + logger.error('Loop error', error); const errorInfo = classifyError(error); - this.emitAutoModeEvent('auto_mode_error', { - error: errorInfo.message, - errorType: errorInfo.type, - }); + this.emitEvent('auto_mode_error', { error: errorInfo.message, errorType: errorInfo.type }); }); } @@ -385,52 +108,42 @@ export class AutoModeService { !this.autoLoopAbortController.signal.aborted ) { try { - // Check if we have capacity if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { - await this.sleep(5000); + await sleep(5000); continue; } - // Load pending features - const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); - + const pendingFeatures = await this.featureLoader.getPending(this.config!.projectPath); if (pendingFeatures.length === 0) { - this.emitAutoModeEvent('auto_mode_idle', { + this.emitEvent('auto_mode_idle', { message: 'No pending features - auto mode idle', projectPath: this.config!.projectPath, }); - await this.sleep(10000); + await sleep(10000); continue; } - // Find a feature not currently running const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); - if (nextFeature) { - // Start feature execution in background this.executeFeature( this.config!.projectPath, nextFeature.id, this.config!.useWorktrees, true ).catch((error) => { - console.error(`[AutoMode] Feature ${nextFeature.id} error:`, error); + logger.error(`Feature ${nextFeature.id} error`, error); }); } - await this.sleep(2000); + await sleep(2000); } catch (error) { - console.error('[AutoMode] Loop iteration error:', error); - await this.sleep(5000); + logger.error('Loop iteration error', error); + await sleep(5000); } } - this.autoLoopRunning = false; } - /** - * Stop the auto mode loop - */ async stopAutoLoop(): Promise { const wasRunning = this.autoLoopRunning; this.autoLoopRunning = false; @@ -438,141 +151,97 @@ export class AutoModeService { this.autoLoopAbortController.abort(); this.autoLoopAbortController = null; } - - // Emit stop event immediately when user explicitly stops if (wasRunning) { - this.emitAutoModeEvent('auto_mode_stopped', { + this.emitEvent('auto_mode_stopped', { message: 'Auto mode stopped', projectPath: this.config?.projectPath, }); } - return this.runningFeatures.size; } - /** - * Execute a single feature - * @param projectPath - The main project path - * @param featureId - The feature ID to execute - * @param useWorktrees - Whether to use worktrees for isolation - * @param isAutoMode - Whether this is running in auto mode - */ + // ============================================================ + // Feature Execution + // ============================================================ + async executeFeature( projectPath: string, featureId: string, useWorktrees = false, isAutoMode = false, - providedWorktreePath?: string, - options?: { - continuationPrompt?: string; - } + _providedWorktreePath?: string, + options?: FeatureExecutionOptions ): Promise { - if (this.runningFeatures.has(featureId)) { - throw new Error('already running'); - } + if (this.runningFeatures.has(featureId)) throw new Error('already running'); - // Add to running features immediately to prevent race conditions const abortController = new AbortController(); - const tempRunningFeature: RunningFeature = { + const startTime = Date.now(); + + this.runningFeatures.set(featureId, { featureId, projectPath, worktreePath: null, branchName: null, abortController, isAutoMode, - startTime: Date.now(), - }; - this.runningFeatures.set(featureId, tempRunningFeature); + startTime, + }); try { - // Validate that project path is allowed using centralized validation validateWorkingDirectory(projectPath); - // Check if feature has existing context - if so, resume instead of starting fresh - // Skip this check if we're already being called with a continuation prompt (from resumeFeature) - if (!options?.continuationPrompt) { - const hasExistingContext = await this.contextExists(projectPath, featureId); - if (hasExistingContext) { - console.log( - `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh` - ); - // Remove from running features temporarily, resumeFeature will add it back - this.runningFeatures.delete(featureId); - return this.resumeFeature(projectPath, featureId, useWorktrees); - } + // Check for existing context - resume instead + if ( + !options?.continuationPrompt && + (await this.verification.contextExists(projectPath, featureId)) + ) { + logger.info(`Feature ${featureId} has existing context, resuming`); + this.runningFeatures.delete(featureId); + return this.resumeFeature(projectPath, featureId, useWorktrees); } - // Emit feature start event early - this.emitAutoModeEvent('auto_mode_feature_start', { + this.emitEvent('auto_mode_feature_start', { featureId, projectPath, - feature: { - id: featureId, - title: 'Loading...', - description: 'Feature is starting', - }, + feature: { id: featureId, title: 'Loading...', description: 'Feature is starting' }, }); - // Load feature details FIRST to get branchName - const feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } - // Derive workDir from feature.branchName - // Worktrees should already be created when the feature is added/edited - let worktreePath: string | null = null; - const branchName = feature.branchName; + const feature = await this.featureLoader.get(projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); - if (useWorktrees && branchName) { - // Try to find existing worktree for this branch - // Worktree should already exist (created when feature was added/edited) - worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); - - if (worktreePath) { - console.log(`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`); - } else { - // Worktree doesn't exist - log warning and continue with project path - console.warn( - `[AutoMode] Worktree for branch "${branchName}" not found, using project path` - ); - } - } - - // Ensure workDir is always an absolute path for cross-platform compatibility - const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); - - // Validate that working directory is allowed using centralized validation + const { workDir, worktreePath } = await this.worktreeManager.resolveWorkDir( + projectPath, + feature.branchName, + useWorktrees + ); validateWorkingDirectory(workDir); - // Update running feature with actual worktree info - tempRunningFeature.worktreePath = worktreePath; - tempRunningFeature.branchName = branchName ?? null; + const running = this.runningFeatures.get(featureId); + if (running) { + running.worktreePath = worktreePath; + running.branchName = feature.branchName ?? null; + } - // Update feature status to in_progress - await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); + await this.featureLoader.updateStatus(projectPath, featureId, 'in_progress'); - // Build the prompt - use continuation prompt if provided (for recovery after plan approval) - let prompt: string; - // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({ projectPath, fsModule: secureFs as Parameters[0]['fsModule'], }); + let prompt: string; if (options?.continuationPrompt) { - // Continuation prompt is used when recovering from a plan approval - // The plan was already approved, so skip the planning phase prompt = options.continuationPrompt; - console.log(`[AutoMode] Using continuation prompt for feature ${featureId}`); } else { - // Normal flow: build prompt with planning phase - const featurePrompt = this.buildFeaturePrompt(feature); - const planningPrefix = this.getPlanningPromptPrefix(feature); + const featurePrompt = buildFeaturePrompt(feature); + const planningPrefix = getPlanningPromptPrefix( + feature.planningMode || 'skip', + feature.requirePlanApproval + ); prompt = planningPrefix + featurePrompt; - // Emit planning mode info if (feature.planningMode && feature.planningMode !== 'skip') { - this.emitAutoModeEvent('planning_started', { + this.emitEvent('planning_started', { featureId: feature.id, mode: feature.planningMode, message: `Starting ${feature.planningMode} planning phase`, @@ -580,17 +249,12 @@ export class AutoModeService { } } - // Extract image paths from feature const imagePaths = feature.imagePaths?.map((img) => typeof img === 'string' ? img : img.path ); - - // Get model from feature const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); - console.log(`[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}`); + logger.info(`Executing feature ${featureId} with model: ${model} in ${workDir}`); - // Run the agent with the feature's model and images - // Context files are passed as system prompt for higher priority await this.runAgent( workDir, featureId, @@ -607,34 +271,28 @@ export class AutoModeService { } ); - // Determine final status based on testing mode: - // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) - // - skipTests=true (manual verification): go to 'waiting_approval' for manual review const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); + await this.featureLoader.updateStatus(projectPath, featureId, finalStatus); - this.emitAutoModeEvent('auto_mode_feature_complete', { + this.emitEvent('auto_mode_feature_complete', { featureId, passes: true, - message: `Feature completed in ${Math.round( - (Date.now() - tempRunningFeature.startTime) / 1000 - )}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`, projectPath, + message: `Feature completed in ${Math.round((Date.now() - startTime) / 1000)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`, }); } catch (error) { const errorInfo = classifyError(error); - if (errorInfo.isAbort) { - this.emitAutoModeEvent('auto_mode_feature_complete', { + this.emitEvent('auto_mode_feature_complete', { featureId, passes: false, message: 'Feature stopped by user', projectPath, }); } else { - console.error(`[AutoMode] Feature ${featureId} failed:`, error); - await this.updateFeatureStatus(projectPath, featureId, 'backlog'); - this.emitAutoModeEvent('auto_mode_error', { + logger.error(`Feature ${featureId} failed`, error); + await this.featureLoader.updateStatus(projectPath, featureId, 'backlog'); + this.emitEvent('auto_mode_error', { featureId, error: errorInfo.message, errorType: errorInfo.type, @@ -642,65 +300,33 @@ export class AutoModeService { }); } } finally { - console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`); - console.log( - `[AutoMode] Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` - ); this.runningFeatures.delete(featureId); } } - /** - * Stop a specific feature - */ async stopFeature(featureId: string): Promise { const running = this.runningFeatures.get(featureId); - if (!running) { - return false; - } - - // Cancel any pending plan approval for this feature - this.cancelPlanApproval(featureId); - + if (!running) return false; + this.planApproval.cancel(featureId); running.abortController.abort(); return true; } - /** - * Resume a feature (continues from saved context) - */ async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise { - if (this.runningFeatures.has(featureId)) { - throw new Error('already running'); + if (this.runningFeatures.has(featureId)) throw new Error('already running'); + + const context = await this.verification.loadContext(projectPath, featureId); + if (context) { + const feature = await this.featureLoader.get(projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + const prompt = buildContinuationPrompt(feature, context); + return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { + continuationPrompt: prompt, + }); } - - // Check if context exists in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - - let hasContext = false; - try { - await secureFs.access(contextPath); - hasContext = true; - } catch { - // No context - } - - if (hasContext) { - // Load previous context and continue - const context = (await secureFs.readFile(contextPath, 'utf-8')) as string; - return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); - } - - // No context, start fresh - executeFeature will handle adding to runningFeatures - // Remove the temporary entry we added - this.runningFeatures.delete(featureId); return this.executeFeature(projectPath, featureId, useWorktrees, false); } - /** - * Follow up on a feature with additional instructions - */ async followUpFeature( projectPath: string, featureId: string, @@ -708,71 +334,26 @@ export class AutoModeService { imagePaths?: string[], useWorktrees = true ): Promise { - // Validate project path early for fast failure validateWorkingDirectory(projectPath); - - if (this.runningFeatures.has(featureId)) { + if (this.runningFeatures.has(featureId)) throw new Error(`Feature ${featureId} is already running`); - } const abortController = new AbortController(); - - // Load feature info for context FIRST to get branchName - const feature = await this.loadFeature(projectPath, featureId); - - // Derive workDir from feature.branchName - // If no branchName, derive from feature ID: feature/{featureId} - let workDir = path.resolve(projectPath); - let worktreePath: string | null = null; + const feature = await this.featureLoader.get(projectPath, featureId); const branchName = feature?.branchName || `feature/${featureId}`; + const { workDir, worktreePath } = await this.worktreeManager.resolveWorkDir( + projectPath, + branchName, + useWorktrees + ); - if (useWorktrees && branchName) { - // Try to find existing worktree for this branch - worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); - - if (worktreePath) { - workDir = worktreePath; - console.log(`[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}`); - } - } - - // Load previous agent output if it exists - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - let previousContext = ''; - try { - previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; - } catch { - // No previous context - } - - // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt + const previousContext = (await this.verification.loadContext(projectPath, featureId)) || ''; const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({ projectPath, fsModule: secureFs as Parameters[0]['fsModule'], }); - // Build complete prompt with feature info, previous context, and follow-up instructions - let fullPrompt = `## Follow-up on Feature Implementation - -${feature ? this.buildFeaturePrompt(feature) : `**Feature ID:** ${featureId}`} -`; - - if (previousContext) { - fullPrompt += ` -## Previous Agent Work -The following is the output from the previous implementation attempt: - -${previousContext} -`; - } - - fullPrompt += ` -## Follow-up Instructions -${prompt} - -## Task -Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`; + const fullPrompt = buildFollowUpPrompt(feature, featureId, previousContext, prompt); this.runningFeatures.set(featureId, { featureId, @@ -783,8 +364,7 @@ Address the follow-up instructions above. Review the previous work and make the isAutoMode: false, startTime: Date.now(), }); - - this.emitAutoModeEvent('auto_mode_feature_start', { + this.emitEvent('auto_mode_feature_start', { featureId, projectPath, feature: feature || { @@ -795,109 +375,44 @@ Address the follow-up instructions above. Review the previous work and make the }); try { - // Get model from feature (already loaded above) const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude); - console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`); + await this.featureLoader.updateStatus(projectPath, featureId, 'in_progress'); - // Update feature status to in_progress - await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); - - // Copy follow-up images to feature folder - const copiedImagePaths: string[] = []; - if (imagePaths && imagePaths.length > 0) { - const featureDirForImages = getFeatureDir(projectPath, featureId); - const featureImagesDir = path.join(featureDirForImages, 'images'); - - await secureFs.mkdir(featureImagesDir, { recursive: true }); - - for (const imagePath of imagePaths) { - try { - // Get the filename from the path - const filename = path.basename(imagePath); - const destPath = path.join(featureImagesDir, filename); - - // Copy the image - await secureFs.copyFile(imagePath, destPath); - - // Store the absolute path (external storage uses absolute paths) - copiedImagePaths.push(destPath); - } catch (error) { - console.error(`[AutoMode] Failed to copy follow-up image ${imagePath}:`, error); - } - } - } - - // Update feature object with new follow-up images BEFORE building prompt - if (copiedImagePaths.length > 0 && feature) { - const currentImagePaths = feature.imagePaths || []; - const newImagePaths = copiedImagePaths.map((p) => ({ - path: p, - filename: path.basename(p), - mimeType: 'image/png', // Default, could be improved - })); - - feature.imagePaths = [...currentImagePaths, ...newImagePaths]; - } - - // Combine original feature images with new follow-up images const allImagePaths: string[] = []; - - // Add all images from feature (now includes both original and new) - if (feature?.imagePaths) { - const allPaths = feature.imagePaths.map((img) => - typeof img === 'string' ? img : img.path + if (feature?.imagePaths) + allImagePaths.push( + ...feature.imagePaths.map((img) => (typeof img === 'string' ? img : img.path)) ); - allImagePaths.push(...allPaths); - } + if (imagePaths) allImagePaths.push(...imagePaths); - // Save updated feature.json with new images - if (copiedImagePaths.length > 0 && feature) { - const featureDirForSave = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDirForSave, 'feature.json'); - - try { - await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); - } catch (error) { - console.error(`[AutoMode] Failed to save feature.json:`, error); - } - } - - // Use fullPrompt (already built above) with model and all images - // Note: Follow-ups skip planning mode - they continue from previous work - // Pass previousContext so the history is preserved in the output file - // Context files are passed as system prompt for higher priority await this.runAgent( workDir, featureId, fullPrompt, abortController, projectPath, - allImagePaths.length > 0 ? allImagePaths : imagePaths, + allImagePaths.length > 0 ? allImagePaths : undefined, model, { projectPath, - planningMode: 'skip', // Follow-ups don't require approval + planningMode: 'skip', previousContent: previousContext || undefined, systemPrompt: contextFilesPrompt || undefined, } ); - // Determine final status based on testing mode: - // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) - // - skipTests=true (manual verification): go to 'waiting_approval' for manual review const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); - - this.emitAutoModeEvent('auto_mode_feature_complete', { + await this.featureLoader.updateStatus(projectPath, featureId, finalStatus); + this.emitEvent('auto_mode_feature_complete', { featureId, passes: true, - message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, + message: `Follow-up completed${finalStatus === 'verified' ? ' - auto-verified' : ''}`, projectPath, }); } catch (error) { const errorInfo = classifyError(error); if (!errorInfo.isCancellation) { - this.emitAutoModeEvent('auto_mode_error', { + this.emitEvent('auto_mode_error', { featureId, error: errorInfo.message, errorType: errorInfo.type, @@ -909,254 +424,43 @@ Address the follow-up instructions above. Review the previous work and make the } } - /** - * Verify a feature's implementation - */ + // ============================================================ + // Verification & Git (delegated) + // ============================================================ + async verifyFeature(projectPath: string, featureId: string): Promise { - // Worktrees are in project dir - const worktreePath = path.join(projectPath, '.worktrees', featureId); - let workDir = projectPath; - - try { - await secureFs.access(worktreePath); - workDir = worktreePath; - } catch { - // No worktree - } - - // Run verification - check if tests pass, build works, etc. - const verificationChecks = [ - { cmd: 'npm run lint', name: 'Lint' }, - { cmd: 'npm run typecheck', name: 'Type check' }, - { cmd: 'npm test', name: 'Tests' }, - { cmd: 'npm run build', name: 'Build' }, - ]; - - let allPassed = true; - const results: Array<{ check: string; passed: boolean; output?: string }> = []; - - for (const check of verificationChecks) { - try { - const { stdout, stderr } = await execAsync(check.cmd, { - cwd: workDir, - timeout: 120000, - }); - results.push({ - check: check.name, - passed: true, - output: stdout || stderr, - }); - } catch (error) { - allPassed = false; - results.push({ - check: check.name, - passed: false, - output: (error as Error).message, - }); - break; // Stop on first failure - } - } - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - passes: allPassed, - message: allPassed - ? 'All verification checks passed' - : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, - }); - - return allPassed; + const result = await this.verification.verify(projectPath, featureId); + return result.success; } - /** - * Commit feature changes - * @param projectPath - The main project path - * @param featureId - The feature ID to commit - * @param providedWorktreePath - Optional: the worktree path where the feature's changes are located - */ async commitFeature( projectPath: string, featureId: string, providedWorktreePath?: string ): Promise { - let workDir = projectPath; - - // Use the provided worktree path if given - if (providedWorktreePath) { - try { - await secureFs.access(providedWorktreePath); - workDir = providedWorktreePath; - console.log(`[AutoMode] Committing in provided worktree: ${workDir}`); - } catch { - console.log( - `[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path` - ); - } - } else { - // Fallback: try to find worktree at legacy location - const legacyWorktreePath = path.join(projectPath, '.worktrees', featureId); - try { - await secureFs.access(legacyWorktreePath); - workDir = legacyWorktreePath; - console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`); - } catch { - console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`); - } - } - - try { - // Check for changes - const { stdout: status } = await execAsync('git status --porcelain', { - cwd: workDir, - }); - if (!status.trim()) { - return null; // No changes - } - - // Load feature for commit message - const feature = await this.loadFeature(projectPath, featureId); - const commitMessage = feature - ? `feat: ${this.extractTitleFromDescription( - feature.description - )}\n\nImplemented by Automaker auto-mode` - : `feat: Feature ${featureId}`; - - // Stage and commit - await execAsync('git add -A', { cwd: workDir }); - await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { - cwd: workDir, - }); - - // Get commit hash - const { stdout: hash } = await execAsync('git rev-parse HEAD', { - cwd: workDir, - }); - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - passes: true, - message: `Changes committed: ${hash.trim().substring(0, 8)}`, - }); - - return hash.trim(); - } catch (error) { - console.error(`[AutoMode] Commit failed for ${featureId}:`, error); - return null; - } - } - - /** - * Check if context exists for a feature - */ - async contextExists(projectPath: string, featureId: string): Promise { - // Context is stored in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - - try { - await secureFs.access(contextPath); - return true; - } catch { - return false; - } - } - - /** - * Analyze project to gather context - */ - async analyzeProject(projectPath: string): Promise { - // Validate project path before proceeding - // This is called here because analyzeProject builds ExecuteOptions directly - // without using a factory function from sdk-options.ts - validateWorkingDirectory(projectPath); - - const abortController = new AbortController(); - - const analysisFeatureId = `analysis-${Date.now()}`; - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId: analysisFeatureId, + const feature = await this.featureLoader.get(projectPath, featureId); + const result = await this.verification.commit( projectPath, - feature: { - id: analysisFeatureId, - title: 'Project Analysis', - description: 'Analyzing project structure', - }, - }); - - const prompt = `Analyze this project and provide a summary of: -1. Project structure and architecture -2. Main technologies and frameworks used -3. Key components and their responsibilities -4. Build and test commands -5. Any existing conventions or patterns - -Format your response as a structured markdown document.`; - - try { - // Use default Claude model for analysis (can be overridden in the future) - const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); - const provider = ProviderFactory.getProviderForModel(analysisModel); - - const options: ExecuteOptions = { - prompt, - model: analysisModel, - maxTurns: 5, - cwd: projectPath, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - }; - - const stream = provider.executeQuery(options); - let analysisResult = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - analysisResult = block.text || ''; - this.emitAutoModeEvent('auto_mode_progress', { - featureId: analysisFeatureId, - content: block.text, - projectPath, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - analysisResult = msg.result || analysisResult; - } - } - - // Save analysis to .automaker directory - const automakerDir = getAutomakerDir(projectPath); - const analysisPath = path.join(automakerDir, 'project-analysis.md'); - await secureFs.mkdir(automakerDir, { recursive: true }); - await secureFs.writeFile(analysisPath, analysisResult); - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId: analysisFeatureId, - passes: true, - message: 'Project analysis completed', - projectPath, - }); - } catch (error) { - const errorInfo = classifyError(error); - this.emitAutoModeEvent('auto_mode_error', { - featureId: analysisFeatureId, - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - }); - } + featureId, + feature, + providedWorktreePath + ); + return result.hash; } - /** - * Get current status - */ - getStatus(): { - isRunning: boolean; - runningFeatures: string[]; - runningCount: number; - } { + contextExists(projectPath: string, featureId: string): Promise { + return this.verification.contextExists(projectPath, featureId); + } + + analyzeProject(projectPath: string): Promise { + return this.projectAnalyzer.analyze(projectPath); + } + + // ============================================================ + // Status + // ============================================================ + + getStatus(): { isRunning: boolean; runningFeatures: string[]; runningCount: number } { return { isRunning: this.runningFeatures.size > 0, runningFeatures: Array.from(this.runningFeatures.keys()), @@ -1164,9 +468,6 @@ Format your response as a structured markdown document.`; }; } - /** - * Get detailed info about all running agents - */ getRunningAgents(): Array<{ featureId: string; projectPath: string; @@ -1181,33 +482,17 @@ Format your response as a structured markdown document.`; })); } - /** - * Wait for plan approval from the user. - * Returns a promise that resolves when the user approves/rejects the plan. - */ + // ============================================================ + // Plan Approval (delegated to PlanApprovalService) + // ============================================================ + waitForPlanApproval( featureId: string, projectPath: string ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { - console.log(`[AutoMode] Registering pending approval for feature ${featureId}`); - console.log( - `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` - ); - return new Promise((resolve, reject) => { - this.pendingApprovals.set(featureId, { - resolve, - reject, - featureId, - projectPath, - }); - console.log(`[AutoMode] Pending approval registered for feature ${featureId}`); - }); + return this.planApproval.waitForApproval(featureId, projectPath); } - /** - * Resolve a pending plan approval. - * Called when the user approves or rejects the plan via API. - */ async resolvePlanApproval( featureId: string, approved: boolean, @@ -1215,485 +500,81 @@ Format your response as a structured markdown document.`; feedback?: string, projectPathFromClient?: string ): Promise<{ success: boolean; error?: string }> { - console.log( - `[AutoMode] resolvePlanApproval called for feature ${featureId}, approved=${approved}` - ); - console.log( - `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` - ); - const pending = this.pendingApprovals.get(featureId); + const result = this.planApproval.resolve(featureId, approved, editedPlan, feedback); - if (!pending) { - console.log(`[AutoMode] No pending approval in Map for feature ${featureId}`); - - // RECOVERY: If no pending approval but we have projectPath from client, - // check if feature's planSpec.status is 'generated' and handle recovery - if (projectPathFromClient) { - console.log(`[AutoMode] Attempting recovery with projectPath: ${projectPathFromClient}`); - const feature = await this.loadFeature(projectPathFromClient, featureId); - - if (feature?.planSpec?.status === 'generated') { - console.log( - `[AutoMode] Feature ${featureId} has planSpec.status='generated', performing recovery` - ); - - if (approved) { - // Update planSpec to approved - await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { - status: 'approved', - approvedAt: new Date().toISOString(), - reviewedByUser: true, - content: editedPlan || feature.planSpec.content, - }); - - // Build continuation prompt and re-run the feature - const planContent = editedPlan || feature.planSpec.content || ''; - let continuationPrompt = `The plan/specification has been approved. `; - if (feedback) { - continuationPrompt += `\n\nUser feedback: ${feedback}\n\n`; - } - continuationPrompt += `Now proceed with the implementation as specified in the plan:\n\n${planContent}\n\nImplement the feature now.`; - - console.log(`[AutoMode] Starting recovery execution for feature ${featureId}`); - - // Start feature execution with the continuation prompt (async, don't await) - // Pass undefined for providedWorktreePath, use options for continuation prompt - this.executeFeature(projectPathFromClient, featureId, true, false, undefined, { - continuationPrompt, - }).catch((error) => { - console.error( - `[AutoMode] Recovery execution failed for feature ${featureId}:`, - error - ); - }); - - return { success: true }; - } else { - // Rejected - update status and emit event - await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { - status: 'rejected', - reviewedByUser: true, - }); - - await this.updateFeatureStatus(projectPathFromClient, featureId, 'backlog'); - - this.emitAutoModeEvent('plan_rejected', { - featureId, - projectPath: projectPathFromClient, - feedback, - }); - - return { success: true }; - } - } - } - - console.log( - `[AutoMode] ERROR: No pending approval found for feature ${featureId} and recovery not possible` - ); - return { - success: false, - error: `No pending approval for feature ${featureId}`, - }; - } - console.log(`[AutoMode] Found pending approval for feature ${featureId}, proceeding...`); - - const { projectPath } = pending; - - // Update feature's planSpec status - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: approved ? 'approved' : 'rejected', - approvedAt: approved ? new Date().toISOString() : undefined, - reviewedByUser: true, - content: editedPlan, // Update content if user provided an edited version - }); - - // If rejected with feedback, we can store it for the user to see - if (!approved && feedback) { - // Emit event so client knows the rejection reason - this.emitAutoModeEvent('plan_rejected', { + if (!result.success && projectPathFromClient) { + return this.handleApprovalRecovery( featureId, - projectPath, + approved, + editedPlan, feedback, - }); + projectPathFromClient + ); } - // Resolve the promise with all data including feedback - pending.resolve({ approved, editedPlan, feedback }); - this.pendingApprovals.delete(featureId); + if (result.success && result.projectPath) { + await this.featureLoader.updatePlanSpec(result.projectPath, featureId, { + status: approved ? 'approved' : 'rejected', + approvedAt: approved ? new Date().toISOString() : undefined, + reviewedByUser: true, + content: editedPlan, + }); + if (!approved && feedback) + this.planApproval.emitRejected(featureId, result.projectPath, feedback); + } + + return result; + } + + cancelPlanApproval(featureId: string): void { + this.planApproval.cancel(featureId); + } + hasPendingApproval(featureId: string): boolean { + return this.planApproval.hasPending(featureId); + } + + // ============================================================ + // Private Helpers + // ============================================================ + + private async handleApprovalRecovery( + featureId: string, + approved: boolean, + editedPlan: string | undefined, + feedback: string | undefined, + projectPath: string + ): Promise<{ success: boolean; error?: string }> { + logger.debug(`Attempting approval recovery for feature ${featureId}`); + const feature = await this.featureLoader.get(projectPath, featureId); + if (feature?.planSpec?.status !== 'generated') + return { success: false, error: `No pending approval for feature ${featureId}` }; + + if (approved) { + await this.featureLoader.updatePlanSpec(projectPath, featureId, { + status: 'approved', + approvedAt: new Date().toISOString(), + reviewedByUser: true, + content: editedPlan || feature.planSpec.content, + }); + const planContent = editedPlan || feature.planSpec.content || ''; + let continuationPrompt = `The plan/specification has been approved. `; + if (feedback) continuationPrompt += `\n\nUser feedback: ${feedback}\n\n`; + continuationPrompt += `Now proceed with the implementation:\n\n${planContent}`; + this.executeFeature(projectPath, featureId, true, false, undefined, { + continuationPrompt, + }).catch((error) => logger.error(`Recovery execution failed for ${featureId}`, error)); + } else { + await this.featureLoader.updatePlanSpec(projectPath, featureId, { + status: 'rejected', + reviewedByUser: true, + }); + await this.featureLoader.updateStatus(projectPath, featureId, 'backlog'); + this.planApproval.emitRejected(featureId, projectPath, feedback); + } return { success: true }; } - /** - * Cancel a pending plan approval (e.g., when feature is stopped). - */ - cancelPlanApproval(featureId: string): void { - console.log(`[AutoMode] cancelPlanApproval called for feature ${featureId}`); - console.log( - `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` - ); - const pending = this.pendingApprovals.get(featureId); - if (pending) { - console.log(`[AutoMode] Found and cancelling pending approval for feature ${featureId}`); - pending.reject(new Error('Plan approval cancelled - feature was stopped')); - this.pendingApprovals.delete(featureId); - } else { - console.log(`[AutoMode] No pending approval to cancel for feature ${featureId}`); - } - } - - /** - * Check if a feature has a pending plan approval. - */ - hasPendingApproval(featureId: string): boolean { - return this.pendingApprovals.has(featureId); - } - - // Private helpers - - /** - * Find an existing worktree for a given branch by checking git worktree list - */ - private async findExistingWorktreeForBranch( - projectPath: string, - branchName: string - ): Promise { - try { - const { stdout } = await execAsync('git worktree list --porcelain', { - cwd: projectPath, - }); - - const lines = stdout.split('\n'); - let currentPath: string | null = null; - let currentBranch: string | null = null; - - for (const line of lines) { - if (line.startsWith('worktree ')) { - currentPath = line.slice(9); - } else if (line.startsWith('branch ')) { - currentBranch = line.slice(7).replace('refs/heads/', ''); - } else if (line === '' && currentPath && currentBranch) { - // End of a worktree entry - if (currentBranch === branchName) { - // Resolve to absolute path - git may return relative paths - // On Windows, this is critical for cwd to work correctly - // On all platforms, absolute paths ensure consistent behavior - const resolvedPath = path.isAbsolute(currentPath) - ? path.resolve(currentPath) - : path.resolve(projectPath, currentPath); - return resolvedPath; - } - currentPath = null; - currentBranch = null; - } - } - - // Check the last entry (if file doesn't end with newline) - if (currentPath && currentBranch && currentBranch === branchName) { - // Resolve to absolute path for cross-platform compatibility - const resolvedPath = path.isAbsolute(currentPath) - ? path.resolve(currentPath) - : path.resolve(projectPath, currentPath); - return resolvedPath; - } - - return null; - } catch { - return null; - } - } - - private async loadFeature(projectPath: string, featureId: string): Promise { - // Features are stored in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - return JSON.parse(data); - } catch { - return null; - } - } - - private async updateFeatureStatus( - projectPath: string, - featureId: string, - status: string - ): Promise { - // Features are stored in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - const feature = JSON.parse(data); - feature.status = status; - feature.updatedAt = new Date().toISOString(); - // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) - // Badge will show for 2 minutes after this timestamp - if (status === 'waiting_approval') { - feature.justFinishedAt = new Date().toISOString(); - } else { - // Clear the timestamp when moving to other statuses - feature.justFinishedAt = undefined; - } - await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); - } catch { - // Feature file may not exist - } - } - - /** - * Update the planSpec of a feature - */ - private async updateFeaturePlanSpec( - projectPath: string, - featureId: string, - updates: Partial - ): Promise { - const featurePath = path.join(projectPath, '.automaker', 'features', featureId, 'feature.json'); - - try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - const feature = JSON.parse(data); - - // Initialize planSpec if it doesn't exist - if (!feature.planSpec) { - feature.planSpec = { - status: 'pending', - version: 1, - reviewedByUser: false, - }; - } - - // Apply updates - Object.assign(feature.planSpec, updates); - - // If content is being updated and it's a new version, increment version - if (updates.content && updates.content !== feature.planSpec.content) { - feature.planSpec.version = (feature.planSpec.version || 0) + 1; - } - - feature.updatedAt = new Date().toISOString(); - await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); - } catch (error) { - console.error(`[AutoMode] Failed to update planSpec for ${featureId}:`, error); - } - } - - private async loadPendingFeatures(projectPath: string): Promise { - // Features are stored in .automaker directory - const featuresDir = getFeaturesDir(projectPath); - - try { - const entries = await secureFs.readdir(featuresDir, { - withFileTypes: true, - }); - const allFeatures: Feature[] = []; - const pendingFeatures: Feature[] = []; - - // Load all features (for dependency checking) - for (const entry of entries) { - if (entry.isDirectory()) { - const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - const feature = JSON.parse(data); - allFeatures.push(feature); - - // Track pending features separately - if ( - feature.status === 'pending' || - feature.status === 'ready' || - feature.status === 'backlog' - ) { - pendingFeatures.push(feature); - } - } catch { - // Skip invalid features - } - } - } - - // Apply dependency-aware ordering - const { orderedFeatures } = resolveDependencies(pendingFeatures); - - // Filter to only features with satisfied dependencies - const readyFeatures = orderedFeatures.filter((feature: Feature) => - areDependenciesSatisfied(feature, allFeatures) - ); - - return readyFeatures; - } catch { - return []; - } - } - - /** - * Extract a title from feature description (first line or truncated) - */ - private extractTitleFromDescription(description: string): string { - if (!description || !description.trim()) { - return 'Untitled Feature'; - } - - // Get first line, or first 60 characters if no newline - const firstLine = description.split('\n')[0].trim(); - if (firstLine.length <= 60) { - return firstLine; - } - - // Truncate to 60 characters and add ellipsis - return firstLine.substring(0, 57) + '...'; - } - - /** - * Get the planning prompt prefix based on feature's planning mode - */ - private getPlanningPromptPrefix(feature: Feature): string { - const mode = feature.planningMode || 'skip'; - - if (mode === 'skip') { - return ''; // No planning phase - } - - // For lite mode, use the approval variant if requirePlanApproval is true - let promptKey: string = mode; - if (mode === 'lite' && feature.requirePlanApproval === true) { - promptKey = 'lite_with_approval'; - } - - const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS]; - if (!planningPrompt) { - return ''; - } - - return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; - } - - private buildFeaturePrompt(feature: Feature): string { - const title = this.extractTitleFromDescription(feature.description); - - let prompt = `## Feature Implementation Task - -**Feature ID:** ${feature.id} -**Title:** ${title} -**Description:** ${feature.description} -`; - - if (feature.spec) { - prompt += ` -**Specification:** -${feature.spec} -`; - } - - // Add images note (like old implementation) - if (feature.imagePaths && feature.imagePaths.length > 0) { - const imagesList = feature.imagePaths - .map((img, idx) => { - const path = typeof img === 'string' ? img : img.path; - const filename = - typeof img === 'string' ? path.split('/').pop() : img.filename || path.split('/').pop(); - const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*'; - return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`; - }) - .join('\n'); - - prompt += ` -**📎 Context Images Attached:** -The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read: - -${imagesList} - -You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing. -`; - } - - // Add verification instructions based on testing mode - if (feature.skipTests) { - // Manual verification - just implement the feature - prompt += ` -## Instructions - -Implement this feature by: -1. First, explore the codebase to understand the existing structure -2. Plan your implementation approach -3. Write the necessary code changes -4. Ensure the code follows existing patterns and conventions - -When done, wrap your final summary in tags like this: - - -## Summary: [Feature Title] - -### Changes Implemented -- [List of changes made] - -### Files Modified -- [List of files] - -### Notes for Developer -- [Any important notes] - - -This helps parse your summary correctly in the output logs.`; - } else { - // Automated testing - implement and verify with Playwright - prompt += ` -## Instructions - -Implement this feature by: -1. First, explore the codebase to understand the existing structure -2. Plan your implementation approach -3. Write the necessary code changes -4. Ensure the code follows existing patterns and conventions - -## Verification with Playwright (REQUIRED) - -After implementing the feature, you MUST verify it works correctly using Playwright: - -1. **Create a temporary Playwright test** to verify the feature works as expected -2. **Run the test** to confirm the feature is working -3. **Delete the test file** after verification - this is a temporary verification test, not a permanent test suite addition - -Example verification workflow: -\`\`\`bash -# Create a simple verification test -npx playwright test my-verification-test.spec.ts - -# After successful verification, delete the test -rm my-verification-test.spec.ts -\`\`\` - -The test should verify the core functionality of the feature. If the test fails, fix the implementation and re-test. - -When done, wrap your final summary in tags like this: - - -## Summary: [Feature Title] - -### Changes Implemented -- [List of changes made] - -### Files Modified -- [List of files] - -### Verification Status -- [Describe how the feature was verified with Playwright] - -### Notes for Developer -- [Any important notes] - - -This helps parse your summary correctly in the output logs.`; - } - - return prompt; - } - private async runAgent( workDir: string, featureId: string, @@ -1702,796 +583,216 @@ This helps parse your summary correctly in the output logs.`; projectPath: string, imagePaths?: string[], model?: string, - options?: { - projectPath?: string; - planningMode?: PlanningMode; - requirePlanApproval?: boolean; - previousContent?: string; - systemPrompt?: string; - } + options?: RunAgentOptions ): Promise { - const finalProjectPath = options?.projectPath || projectPath; const planningMode = options?.planningMode || 'skip'; const previousContent = options?.previousContent; - - // Check if this planning mode can generate a spec/plan that needs approval - // - spec and full always generate specs - // - lite only generates approval-ready content when requirePlanApproval is true const planningModeRequiresApproval = planningMode === 'spec' || planningMode === 'full' || (planningMode === 'lite' && options?.requirePlanApproval === true); const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true; - // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set - // This prevents actual API calls during automated testing - if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { - console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`); + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') + return this.runMockAgent(workDir, featureId, projectPath); - // Simulate some work being done - await this.sleep(500); - - // Emit mock progress events to simulate agent activity - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: 'Mock agent: Analyzing the codebase...', - }); - - await this.sleep(300); - - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: 'Mock agent: Implementing the feature...', - }); - - await this.sleep(300); - - // Create a mock file with "yellow" content as requested in the test - const mockFilePath = path.join(workDir, 'yellow.txt'); - await secureFs.writeFile(mockFilePath, 'yellow'); - - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: "Mock agent: Created yellow.txt file with content 'yellow'", - }); - - await this.sleep(200); - - // Save mock agent output - const featureDirForOutput = getFeatureDir(projectPath, featureId); - const outputPath = path.join(featureDirForOutput, 'agent-output.md'); - - const mockOutput = `# Mock Agent Output - -## Summary -This is a mock agent response for CI/CD testing. - -## Changes Made -- Created \`yellow.txt\` with content "yellow" - -## Notes -This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. -`; - - await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); - await secureFs.writeFile(outputPath, mockOutput); - - console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`); - return; - } - - // Build SDK options using centralized configuration for feature implementation - const sdkOptions = createAutoModeOptions({ - cwd: workDir, - model: model, - abortController, - }); - - // Extract model, maxTurns, and allowedTools from SDK options + const sdkOptions = createAutoModeOptions({ cwd: workDir, model, abortController }); const finalModel = sdkOptions.model!; - const maxTurns = sdkOptions.maxTurns; + const maxTurns = sdkOptions.maxTurns || 100; const allowedTools = sdkOptions.allowedTools as string[] | undefined; - console.log( - `[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}, planningMode: ${planningMode}, requiresApproval: ${requiresApproval}` + logger.debug( + `runAgent for ${featureId}: model=${finalModel}, planningMode=${planningMode}, requiresApproval=${requiresApproval}` ); - - // Get provider for this model const provider = ProviderFactory.getProviderForModel(finalModel); - - console.log(`[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"`); - - // Build prompt content with images using utility const { content: promptContent } = await buildPromptWithImages( prompt, imagePaths, workDir, - false // don't duplicate paths in text + false ); - // Debug: Log if system prompt is provided - if (options?.systemPrompt) { - console.log( - `[AutoMode] System prompt provided (${options.systemPrompt.length} chars), first 200 chars:\n${options.systemPrompt.substring(0, 200)}...` - ); - } + const featureDir = getFeatureDir(projectPath, featureId); + const outputWriter = createFeatureOutputWriter(featureDir, previousContent); const executeOptions: ExecuteOptions = { prompt: promptContent, model: finalModel, - maxTurns: maxTurns, + maxTurns, cwd: workDir, - allowedTools: allowedTools, + allowedTools, abortController, systemPrompt: options?.systemPrompt, }; - - // Execute via provider const stream = provider.executeQuery(executeOptions); - // Initialize with previous content if this is a follow-up, with a separator - let responseText = previousContent - ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` - : ''; let specDetected = false; - // Agent output goes to .automaker directory - // Note: We use projectPath here, not workDir, because workDir might be a worktree path - const featureDirForOutput = getFeatureDir(projectPath, featureId); - const outputPath = path.join(featureDirForOutput, 'agent-output.md'); - - // Incremental file writing state - let writeTimeout: ReturnType | null = null; - const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms - - // Helper to write current responseText to file - const writeToFile = async (): Promise => { - try { - await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); - await secureFs.writeFile(outputPath, responseText); - } catch (error) { - // Log but don't crash - file write errors shouldn't stop execution - console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error); - } - }; - - // Debounced write - schedules a write after WRITE_DEBOUNCE_MS - const scheduleWrite = (): void => { - if (writeTimeout) { - clearTimeout(writeTimeout); - } - writeTimeout = setTimeout(() => { - writeToFile(); - }, WRITE_DEBOUNCE_MS); - }; - - streamLoop: for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - // Add separator before new text if we already have content and it doesn't end with newlines - if (responseText.length > 0 && !responseText.endsWith('\n\n')) { - if (responseText.endsWith('\n')) { - responseText += '\n'; - } else { - responseText += '\n\n'; - } - } - responseText += block.text || ''; - - // Check for authentication errors in the response - if ( - block.text && - (block.text.includes('Invalid API key') || - block.text.includes('authentication_failed') || - block.text.includes('Fix external API key')) - ) { - throw new Error( - 'Authentication failed: Invalid or expired API key. ' + - "Please check your ANTHROPIC_API_KEY, or run 'claude login' to re-authenticate." - ); - } - - // Schedule incremental file write (debounced) - scheduleWrite(); - - // Check for [SPEC_GENERATED] marker in planning modes (spec or full) - if ( - planningModeRequiresApproval && - !specDetected && - responseText.includes('[SPEC_GENERATED]') - ) { - specDetected = true; - - // Extract plan content (everything before the marker) - const markerIndex = responseText.indexOf('[SPEC_GENERATED]'); - const planContent = responseText.substring(0, markerIndex).trim(); - - // Parse tasks from the generated spec (for spec and full modes) - // Use let since we may need to update this after plan revision - let parsedTasks = parseTasksFromSpec(planContent); - const tasksTotal = parsedTasks.length; - - console.log( - `[AutoMode] Parsed ${tasksTotal} tasks from spec for feature ${featureId}` - ); - if (parsedTasks.length > 0) { - console.log(`[AutoMode] Tasks: ${parsedTasks.map((t) => t.id).join(', ')}`); - } - - // Update planSpec status to 'generated' and save content with parsed tasks - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'generated', - content: planContent, - version: 1, - generatedAt: new Date().toISOString(), - reviewedByUser: false, - tasks: parsedTasks, - tasksTotal, - tasksCompleted: 0, - }); - - let approvedPlanContent = planContent; - let userFeedback: string | undefined; - let currentPlanContent = planContent; - let planVersion = 1; - - // Only pause for approval if requirePlanApproval is true - if (requiresApproval) { - // ======================================== - // PLAN REVISION LOOP - // Keep regenerating plan until user approves - // ======================================== - let planApproved = false; - - while (!planApproved) { - console.log( - `[AutoMode] Spec v${planVersion} generated for feature ${featureId}, waiting for approval` - ); - - // CRITICAL: Register pending approval BEFORE emitting event - const approvalPromise = this.waitForPlanApproval(featureId, projectPath); - - // Emit plan_approval_required event - this.emitAutoModeEvent('plan_approval_required', { + try { + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + outputWriter.appendWithSeparator(block.text); + if ( + planningModeRequiresApproval && + !specDetected && + outputWriter.getContent().includes('[SPEC_GENERATED]') + ) { + specDetected = true; + const planContent = extractBeforeMarker( + outputWriter.getContent(), + '[SPEC_GENERATED]' + ); + if (planContent) { + await this.handleSpecGenerated( featureId, projectPath, - planContent: currentPlanContent, + planContent, planningMode, - planVersion, - }); - - // Wait for user response - try { - const approvalResult = await approvalPromise; - - if (approvalResult.approved) { - // User approved the plan - console.log( - `[AutoMode] Plan v${planVersion} approved for feature ${featureId}` - ); - planApproved = true; - - // If user provided edits, use the edited version - if (approvalResult.editedPlan) { - approvedPlanContent = approvalResult.editedPlan; - await this.updateFeaturePlanSpec(projectPath, featureId, { - content: approvalResult.editedPlan, - }); - } else { - approvedPlanContent = currentPlanContent; - } - - // Capture any additional feedback for implementation - userFeedback = approvalResult.feedback; - - // Emit approval event - this.emitAutoModeEvent('plan_approved', { - featureId, - projectPath, - hasEdits: !!approvalResult.editedPlan, - planVersion, - }); - } else { - // User rejected - check if they provided feedback for revision - const hasFeedback = - approvalResult.feedback && approvalResult.feedback.trim().length > 0; - const hasEdits = - approvalResult.editedPlan && approvalResult.editedPlan.trim().length > 0; - - if (!hasFeedback && !hasEdits) { - // No feedback or edits = explicit cancel - console.log( - `[AutoMode] Plan rejected without feedback for feature ${featureId}, cancelling` - ); - throw new Error('Plan cancelled by user'); - } - - // User wants revisions - regenerate the plan - console.log( - `[AutoMode] Plan v${planVersion} rejected with feedback for feature ${featureId}, regenerating...` - ); - planVersion++; - - // Emit revision event - this.emitAutoModeEvent('plan_revision_requested', { - featureId, - projectPath, - feedback: approvalResult.feedback, - hasEdits: !!hasEdits, - planVersion, - }); - - // Build revision prompt - let revisionPrompt = `The user has requested revisions to the plan/specification. - -## Previous Plan (v${planVersion - 1}) -${hasEdits ? approvalResult.editedPlan : currentPlanContent} - -## User Feedback -${approvalResult.feedback || 'Please revise the plan based on the edits above.'} - -## Instructions -Please regenerate the specification incorporating the user's feedback. -Keep the same format with the \`\`\`tasks block for task definitions. -After generating the revised spec, output: -"[SPEC_GENERATED] Please review the revised specification above." -`; - - // Update status to regenerating - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'generating', - version: planVersion, - }); - - // Make revision call - const revisionStream = provider.executeQuery({ - prompt: revisionPrompt, - model: finalModel, - maxTurns: maxTurns || 100, - cwd: workDir, - allowedTools: allowedTools, - abortController, - }); - - let revisionText = ''; - for await (const msg of revisionStream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - revisionText += block.text || ''; - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: block.text, - }); - } - } - } else if (msg.type === 'error') { - throw new Error(msg.error || 'Error during plan revision'); - } else if (msg.type === 'result' && msg.subtype === 'success') { - revisionText += msg.result || ''; - } - } - - // Extract new plan content - const markerIndex = revisionText.indexOf('[SPEC_GENERATED]'); - if (markerIndex > 0) { - currentPlanContent = revisionText.substring(0, markerIndex).trim(); - } else { - currentPlanContent = revisionText.trim(); - } - - // Re-parse tasks from revised plan - const revisedTasks = parseTasksFromSpec(currentPlanContent); - console.log(`[AutoMode] Revised plan has ${revisedTasks.length} tasks`); - - // Update planSpec with revised content - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'generated', - content: currentPlanContent, - version: planVersion, - tasks: revisedTasks, - tasksTotal: revisedTasks.length, - tasksCompleted: 0, - }); - - // Update parsedTasks for implementation - parsedTasks = revisedTasks; - - responseText += revisionText; - } - } catch (error) { - if ((error as Error).message.includes('cancelled')) { - throw error; - } - throw new Error(`Plan approval failed: ${(error as Error).message}`); - } - } - } else { - // Auto-approve: requirePlanApproval is false, just continue without pausing - console.log( - `[AutoMode] Spec generated for feature ${featureId}, auto-approving (requirePlanApproval=false)` - ); - - // Emit info event for frontend - this.emitAutoModeEvent('plan_auto_approved', { - featureId, - projectPath, - planContent, - planningMode, - }); - - approvedPlanContent = planContent; - } - - // CRITICAL: After approval, we need to make a second call to continue implementation - // The agent is waiting for "approved" - we need to send it and continue - console.log( - `[AutoMode] Making continuation call after plan approval for feature ${featureId}` - ); - - // Update planSpec status to approved (handles both manual and auto-approval paths) - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'approved', - approvedAt: new Date().toISOString(), - reviewedByUser: requiresApproval, - }); - - // ======================================== - // MULTI-AGENT TASK EXECUTION - // Each task gets its own focused agent call - // ======================================== - - if (parsedTasks.length > 0) { - console.log( - `[AutoMode] Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}` - ); - - // Execute each task with a separate agent - for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) { - const task = parsedTasks[taskIndex]; - - // Check for abort - if (abortController.signal.aborted) { - throw new Error('Feature execution aborted'); - } - - // Emit task started - console.log(`[AutoMode] Starting task ${task.id}: ${task.description}`); - this.emitAutoModeEvent('auto_mode_task_started', { - featureId, - projectPath, - taskId: task.id, - taskDescription: task.description, - taskIndex, - tasksTotal: parsedTasks.length, - }); - - // Update planSpec with current task - await this.updateFeaturePlanSpec(projectPath, featureId, { - currentTaskId: task.id, - }); - - // Build focused prompt for this specific task - const taskPrompt = this.buildTaskPrompt( - task, - parsedTasks, - taskIndex, - approvedPlanContent, - userFeedback - ); - - // Execute task with dedicated agent - const taskStream = provider.executeQuery({ - prompt: taskPrompt, - model: finalModel, - maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task - cwd: workDir, - allowedTools: allowedTools, + requiresApproval, + workDir, + finalModel, + maxTurns, + allowedTools, abortController, - }); - - let taskOutput = ''; - - // Process task stream - for await (const msg of taskStream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - taskOutput += block.text || ''; - responseText += block.text || ''; - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: block.text, - }); - } else if (block.type === 'tool_use') { - this.emitAutoModeEvent('auto_mode_tool', { - featureId, - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'error') { - throw new Error(msg.error || `Error during task ${task.id}`); - } else if (msg.type === 'result' && msg.subtype === 'success') { - taskOutput += msg.result || ''; - responseText += msg.result || ''; - } - } - - // Emit task completed - console.log(`[AutoMode] Task ${task.id} completed for feature ${featureId}`); - this.emitAutoModeEvent('auto_mode_task_complete', { - featureId, - projectPath, - taskId: task.id, - tasksCompleted: taskIndex + 1, - tasksTotal: parsedTasks.length, - }); - - // Update planSpec with progress - await this.updateFeaturePlanSpec(projectPath, featureId, { - tasksCompleted: taskIndex + 1, - }); - - // Check for phase completion (group tasks by phase) - if (task.phase) { - const nextTask = parsedTasks[taskIndex + 1]; - if (!nextTask || nextTask.phase !== task.phase) { - // Phase changed, emit phase complete - const phaseMatch = task.phase.match(/Phase\s*(\d+)/i); - if (phaseMatch) { - this.emitAutoModeEvent('auto_mode_phase_complete', { - featureId, - projectPath, - phaseNumber: parseInt(phaseMatch[1], 10), - }); - } - } - } - } - - console.log( - `[AutoMode] All ${parsedTasks.length} tasks completed for feature ${featureId}` - ); - } else { - // No parsed tasks - fall back to single-agent execution - console.log( - `[AutoMode] No parsed tasks, using single-agent execution for feature ${featureId}` - ); - - const continuationPrompt = `The plan/specification has been approved. Now implement it. -${userFeedback ? `\n## User Feedback\n${userFeedback}\n` : ''} -## Approved Plan - -${approvedPlanContent} - -## Instructions - -Implement all the changes described in the plan above.`; - - const continuationStream = provider.executeQuery({ - prompt: continuationPrompt, - model: finalModel, - maxTurns: maxTurns, - cwd: workDir, - allowedTools: allowedTools, - abortController, - }); - - for await (const msg of continuationStream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text || ''; - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: block.text, - }); - } else if (block.type === 'tool_use') { - this.emitAutoModeEvent('auto_mode_tool', { - featureId, - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'error') { - throw new Error(msg.error || 'Unknown error during implementation'); - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText += msg.result || ''; - } + provider, + outputWriter + ); + return; } } - - console.log(`[AutoMode] Implementation completed for feature ${featureId}`); - // Exit the original stream loop since continuation is done - break streamLoop; + if (!specDetected) + this.emitEvent('auto_mode_progress', { featureId, content: block.text }); + } else if (block.type === 'tool_use' && block.name) { + this.emitEvent('auto_mode_tool', { featureId, tool: block.name, input: block.input }); + outputWriter.appendToolUse(block.name, block.input); } - - // Only emit progress for non-marker text (marker was already handled above) - if (!specDetected) { - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: block.text, - }); - } - } else if (block.type === 'tool_use') { - // Emit event for real-time UI - this.emitAutoModeEvent('auto_mode_tool', { - featureId, - tool: block.name, - input: block.input, - }); - - // Also add to file output for persistence - if (responseText.length > 0 && !responseText.endsWith('\n')) { - responseText += '\n'; - } - responseText += `\n🔧 Tool: ${block.name}\n`; - if (block.input) { - responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; - } - scheduleWrite(); } + } else if (msg.type === 'error') { + throw new Error(msg.error || 'Unknown error'); } - } else if (msg.type === 'error') { - // Handle error messages - throw new Error(msg.error || 'Unknown error'); - } else if (msg.type === 'result' && msg.subtype === 'success') { - // Don't replace responseText - the accumulated content is the full history - // The msg.result is just a summary which would lose all tool use details - // Just ensure final write happens - scheduleWrite(); } + } finally { + await outputWriter.flush(); } - - // Clear any pending timeout and do a final write to ensure all content is saved - if (writeTimeout) { - clearTimeout(writeTimeout); - } - // Final write - ensure all accumulated content is saved - await writeToFile(); } - private async executeFeatureWithContext( - projectPath: string, + private async handleSpecGenerated( featureId: string, - context: string, - useWorktrees: boolean - ): Promise { - const feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } - - const prompt = `## Continuing Feature Implementation - -${this.buildFeaturePrompt(feature)} - -## Previous Context -The following is the output from a previous implementation attempt. Continue from where you left off: - -${context} - -## Instructions -Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`; - - return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { - continuationPrompt: prompt, - }); - } - - /** - * Build a focused prompt for executing a single task. - * Each task gets minimal context to keep the agent focused. - */ - private buildTaskPrompt( - task: ParsedTask, - allTasks: ParsedTask[], - taskIndex: number, + projectPath: string, planContent: string, - userFeedback?: string - ): string { - const completedTasks = allTasks.slice(0, taskIndex); - const remainingTasks = allTasks.slice(taskIndex + 1); + planningMode: PlanningMode, + requiresApproval: boolean, + workDir: string, + model: string, + maxTurns: number, + allowedTools: string[] | undefined, + abortController: AbortController, + provider: ReturnType, + outputWriter: ReturnType + ): Promise { + let parsedTasks = parseTasksFromSpec(planContent); - let prompt = `# Task Execution: ${task.id} - -You are executing a specific task as part of a larger feature implementation. - -## Your Current Task - -**Task ID:** ${task.id} -**Description:** ${task.description} -${task.filePath ? `**Primary File:** ${task.filePath}` : ''} -${task.phase ? `**Phase:** ${task.phase}` : ''} - -## Context - -`; - - // Show what's already done - if (completedTasks.length > 0) { - prompt += `### Already Completed (${completedTasks.length} tasks) -${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')} - -`; - } - - // Show remaining tasks - if (remainingTasks.length > 0) { - prompt += `### Coming Up Next (${remainingTasks.length} tasks remaining) -${remainingTasks - .slice(0, 3) - .map((t) => `- [ ] ${t.id}: ${t.description}`) - .join('\n')} -${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ''} - -`; - } - - // Add user feedback if any - if (userFeedback) { - prompt += `### User Feedback -${userFeedback} - -`; - } - - // Add relevant excerpt from plan (just the task-related part to save context) - prompt += `### Reference: Full Plan -
-${planContent} -
- -## Instructions - -1. Focus ONLY on completing task ${task.id}: "${task.description}" -2. Do not work on other tasks -3. Use the existing codebase patterns -4. When done, summarize what you implemented - -Begin implementing task ${task.id} now.`; - - return prompt; - } - - /** - * Emit an auto-mode event wrapped in the correct format for the client. - * All auto-mode events are sent as type "auto-mode:event" with the actual - * event type and data in the payload. - */ - private emitAutoModeEvent(eventType: string, data: Record): void { - // Wrap the event in auto-mode:event format expected by the client - this.events.emit('auto-mode:event', { - type: eventType, - ...data, + await this.featureLoader.updatePlanSpec(projectPath, featureId, { + status: 'generated', + content: planContent, + version: 1, + generatedAt: new Date().toISOString(), + reviewedByUser: false, + tasks: parsedTasks, + tasksTotal: parsedTasks.length, + tasksCompleted: 0, }); - } - private sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, ms); + let approvedPlanContent = planContent; + let userFeedback: string | undefined; - // If signal is provided and already aborted, reject immediately - if (signal?.aborted) { - clearTimeout(timeout); - reject(new Error('Aborted')); - return; + if (requiresApproval) { + this.planApproval.emitApprovalRequired(featureId, projectPath, planContent, planningMode, 1); + const approvalResult = await this.planApproval.waitForApproval(featureId, projectPath); + + if (!approvalResult.approved) { + if (!approvalResult.feedback?.trim()) throw new Error('Plan cancelled by user'); + throw new Error('Plan revision not yet implemented in refactored version'); } - // Listen for abort signal - if (signal) { - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeout); - reject(new Error('Aborted')); - }, - { once: true } - ); - } + approvedPlanContent = approvalResult.editedPlan || planContent; + userFeedback = approvalResult.feedback; + if (approvalResult.editedPlan) parsedTasks = parseTasksFromSpec(approvalResult.editedPlan); + this.planApproval.emitApproved(featureId, projectPath, !!approvalResult.editedPlan, 1); + } else { + this.planApproval.emitAutoApproved(featureId, projectPath, planContent, planningMode); + } + + await this.featureLoader.updatePlanSpec(projectPath, featureId, { + status: 'approved', + approvedAt: new Date().toISOString(), + reviewedByUser: requiresApproval, }); + + if (parsedTasks.length > 0) { + const context: TaskExecutionContext = { + workDir, + featureId, + projectPath, + model, + maxTurns, + allowedTools, + abortController, + planContent: approvedPlanContent, + userFeedback, + }; + for await (const progress of this.taskExecutor.executeAll(parsedTasks, context, provider)) { + await this.featureLoader.updatePlanSpec(projectPath, featureId, { + tasksCompleted: progress.taskIndex + (progress.status === 'completed' ? 1 : 0), + currentTaskId: progress.taskId, + }); + } + } else { + const continuationPrompt = `The plan has been approved. Implement it:\n\n${approvedPlanContent}`; + const continuationStream = provider.executeQuery({ + prompt: continuationPrompt, + model, + maxTurns, + cwd: workDir, + allowedTools, + abortController, + }); + await processStream(continuationStream, { + onText: (text) => { + outputWriter.append(text); + this.emitEvent('auto_mode_progress', { featureId, content: text }); + }, + onToolUse: (name, input) => { + this.emitEvent('auto_mode_tool', { featureId, tool: name, input }); + }, + }); + } + + await outputWriter.flush(); + } + + private async runMockAgent( + workDir: string, + featureId: string, + projectPath: string + ): Promise { + logger.info(`MOCK MODE: Skipping real agent for ${featureId}`); + await sleep(500); + this.emitEvent('auto_mode_progress', { featureId, content: 'Mock agent: Analyzing...' }); + await sleep(300); + this.emitEvent('auto_mode_progress', { featureId, content: 'Mock agent: Implementing...' }); + await sleep(300); + await secureFs.writeFile(path.join(workDir, 'yellow.txt'), 'yellow'); + + const featureDir = getFeatureDir(projectPath, featureId); + const outputPath = path.join(featureDir, 'agent-output.md'); + await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); + await secureFs.writeFile(outputPath, '# Mock Agent Output\n\nThis is a mock response.'); + logger.info(`MOCK MODE: Completed for ${featureId}`); + } + + private emitEvent(eventType: string, data: Record): void { + this.events.emit('auto-mode:event', { type: eventType, ...data }); } } diff --git a/apps/server/src/services/auto-mode/feature-verification.ts b/apps/server/src/services/auto-mode/feature-verification.ts new file mode 100644 index 00000000..6158671c --- /dev/null +++ b/apps/server/src/services/auto-mode/feature-verification.ts @@ -0,0 +1,163 @@ +/** + * Feature Verification Service - Handles verification and commit operations + * + * Provides functionality to verify feature implementations (lint, typecheck, test, build) + * and commit changes to git. + */ + +import { createLogger } from '@automaker/utils'; +import { + runVerificationChecks, + hasUncommittedChanges, + commitAll, + shortHash, +} from '@automaker/git-utils'; +import { extractTitleFromDescription } from '@automaker/prompts'; +import { getFeatureDir } from '@automaker/platform'; +import * as secureFs from '../../lib/secure-fs.js'; +import path from 'path'; +import type { EventEmitter } from '../../lib/events.js'; +import type { Feature } from '@automaker/types'; + +const logger = createLogger('FeatureVerification'); + +export interface VerificationResult { + success: boolean; + failedCheck?: string; +} + +export interface CommitResult { + hash: string | null; + shortHash?: string; +} + +export class FeatureVerificationService { + private events: EventEmitter; + + constructor(events: EventEmitter) { + this.events = events; + } + + /** + * Resolve the working directory for a feature (checks for worktree) + */ + async resolveWorkDir(projectPath: string, featureId: string): Promise { + const worktreePath = path.join(projectPath, '.worktrees', featureId); + + try { + await secureFs.access(worktreePath); + return worktreePath; + } catch { + return projectPath; + } + } + + /** + * Verify a feature's implementation by running checks + */ + async verify(projectPath: string, featureId: string): Promise { + const workDir = await this.resolveWorkDir(projectPath, featureId); + + const result = await runVerificationChecks(workDir); + + if (result.success) { + this.emitEvent('auto_mode_feature_complete', { + featureId, + passes: true, + message: 'All verification checks passed', + }); + } else { + this.emitEvent('auto_mode_feature_complete', { + featureId, + passes: false, + message: `Verification failed: ${result.failedCheck}`, + }); + } + + return result; + } + + /** + * Commit feature changes + */ + async commit( + projectPath: string, + featureId: string, + feature: Feature | null, + providedWorktreePath?: string + ): Promise { + let workDir = projectPath; + + if (providedWorktreePath) { + try { + await secureFs.access(providedWorktreePath); + workDir = providedWorktreePath; + } catch { + // Use project path + } + } else { + workDir = await this.resolveWorkDir(projectPath, featureId); + } + + // Check for changes + const hasChanges = await hasUncommittedChanges(workDir); + if (!hasChanges) { + return { hash: null }; + } + + // Build commit message + const title = feature + ? extractTitleFromDescription(feature.description) + : `Feature ${featureId}`; + const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`; + + // Commit changes + const hash = await commitAll(workDir, commitMessage); + + if (hash) { + const short = shortHash(hash); + this.emitEvent('auto_mode_feature_complete', { + featureId, + passes: true, + message: `Changes committed: ${short}`, + }); + return { hash, shortHash: short }; + } + + logger.error(`Commit failed for ${featureId}`); + return { hash: null }; + } + + /** + * Check if context (agent-output.md) exists for a feature + */ + async contextExists(projectPath: string, featureId: string): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, 'agent-output.md'); + + try { + await secureFs.access(contextPath); + return true; + } catch { + return false; + } + } + + /** + * Load existing context for a feature + */ + async loadContext(projectPath: string, featureId: string): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, 'agent-output.md'); + + try { + return (await secureFs.readFile(contextPath, 'utf-8')) as string; + } catch { + return null; + } + } + + private emitEvent(eventType: string, data: Record): void { + this.events.emit('auto-mode:event', { type: eventType, ...data }); + } +} diff --git a/apps/server/src/services/auto-mode/index.ts b/apps/server/src/services/auto-mode/index.ts new file mode 100644 index 00000000..475b4a47 --- /dev/null +++ b/apps/server/src/services/auto-mode/index.ts @@ -0,0 +1,28 @@ +/** + * Auto Mode Services + * + * Re-exports all auto-mode related services and types. + */ + +// Services +export { PlanApprovalService } from './plan-approval-service.js'; +export { TaskExecutor } from './task-executor.js'; +export { WorktreeManager, worktreeManager } from './worktree-manager.js'; +export { OutputWriter, createFeatureOutputWriter } from './output-writer.js'; +export { ProjectAnalyzer } from './project-analyzer.js'; +export { FeatureVerificationService } from './feature-verification.js'; +export type { VerificationResult, CommitResult } from './feature-verification.js'; + +// Types +export type { + RunningFeature, + AutoLoopState, + AutoModeConfig, + PendingApproval, + ApprovalResult, + FeatureExecutionOptions, + RunAgentOptions, + FeatureWithPlanning, + TaskExecutionContext, + TaskProgress, +} from './types.js'; diff --git a/apps/server/src/services/auto-mode/output-writer.ts b/apps/server/src/services/auto-mode/output-writer.ts new file mode 100644 index 00000000..d010195c --- /dev/null +++ b/apps/server/src/services/auto-mode/output-writer.ts @@ -0,0 +1,154 @@ +/** + * Output Writer - Incremental file writing for agent output + * + * Handles debounced file writes to avoid excessive I/O during streaming. + * Used to persist agent output to agent-output.md in the feature directory. + */ + +import * as secureFs from '../../lib/secure-fs.js'; +import path from 'path'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('OutputWriter'); + +/** + * Handles incremental, debounced file writing for agent output + */ +export class OutputWriter { + private content = ''; + private writeTimeout: ReturnType | null = null; + private readonly debounceMs: number; + private readonly outputPath: string; + + /** + * Create a new output writer + * + * @param outputPath - Full path to the output file + * @param debounceMs - Debounce interval for writes (default: 500ms) + * @param initialContent - Optional initial content to start with + */ + constructor(outputPath: string, debounceMs = 500, initialContent = '') { + this.outputPath = outputPath; + this.debounceMs = debounceMs; + this.content = initialContent; + } + + /** + * Append text to the output + * + * Schedules a debounced write to the file. + */ + append(text: string): void { + this.content += text; + this.scheduleWrite(); + } + + /** + * Append text with automatic separator handling + * + * Ensures proper spacing between sections. + */ + appendWithSeparator(text: string): void { + if (this.content.length > 0 && !this.content.endsWith('\n\n')) { + if (this.content.endsWith('\n')) { + this.content += '\n'; + } else { + this.content += '\n\n'; + } + } + this.append(text); + } + + /** + * Append a tool use entry + */ + appendToolUse(toolName: string, input?: unknown): void { + if (this.content.length > 0 && !this.content.endsWith('\n')) { + this.content += '\n'; + } + this.content += `\n🔧 Tool: ${toolName}\n`; + if (input) { + this.content += `Input: ${JSON.stringify(input, null, 2)}\n`; + } + this.scheduleWrite(); + } + + /** + * Get the current accumulated content + */ + getContent(): string { + return this.content; + } + + /** + * Set content directly (for follow-up sessions with previous content) + */ + setContent(content: string): void { + this.content = content; + } + + /** + * Schedule a debounced write + */ + private scheduleWrite(): void { + if (this.writeTimeout) { + clearTimeout(this.writeTimeout); + } + this.writeTimeout = setTimeout(() => { + this.flush().catch((error) => { + logger.error('Failed to flush output', error); + }); + }, this.debounceMs); + } + + /** + * Flush content to disk immediately + * + * Call this to ensure all content is written, e.g., at the end of execution. + */ + async flush(): Promise { + if (this.writeTimeout) { + clearTimeout(this.writeTimeout); + this.writeTimeout = null; + } + + try { + await secureFs.mkdir(path.dirname(this.outputPath), { recursive: true }); + await secureFs.writeFile(this.outputPath, this.content); + } catch (error) { + logger.error(`Failed to write to ${this.outputPath}`, error); + // Don't throw - file write errors shouldn't crash execution + } + } + + /** + * Cancel any pending writes + */ + cancel(): void { + if (this.writeTimeout) { + clearTimeout(this.writeTimeout); + this.writeTimeout = null; + } + } +} + +/** + * Create an output writer for a feature + * + * @param featureDir - The feature directory path + * @param previousContent - Optional content from previous session + * @returns Configured output writer + */ +export function createFeatureOutputWriter( + featureDir: string, + previousContent?: string +): OutputWriter { + const outputPath = path.join(featureDir, 'agent-output.md'); + + // If there's previous content, add a follow-up separator + const initialContent = previousContent + ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` + : ''; + + return new OutputWriter(outputPath, 500, initialContent); +} diff --git a/apps/server/src/services/auto-mode/plan-approval-service.ts b/apps/server/src/services/auto-mode/plan-approval-service.ts new file mode 100644 index 00000000..98d594dd --- /dev/null +++ b/apps/server/src/services/auto-mode/plan-approval-service.ts @@ -0,0 +1,236 @@ +/** + * Plan Approval Service - Handles plan/spec approval workflow + * + * Manages the async approval flow where: + * 1. Agent generates a spec with [SPEC_GENERATED] marker + * 2. Service emits plan_approval_required event + * 3. User reviews and approves/rejects via API + * 4. Service resolves the waiting promise to continue execution + */ + +import type { EventEmitter } from '../../lib/events.js'; +import type { PlanSpec, PlanningMode } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; +import type { PendingApproval, ApprovalResult } from './types.js'; + +const logger = createLogger('PlanApprovalService'); + +/** + * Manages plan approval workflow for spec-driven development + */ +export class PlanApprovalService { + private pendingApprovals = new Map(); + private events: EventEmitter; + + constructor(events: EventEmitter) { + this.events = events; + } + + /** + * Wait for plan approval from the user + * + * Returns a promise that resolves when the user approves or rejects + * the plan via the API. + * + * @param featureId - The feature awaiting approval + * @param projectPath - The project path + * @returns Promise resolving to approval result + */ + waitForApproval(featureId: string, projectPath: string): Promise { + logger.debug(`Registering pending approval for feature ${featureId}`); + logger.debug( + `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` + ); + + return new Promise((resolve, reject) => { + this.pendingApprovals.set(featureId, { + resolve, + reject, + featureId, + projectPath, + }); + logger.debug(`Pending approval registered for feature ${featureId}`); + }); + } + + /** + * Resolve a pending plan approval + * + * Called when the user approves or rejects the plan via API. + * + * @param featureId - The feature ID + * @param approved - Whether the plan was approved + * @param editedPlan - Optional edited plan content + * @param feedback - Optional user feedback + * @returns Result indicating success or error + */ + resolve( + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string + ): { success: boolean; error?: string; projectPath?: string } { + logger.debug(`resolvePlanApproval called for feature ${featureId}, approved=${approved}`); + logger.debug( + `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` + ); + + const pending = this.pendingApprovals.get(featureId); + + if (!pending) { + logger.warn(`No pending approval found for feature ${featureId}`); + return { + success: false, + error: `No pending approval for feature ${featureId}`, + }; + } + + logger.debug(`Found pending approval for feature ${featureId}, resolving...`); + + // Resolve the promise with all data including feedback + pending.resolve({ approved, editedPlan, feedback }); + this.pendingApprovals.delete(featureId); + + return { success: true, projectPath: pending.projectPath }; + } + + /** + * Cancel a pending plan approval + * + * Called when a feature is stopped while waiting for approval. + * + * @param featureId - The feature ID to cancel + */ + cancel(featureId: string): void { + logger.debug(`cancelPlanApproval called for feature ${featureId}`); + const pending = this.pendingApprovals.get(featureId); + + if (pending) { + logger.debug(`Found and cancelling pending approval for feature ${featureId}`); + pending.reject(new Error('Plan approval cancelled - feature was stopped')); + this.pendingApprovals.delete(featureId); + } else { + logger.debug(`No pending approval to cancel for feature ${featureId}`); + } + } + + /** + * Check if a feature has a pending plan approval + * + * @param featureId - The feature ID to check + * @returns True if there's a pending approval + */ + hasPending(featureId: string): boolean { + return this.pendingApprovals.has(featureId); + } + + /** + * Get the project path for a pending approval + * + * Useful for recovery scenarios where we need to know which + * project a pending approval belongs to. + * + * @param featureId - The feature ID + * @returns The project path or undefined + */ + getProjectPath(featureId: string): string | undefined { + return this.pendingApprovals.get(featureId)?.projectPath; + } + + /** + * Get all pending approval feature IDs + * + * @returns Array of feature IDs with pending approvals + */ + getAllPending(): string[] { + return Array.from(this.pendingApprovals.keys()); + } + + /** + * Emit a plan-related event + */ + emitPlanEvent( + eventType: string, + featureId: string, + projectPath: string, + data: Record = {} + ): void { + this.events.emit('auto-mode:event', { + type: eventType, + featureId, + projectPath, + ...data, + }); + } + + /** + * Emit plan approval required event + */ + emitApprovalRequired( + featureId: string, + projectPath: string, + planContent: string, + planningMode: PlanningMode, + planVersion: number + ): void { + this.emitPlanEvent('plan_approval_required', featureId, projectPath, { + planContent, + planningMode, + planVersion, + }); + } + + /** + * Emit plan approved event + */ + emitApproved( + featureId: string, + projectPath: string, + hasEdits: boolean, + planVersion: number + ): void { + this.emitPlanEvent('plan_approved', featureId, projectPath, { + hasEdits, + planVersion, + }); + } + + /** + * Emit plan rejected event + */ + emitRejected(featureId: string, projectPath: string, feedback?: string): void { + this.emitPlanEvent('plan_rejected', featureId, projectPath, { feedback }); + } + + /** + * Emit plan auto-approved event + */ + emitAutoApproved( + featureId: string, + projectPath: string, + planContent: string, + planningMode: PlanningMode + ): void { + this.emitPlanEvent('plan_auto_approved', featureId, projectPath, { + planContent, + planningMode, + }); + } + + /** + * Emit plan revision requested event + */ + emitRevisionRequested( + featureId: string, + projectPath: string, + feedback: string | undefined, + hasEdits: boolean, + planVersion: number + ): void { + this.emitPlanEvent('plan_revision_requested', featureId, projectPath, { + feedback, + hasEdits, + planVersion, + }); + } +} diff --git a/apps/server/src/services/auto-mode/project-analyzer.ts b/apps/server/src/services/auto-mode/project-analyzer.ts new file mode 100644 index 00000000..2ed68585 --- /dev/null +++ b/apps/server/src/services/auto-mode/project-analyzer.ts @@ -0,0 +1,111 @@ +/** + * Project Analyzer - Analyzes project structure and context + * + * Provides project analysis functionality using Claude to understand + * codebase architecture, patterns, and conventions. + */ + +import type { ExecuteOptions } from '@automaker/types'; +import { createLogger, classifyError } from '@automaker/utils'; +import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import { getAutomakerDir } from '@automaker/platform'; +import { ProviderFactory } from '../../providers/provider-factory.js'; +import { validateWorkingDirectory } from '../../lib/sdk-options.js'; +import { processStream } from '../../lib/stream-processor.js'; +import * as secureFs from '../../lib/secure-fs.js'; +import path from 'path'; +import type { EventEmitter } from '../../lib/events.js'; + +const logger = createLogger('ProjectAnalyzer'); + +const ANALYSIS_PROMPT = `Analyze this project and provide a summary of: +1. Project structure and architecture +2. Main technologies and frameworks used +3. Key components and their responsibilities +4. Build and test commands +5. Any existing conventions or patterns + +Format your response as a structured markdown document.`; + +export class ProjectAnalyzer { + private events: EventEmitter; + + constructor(events: EventEmitter) { + this.events = events; + } + + /** + * Analyze project to gather context + */ + async analyze(projectPath: string): Promise { + validateWorkingDirectory(projectPath); + + const abortController = new AbortController(); + const analysisFeatureId = `analysis-${Date.now()}`; + + this.emitEvent('auto_mode_feature_start', { + featureId: analysisFeatureId, + projectPath, + feature: { + id: analysisFeatureId, + title: 'Project Analysis', + description: 'Analyzing project structure', + }, + }); + + try { + const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); + const provider = ProviderFactory.getProviderForModel(analysisModel); + + const options: ExecuteOptions = { + prompt: ANALYSIS_PROMPT, + model: analysisModel, + maxTurns: 5, + cwd: projectPath, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + }; + + const stream = provider.executeQuery(options); + let analysisResult = ''; + + const result = await processStream(stream, { + onText: (text) => { + analysisResult += text; + this.emitEvent('auto_mode_progress', { + featureId: analysisFeatureId, + content: text, + projectPath, + }); + }, + }); + + analysisResult = result.text || analysisResult; + + // Save analysis + const automakerDir = getAutomakerDir(projectPath); + const analysisPath = path.join(automakerDir, 'project-analysis.md'); + await secureFs.mkdir(automakerDir, { recursive: true }); + await secureFs.writeFile(analysisPath, analysisResult); + + this.emitEvent('auto_mode_feature_complete', { + featureId: analysisFeatureId, + passes: true, + message: 'Project analysis completed', + projectPath, + }); + } catch (error) { + const errorInfo = classifyError(error); + this.emitEvent('auto_mode_error', { + featureId: analysisFeatureId, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + } + } + + private emitEvent(eventType: string, data: Record): void { + this.events.emit('auto-mode:event', { type: eventType, ...data }); + } +} diff --git a/apps/server/src/services/auto-mode/task-executor.ts b/apps/server/src/services/auto-mode/task-executor.ts new file mode 100644 index 00000000..d2155cd2 --- /dev/null +++ b/apps/server/src/services/auto-mode/task-executor.ts @@ -0,0 +1,268 @@ +/** + * Task Executor - Multi-agent task execution for spec-driven development + * + * Handles the sequential execution of parsed tasks from a spec, + * where each task gets its own focused agent call. + */ + +import type { ExecuteOptions, ParsedTask } from '@automaker/types'; +import type { EventEmitter } from '../../lib/events.js'; +import type { BaseProvider } from '../../providers/base-provider.js'; +import { buildTaskPrompt } from '@automaker/prompts'; +import { createLogger } from '@automaker/utils'; +import { processStream } from '../../lib/stream-processor.js'; +import type { TaskExecutionContext, TaskProgress } from './types.js'; + +const logger = createLogger('TaskExecutor'); + +/** + * Handles multi-agent task execution for spec-driven development + */ +export class TaskExecutor { + private events: EventEmitter; + + constructor(events: EventEmitter) { + this.events = events; + } + + /** + * Execute all tasks sequentially + * + * Each task gets its own focused agent call with context about + * completed and remaining tasks. + * + * @param tasks - Parsed tasks from the spec + * @param context - Execution context including provider, model, etc. + * @param provider - The provider to use for execution + * @yields TaskProgress events for each task + */ + async *executeAll( + tasks: ParsedTask[], + context: TaskExecutionContext, + provider: BaseProvider + ): AsyncGenerator { + logger.info( + `Starting multi-agent execution: ${tasks.length} tasks for feature ${context.featureId}` + ); + + for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { + const task = tasks[taskIndex]; + + // Check for abort + if (context.abortController.signal.aborted) { + throw new Error('Feature execution aborted'); + } + + // Emit task started + logger.info(`Starting task ${task.id}: ${task.description}`); + this.emitTaskEvent('auto_mode_task_started', context, { + taskId: task.id, + taskDescription: task.description, + taskIndex, + tasksTotal: tasks.length, + }); + + yield { + taskId: task.id, + taskIndex, + tasksTotal: tasks.length, + status: 'started', + }; + + // Build focused prompt for this task + const taskPrompt = buildTaskPrompt( + task, + tasks, + taskIndex, + context.planContent, + context.userFeedback + ); + + // Execute task with dedicated agent call + const taskOptions: ExecuteOptions = { + prompt: taskPrompt, + model: context.model, + maxTurns: Math.min(context.maxTurns, 50), // Limit turns per task + cwd: context.workDir, + allowedTools: context.allowedTools, + abortController: context.abortController, + }; + + const taskStream = provider.executeQuery(taskOptions); + + // Process task stream + let taskOutput = ''; + try { + const result = await processStream(taskStream, { + onText: (text) => { + taskOutput += text; + this.emitProgressEvent(context.featureId, text); + }, + onToolUse: (name, input) => { + this.emitToolEvent(context.featureId, name, input); + }, + }); + taskOutput = result.text; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Task ${task.id} failed: ${errorMessage}`); + yield { + taskId: task.id, + taskIndex, + tasksTotal: tasks.length, + status: 'failed', + output: errorMessage, + }; + throw error; + } + + // Emit task completed + logger.info(`Task ${task.id} completed for feature ${context.featureId}`); + this.emitTaskEvent('auto_mode_task_complete', context, { + taskId: task.id, + tasksCompleted: taskIndex + 1, + tasksTotal: tasks.length, + }); + + // Check for phase completion + const phaseComplete = this.checkPhaseComplete(task, tasks, taskIndex); + + yield { + taskId: task.id, + taskIndex, + tasksTotal: tasks.length, + status: 'completed', + output: taskOutput, + phaseComplete, + }; + + // Emit phase complete if needed + if (phaseComplete !== undefined) { + this.emitPhaseComplete(context, phaseComplete); + } + } + + logger.info(`All ${tasks.length} tasks completed for feature ${context.featureId}`); + } + + /** + * Execute a single task (for cases where you don't need the full loop) + * + * @param task - The task to execute + * @param allTasks - All tasks for context + * @param taskIndex - Index of this task + * @param context - Execution context + * @param provider - The provider to use + * @returns Task output text + */ + async executeOne( + task: ParsedTask, + allTasks: ParsedTask[], + taskIndex: number, + context: TaskExecutionContext, + provider: BaseProvider + ): Promise { + const taskPrompt = buildTaskPrompt( + task, + allTasks, + taskIndex, + context.planContent, + context.userFeedback + ); + + const taskOptions: ExecuteOptions = { + prompt: taskPrompt, + model: context.model, + maxTurns: Math.min(context.maxTurns, 50), + cwd: context.workDir, + allowedTools: context.allowedTools, + abortController: context.abortController, + }; + + const taskStream = provider.executeQuery(taskOptions); + + const result = await processStream(taskStream, { + onText: (text) => { + this.emitProgressEvent(context.featureId, text); + }, + onToolUse: (name, input) => { + this.emitToolEvent(context.featureId, name, input); + }, + }); + + return result.text; + } + + /** + * Check if completing this task completes a phase + */ + private checkPhaseComplete( + task: ParsedTask, + allTasks: ParsedTask[], + taskIndex: number + ): number | undefined { + if (!task.phase) { + return undefined; + } + + const nextTask = allTasks[taskIndex + 1]; + if (!nextTask || nextTask.phase !== task.phase) { + // Phase changed or no more tasks + const phaseMatch = task.phase.match(/Phase\s*(\d+)/i); + return phaseMatch ? parseInt(phaseMatch[1], 10) : undefined; + } + + return undefined; + } + + /** + * Emit a task-related event + */ + private emitTaskEvent( + eventType: string, + context: TaskExecutionContext, + data: Record + ): void { + this.events.emit('auto-mode:event', { + type: eventType, + featureId: context.featureId, + projectPath: context.projectPath, + ...data, + }); + } + + /** + * Emit progress event for text output + */ + private emitProgressEvent(featureId: string, content: string): void { + this.events.emit('auto-mode:event', { + type: 'auto_mode_progress', + featureId, + content, + }); + } + + /** + * Emit tool use event + */ + private emitToolEvent(featureId: string, tool: string, input: unknown): void { + this.events.emit('auto-mode:event', { + type: 'auto_mode_tool', + featureId, + tool, + input, + }); + } + + /** + * Emit phase complete event + */ + private emitPhaseComplete(context: TaskExecutionContext, phaseNumber: number): void { + this.events.emit('auto-mode:event', { + type: 'auto_mode_phase_complete', + featureId: context.featureId, + projectPath: context.projectPath, + phaseNumber, + }); + } +} diff --git a/apps/server/src/services/auto-mode/types.ts b/apps/server/src/services/auto-mode/types.ts new file mode 100644 index 00000000..94a50467 --- /dev/null +++ b/apps/server/src/services/auto-mode/types.ts @@ -0,0 +1,121 @@ +/** + * Internal types for AutoModeService + * + * These types are used internally by the auto-mode services + * and are not exported to the public API. + */ + +import type { PlanningMode, PlanSpec } from '@automaker/types'; + +/** + * Running feature state + */ +export interface RunningFeature { + featureId: string; + projectPath: string; + worktreePath: string | null; + branchName: string | null; + abortController: AbortController; + isAutoMode: boolean; + startTime: number; +} + +/** + * Auto-loop configuration + */ +export interface AutoLoopState { + projectPath: string; + maxConcurrency: number; + abortController: AbortController; + isRunning: boolean; +} + +/** + * Auto-mode configuration + */ +export interface AutoModeConfig { + maxConcurrency: number; + useWorktrees: boolean; + projectPath: string; +} + +/** + * Pending plan approval state + */ +export interface PendingApproval { + resolve: (result: ApprovalResult) => void; + reject: (error: Error) => void; + featureId: string; + projectPath: string; +} + +/** + * Result of plan approval + */ +export interface ApprovalResult { + approved: boolean; + editedPlan?: string; + feedback?: string; +} + +/** + * Options for executing a feature + */ +export interface FeatureExecutionOptions { + continuationPrompt?: string; +} + +/** + * Options for running the agent + */ +export interface RunAgentOptions { + projectPath: string; + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + systemPrompt?: string; +} + +/** + * Feature with planning fields for internal use + */ +export interface FeatureWithPlanning { + id: string; + description: string; + spec?: string; + model?: string; + imagePaths?: Array; + branchName?: string; + skipTests?: boolean; + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + planSpec?: PlanSpec; + [key: string]: unknown; +} + +/** + * Task execution context + */ +export interface TaskExecutionContext { + workDir: string; + featureId: string; + projectPath: string; + model: string; + maxTurns: number; + allowedTools?: string[]; + abortController: AbortController; + planContent: string; + userFeedback?: string; +} + +/** + * Task progress event + */ +export interface TaskProgress { + taskId: string; + taskIndex: number; + tasksTotal: number; + status: 'started' | 'completed' | 'failed'; + output?: string; + phaseComplete?: number; +} diff --git a/apps/server/src/services/auto-mode/worktree-manager.ts b/apps/server/src/services/auto-mode/worktree-manager.ts new file mode 100644 index 00000000..e2f13d14 --- /dev/null +++ b/apps/server/src/services/auto-mode/worktree-manager.ts @@ -0,0 +1,157 @@ +/** + * Worktree Manager - Git worktree operations for feature isolation + * + * Handles finding and resolving git worktrees for feature branches. + * Worktrees are created when features are added/edited, this service + * finds existing worktrees for execution. + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { createLogger } from '@automaker/utils'; + +const execAsync = promisify(exec); +const logger = createLogger('WorktreeManager'); + +/** + * Result of resolving a working directory + */ +export interface WorkDirResult { + /** The resolved working directory path */ + workDir: string; + /** The worktree path if using a worktree, null otherwise */ + worktreePath: string | null; +} + +/** + * Manages git worktree operations for feature isolation + */ +export class WorktreeManager { + /** + * Find existing worktree path for a branch + * + * Parses `git worktree list --porcelain` output to find the worktree + * associated with a specific branch. + * + * @param projectPath - The main project path + * @param branchName - The branch to find a worktree for + * @returns The absolute path to the worktree, or null if not found + */ + async findWorktreeForBranch(projectPath: string, branchName: string): Promise { + try { + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + + const lines = stdout.split('\n'); + let currentPath: string | null = null; + let currentBranch: string | null = null; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentPath = line.slice(9); + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line === '' && currentPath && currentBranch) { + // End of a worktree entry + if (currentBranch === branchName) { + // Resolve to absolute path - git may return relative paths + // On Windows, this is critical for cwd to work correctly + const resolvedPath = path.isAbsolute(currentPath) + ? path.resolve(currentPath) + : path.resolve(projectPath, currentPath); + return resolvedPath; + } + currentPath = null; + currentBranch = null; + } + } + + // Check the last entry (if file doesn't end with newline) + if (currentPath && currentBranch && currentBranch === branchName) { + const resolvedPath = path.isAbsolute(currentPath) + ? path.resolve(currentPath) + : path.resolve(projectPath, currentPath); + return resolvedPath; + } + + return null; + } catch (error) { + logger.warn(`Failed to find worktree for branch ${branchName}`, error); + return null; + } + } + + /** + * Resolve the working directory for feature execution + * + * If worktrees are enabled and a branch name is provided, attempts to + * find an existing worktree. Falls back to the project path if no + * worktree is found. + * + * @param projectPath - The main project path + * @param branchName - Optional branch name to look for + * @param useWorktrees - Whether to use worktrees + * @returns The resolved work directory and worktree path + */ + async resolveWorkDir( + projectPath: string, + branchName: string | undefined, + useWorktrees: boolean + ): Promise { + let worktreePath: string | null = null; + + if (useWorktrees && branchName) { + worktreePath = await this.findWorktreeForBranch(projectPath, branchName); + + if (worktreePath) { + logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); + } else { + logger.warn(`Worktree for branch "${branchName}" not found, using project path`); + } + } + + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + + return { workDir, worktreePath }; + } + + /** + * Check if a path is a valid worktree + * + * @param worktreePath - Path to check + * @returns True if the path is a valid git worktree + */ + async isValidWorktree(worktreePath: string): Promise { + try { + // Check if .git file exists (worktrees have a .git file, not directory) + const { stdout } = await execAsync('git rev-parse --is-inside-work-tree', { + cwd: worktreePath, + }); + return stdout.trim() === 'true'; + } catch { + return false; + } + } + + /** + * Get the branch name for a worktree + * + * @param worktreePath - Path to the worktree + * @returns The branch name or null if not a valid worktree + */ + async getWorktreeBranch(worktreePath: string): Promise { + try { + const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + return stdout.trim(); + } catch { + return null; + } + } +} + +// Export a singleton instance for convenience +export const worktreeManager = new WorktreeManager(); diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index fbf86d49..2896bd58 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -4,8 +4,9 @@ */ import path from 'path'; -import type { Feature } from '@automaker/types'; +import type { Feature, PlanSpec, FeatureStatus } from '@automaker/types'; import { createLogger } from '@automaker/utils'; +import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; import * as secureFs from '../lib/secure-fs.js'; import { getFeaturesDir, @@ -381,4 +382,115 @@ export class FeatureLoader { } } } + + /** + * Check if agent output exists for a feature + */ + async hasAgentOutput(projectPath: string, featureId: string): Promise { + try { + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + await secureFs.access(agentOutputPath); + return true; + } catch { + return false; + } + } + + /** + * Update feature status with proper timestamp handling + * Used by auto-mode to update feature status during execution + */ + async updateStatus( + projectPath: string, + featureId: string, + status: FeatureStatus + ): Promise { + try { + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; + const feature = JSON.parse(content) as Feature; + + feature.status = status; + feature.updatedAt = new Date().toISOString(); + + // Handle justFinishedAt for waiting_approval status + if (status === 'waiting_approval') { + feature.justFinishedAt = new Date().toISOString(); + } else { + feature.justFinishedAt = undefined; + } + + await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2)); + return feature; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`[FeatureLoader] Failed to update status for ${featureId}:`, error); + return null; + } + } + + /** + * Update feature plan specification + * Handles version incrementing and timestamp management + */ + async updatePlanSpec( + projectPath: string, + featureId: string, + updates: Partial + ): Promise { + try { + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; + const feature = JSON.parse(content) as Feature; + + // Initialize planSpec if not present + if (!feature.planSpec) { + feature.planSpec = { status: 'pending', version: 1, reviewedByUser: false }; + } + + // Increment version if content changed + if (updates.content && updates.content !== feature.planSpec.content) { + feature.planSpec.version = (feature.planSpec.version || 0) + 1; + } + + // Merge updates + Object.assign(feature.planSpec, updates); + feature.updatedAt = new Date().toISOString(); + + await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2)); + return feature; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`[FeatureLoader] Failed to update planSpec for ${featureId}:`, error); + return null; + } + } + + /** + * Get features that are pending and ready to execute + * Filters by status and resolves dependencies + */ + async getPending(projectPath: string): Promise { + try { + const allFeatures = await this.getAll(projectPath); + const pendingFeatures = allFeatures.filter((f) => + ['pending', 'ready', 'backlog'].includes(f.status) + ); + + // Resolve dependencies and order features + const { orderedFeatures } = resolveDependencies(pendingFeatures); + + // Filter to features whose dependencies are satisfied + return orderedFeatures.filter((feature: Feature) => + areDependenciesSatisfied(feature, allFeatures) + ); + } catch (error) { + logger.error('[FeatureLoader] Failed to get pending features:', error); + return []; + } + } } diff --git a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts index 7b52fe38..7a358181 100644 --- a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts +++ b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AutoModeService } from '@/services/auto-mode-service.js'; +import { + getPlanningPromptPrefix, + parseTasksFromSpec, + parseTaskLine, + buildFeaturePrompt, + extractTitleFromDescription, +} from '@automaker/prompts'; describe('auto-mode-service.ts - Planning Mode', () => { let service: AutoModeService; @@ -18,54 +25,28 @@ describe('auto-mode-service.ts - Planning Mode', () => { await service.stopAutoLoop().catch(() => {}); }); - describe('getPlanningPromptPrefix', () => { - // Access private method through any cast for testing - const getPlanningPromptPrefix = (svc: any, feature: any) => { - return svc.getPlanningPromptPrefix(feature); - }; - + describe('getPlanningPromptPrefix (from @automaker/prompts)', () => { it('should return empty string for skip mode', () => { - const feature = { id: 'test', planningMode: 'skip' as const }; - const result = getPlanningPromptPrefix(service, feature); - expect(result).toBe(''); - }); - - it('should return empty string when planningMode is undefined', () => { - const feature = { id: 'test' }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('skip'); expect(result).toBe(''); }); it('should return lite prompt for lite mode without approval', () => { - const feature = { - id: 'test', - planningMode: 'lite' as const, - requirePlanApproval: false, - }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('lite', false); expect(result).toContain('Planning Phase (Lite Mode)'); expect(result).toContain('[PLAN_GENERATED]'); expect(result).toContain('Feature Request'); }); it('should return lite_with_approval prompt for lite mode with approval', () => { - const feature = { - id: 'test', - planningMode: 'lite' as const, - requirePlanApproval: true, - }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('lite', true); expect(result).toContain('Planning Phase (Lite Mode)'); expect(result).toContain('[SPEC_GENERATED]'); expect(result).toContain('DO NOT proceed with implementation'); }); it('should return spec prompt for spec mode', () => { - const feature = { - id: 'test', - planningMode: 'spec' as const, - }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('spec'); expect(result).toContain('Specification Phase (Spec Mode)'); expect(result).toContain('```tasks'); expect(result).toContain('T001'); @@ -74,11 +55,7 @@ describe('auto-mode-service.ts - Planning Mode', () => { }); it('should return full prompt for full mode', () => { - const feature = { - id: 'test', - planningMode: 'full' as const, - }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('full'); expect(result).toContain('Full Specification Phase (Full SDD Mode)'); expect(result).toContain('Phase 1: Foundation'); expect(result).toContain('Phase 2: Core Implementation'); @@ -86,11 +63,7 @@ describe('auto-mode-service.ts - Planning Mode', () => { }); it('should include the separator and Feature Request header', () => { - const feature = { - id: 'test', - planningMode: 'spec' as const, - }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('spec'); expect(result).toContain('---'); expect(result).toContain('## Feature Request'); }); @@ -98,8 +71,7 @@ describe('auto-mode-service.ts - Planning Mode', () => { it('should instruct agent to NOT output exploration text', () => { const modes = ['lite', 'spec', 'full'] as const; for (const mode of modes) { - const feature = { id: 'test', planningMode: mode }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix(mode); expect(result).toContain('Do NOT output exploration text'); expect(result).toContain('Start DIRECTLY'); } @@ -198,17 +170,14 @@ describe('auto-mode-service.ts - Planning Mode', () => { }); }); - describe('buildFeaturePrompt', () => { - const buildFeaturePrompt = (svc: any, feature: any) => { - return svc.buildFeaturePrompt(feature); - }; - + describe('buildFeaturePrompt (from @automaker/prompts)', () => { it('should include feature ID and description', () => { const feature = { id: 'feat-123', + category: 'Test', description: 'Add user authentication', }; - const result = buildFeaturePrompt(service, feature); + const result = buildFeaturePrompt(feature); expect(result).toContain('feat-123'); expect(result).toContain('Add user authentication'); }); @@ -216,10 +185,11 @@ describe('auto-mode-service.ts - Planning Mode', () => { it('should include specification when present', () => { const feature = { id: 'feat-123', + category: 'Test', description: 'Test feature', spec: 'Detailed specification here', }; - const result = buildFeaturePrompt(service, feature); + const result = buildFeaturePrompt(feature); expect(result).toContain('Specification:'); expect(result).toContain('Detailed specification here'); }); @@ -227,13 +197,14 @@ describe('auto-mode-service.ts - Planning Mode', () => { it('should include image paths when present', () => { const feature = { id: 'feat-123', + category: 'Test', description: 'Test feature', imagePaths: [ { path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' }, '/tmp/image2.jpg', ], }; - const result = buildFeaturePrompt(service, feature); + const result = buildFeaturePrompt(feature); expect(result).toContain('Context Images Attached'); expect(result).toContain('image1.png'); expect(result).toContain('/tmp/image2.jpg'); @@ -242,55 +213,46 @@ describe('auto-mode-service.ts - Planning Mode', () => { it('should include summary tags instruction', () => { const feature = { id: 'feat-123', + category: 'Test', description: 'Test feature', }; - const result = buildFeaturePrompt(service, feature); + const result = buildFeaturePrompt(feature); expect(result).toContain(''); - expect(result).toContain(''); + expect(result).toContain('summary'); }); }); - describe('extractTitleFromDescription', () => { - const extractTitle = (svc: any, description: string) => { - return svc.extractTitleFromDescription(description); - }; - + describe('extractTitleFromDescription (from @automaker/prompts)', () => { it("should return 'Untitled Feature' for empty description", () => { - expect(extractTitle(service, '')).toBe('Untitled Feature'); - expect(extractTitle(service, ' ')).toBe('Untitled Feature'); + expect(extractTitleFromDescription('')).toBe('Untitled Feature'); + expect(extractTitleFromDescription(' ')).toBe('Untitled Feature'); }); it('should return first line if under 60 characters', () => { const description = 'Add user login\nWith email validation'; - expect(extractTitle(service, description)).toBe('Add user login'); + expect(extractTitleFromDescription(description)).toBe('Add user login'); }); it('should truncate long first lines to 60 characters', () => { const description = 'This is a very long feature description that exceeds the sixty character limit significantly'; - const result = extractTitle(service, description); + const result = extractTitleFromDescription(description); expect(result.length).toBe(60); expect(result).toContain('...'); }); }); - describe('PLANNING_PROMPTS structure', () => { - const getPlanningPromptPrefix = (svc: any, feature: any) => { - return svc.getPlanningPromptPrefix(feature); - }; - + describe('PLANNING_PROMPTS structure (from @automaker/prompts)', () => { it('should have all required planning modes', () => { const modes = ['lite', 'spec', 'full'] as const; for (const mode of modes) { - const feature = { id: 'test', planningMode: mode }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix(mode); expect(result.length).toBeGreaterThan(100); } }); it('lite prompt should include correct structure', () => { - const feature = { id: 'test', planningMode: 'lite' as const }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('lite'); expect(result).toContain('Goal'); expect(result).toContain('Approach'); expect(result).toContain('Files to Touch'); @@ -299,8 +261,7 @@ describe('auto-mode-service.ts - Planning Mode', () => { }); it('spec prompt should include task format instructions', () => { - const feature = { id: 'test', planningMode: 'spec' as const }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('spec'); expect(result).toContain('Problem'); expect(result).toContain('Solution'); expect(result).toContain('Acceptance Criteria'); @@ -310,8 +271,7 @@ describe('auto-mode-service.ts - Planning Mode', () => { }); it('full prompt should include phases', () => { - const feature = { id: 'test', planningMode: 'full' as const }; - const result = getPlanningPromptPrefix(service, feature); + const result = getPlanningPromptPrefix('full'); expect(result).toContain('Problem Statement'); expect(result).toContain('User Story'); expect(result).toContain('Technical Context'); diff --git a/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts b/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts index 984e38c5..40a6cbff 100644 --- a/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts +++ b/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts @@ -1,92 +1,5 @@ import { describe, it, expect } from 'vitest'; - -/** - * Test the task parsing logic by reimplementing the parsing functions - * These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine - */ - -interface ParsedTask { - id: string; - description: string; - filePath?: string; - phase?: string; - status: 'pending' | 'in_progress' | 'completed'; -} - -function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { - // Match pattern: - [ ] T###: Description | File: path - const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); - if (!taskMatch) { - // Try simpler pattern without file - const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); - if (simpleMatch) { - return { - id: simpleMatch[1], - description: simpleMatch[2].trim(), - phase: currentPhase, - status: 'pending', - }; - } - return null; - } - - return { - id: taskMatch[1], - description: taskMatch[2].trim(), - filePath: taskMatch[3]?.trim(), - phase: currentPhase, - status: 'pending', - }; -} - -function parseTasksFromSpec(specContent: string): ParsedTask[] { - const tasks: ParsedTask[] = []; - - // Extract content within ```tasks ... ``` block - const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); - if (!tasksBlockMatch) { - // Try fallback: look for task lines anywhere in content - const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); - if (!taskLines) { - return tasks; - } - // Parse fallback task lines - let currentPhase: string | undefined; - for (const line of taskLines) { - const parsed = parseTaskLine(line, currentPhase); - if (parsed) { - tasks.push(parsed); - } - } - return tasks; - } - - const tasksContent = tasksBlockMatch[1]; - const lines = tasksContent.split('\n'); - - let currentPhase: string | undefined; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Check for phase header (e.g., "## Phase 1: Foundation") - const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); - if (phaseMatch) { - currentPhase = phaseMatch[1].trim(); - continue; - } - - // Check for task line - if (trimmedLine.startsWith('- [ ]')) { - const parsed = parseTaskLine(trimmedLine, currentPhase); - if (parsed) { - tasks.push(parsed); - } - } - } - - return tasks; -} +import { parseTaskLine, parseTasksFromSpec } from '@automaker/prompts'; describe('Task Parsing', () => { describe('parseTaskLine', () => { diff --git a/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx b/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx index 66af8d13..786afbaa 100644 --- a/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx @@ -16,12 +16,10 @@ import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; -import type { PlanSpec } from '@/store/app-store'; - -export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; +import type { PlanningMode, PlanSpec, ParsedTask } from '@automaker/types'; // Re-export for backwards compatibility -export type { ParsedTask, PlanSpec } from '@/store/app-store'; +export type { PlanningMode, ParsedTask, PlanSpec }; interface PlanningModeSelectorProps { mode: PlanningMode; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 469ca9bd..0d9244b2 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -4,11 +4,25 @@ import type { Project, TrashedProject } from '@/lib/electron'; import type { Feature as BaseFeature, FeatureImagePath, + FeatureTextFilePath, AgentModel, PlanningMode, AIProfile, + ParsedTask, + PlanSpec, + ThinkingLevel, } from '@automaker/types'; +// Re-export types from @automaker/types for backwards compatibility +export type { + AgentModel, + PlanningMode, + AIProfile, + ThinkingLevel, + FeatureImagePath, + FeatureTextFilePath, +}; + // Re-export ThemeMode for convenience export type { ThemeMode }; @@ -269,28 +283,8 @@ export interface Feature extends Omit< prUrl?: string; // UI-specific: Pull request URL } -// Parsed task from spec (for spec and full planning modes) -export interface ParsedTask { - id: string; // e.g., "T001" - description: string; // e.g., "Create user model" - filePath?: string; // e.g., "src/models/user.ts" - phase?: string; // e.g., "Phase 1: Foundation" (for full mode) - status: 'pending' | 'in_progress' | 'completed' | 'failed'; -} - -// PlanSpec status for feature planning/specification -export interface PlanSpec { - status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; - content?: string; // The actual spec/plan markdown content - version: number; - generatedAt?: string; // ISO timestamp - approvedAt?: string; // ISO timestamp - reviewedByUser: boolean; // True if user has seen the spec - tasksCompleted?: number; - tasksTotal?: number; - currentTaskId?: string; // ID of the task currently being worked on - tasks?: ParsedTask[]; // Parsed tasks from the spec -} +// Re-export planning types for backwards compatibility with existing imports +export type { ParsedTask, PlanSpec }; // File tree node for project analysis export interface FileTreeNode { diff --git a/libs/git-utils/src/commit.ts b/libs/git-utils/src/commit.ts new file mode 100644 index 00000000..81c4b479 --- /dev/null +++ b/libs/git-utils/src/commit.ts @@ -0,0 +1,112 @@ +/** + * Git Commit Utilities - Commit operations for git repositories + * + * Provides utilities for staging and committing changes. + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +/** + * Check if there are uncommitted changes in the working directory + * + * @param workDir - The working directory to check + * @returns True if there are uncommitted changes + */ +export async function hasUncommittedChanges(workDir: string): Promise { + try { + const { stdout } = await execAsync('git status --porcelain', { cwd: workDir }); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +/** + * Stage all changes and commit with a message + * + * @param workDir - The working directory + * @param message - The commit message + * @returns The commit hash if successful, null otherwise + */ +export async function commitAll(workDir: string, message: string): Promise { + try { + // Check for changes first + const hasChanges = await hasUncommittedChanges(workDir); + if (!hasChanges) { + return null; + } + + // Stage all changes + await execAsync('git add -A', { cwd: workDir }); + + // Commit with message (escape double quotes) + const escapedMessage = message.replace(/"/g, '\\"'); + await execAsync(`git commit -m "${escapedMessage}"`, { cwd: workDir }); + + // Get the commit hash + const { stdout } = await execAsync('git rev-parse HEAD', { cwd: workDir }); + return stdout.trim(); + } catch { + return null; + } +} + +/** + * Get the current HEAD commit hash + * + * @param workDir - The working directory + * @returns The commit hash or null if not a git repo + */ +export async function getHeadHash(workDir: string): Promise { + try { + const { stdout } = await execAsync('git rev-parse HEAD', { cwd: workDir }); + return stdout.trim(); + } catch { + return null; + } +} + +/** + * Get the short version of a commit hash + * + * @param hash - The full commit hash + * @param length - Length of short hash (default 8) + * @returns The shortened hash + */ +export function shortHash(hash: string, length = 8): string { + return hash.substring(0, length); +} + +/** + * Run verification commands (lint, typecheck, test, build) + * + * @param workDir - The working directory + * @param checks - Optional custom checks (defaults to npm scripts) + * @returns Object with success status and failed check name if any + */ +export async function runVerificationChecks( + workDir: string, + checks?: Array<{ cmd: string; name: string }> +): Promise<{ success: boolean; failedCheck?: string }> { + const defaultChecks = [ + { cmd: 'npm run lint', name: 'Lint' }, + { cmd: 'npm run typecheck', name: 'Type check' }, + { cmd: 'npm test', name: 'Tests' }, + { cmd: 'npm run build', name: 'Build' }, + ]; + + const checksToRun = checks || defaultChecks; + + for (const check of checksToRun) { + try { + await execAsync(check.cmd, { cwd: workDir, timeout: 120000 }); + } catch { + return { success: false, failedCheck: check.name }; + } + } + + return { success: true }; +} diff --git a/libs/git-utils/src/index.ts b/libs/git-utils/src/index.ts index 33067e91..7b29c8ac 100644 --- a/libs/git-utils/src/index.ts +++ b/libs/git-utils/src/index.ts @@ -17,3 +17,12 @@ export { generateDiffsForNonGitDirectory, getGitRepositoryDiffs, } from './diff.js'; + +// Export commit utilities +export { + hasUncommittedChanges, + commitAll, + getHeadHash, + shortHash, + runVerificationChecks, +} from './commit.js'; diff --git a/libs/prompts/src/feature-prompt.ts b/libs/prompts/src/feature-prompt.ts new file mode 100644 index 00000000..8866e079 --- /dev/null +++ b/libs/prompts/src/feature-prompt.ts @@ -0,0 +1,138 @@ +/** + * Feature Prompt - Prompt building for feature implementation + * + * Contains utilities for building prompts from Feature objects. + */ + +import type { Feature } from '@automaker/types'; + +/** + * Extract a title from feature description + * + * Takes the first line of the description and truncates if needed. + * + * @param description - The feature description + * @returns A title string (max 60 chars) + */ +export function extractTitleFromDescription(description: string): string { + if (!description?.trim()) { + return 'Untitled Feature'; + } + + const firstLine = description.split('\n')[0].trim(); + return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...'; +} + +/** + * Build a feature implementation prompt + * + * Creates a structured prompt for the AI agent to implement a feature. + * + * @param feature - The feature to build a prompt for + * @returns The formatted prompt string + */ +export function buildFeaturePrompt(feature: Feature): string { + const title = extractTitleFromDescription(feature.description); + + let prompt = `## Feature Implementation Task + +**Feature ID:** ${feature.id} +**Title:** ${title} +**Description:** ${feature.description} +`; + + if (feature.spec) { + prompt += `\n**Specification:**\n${feature.spec}\n`; + } + + if (feature.imagePaths && feature.imagePaths.length > 0) { + const imagesList = feature.imagePaths + .map((img, idx) => { + const imgPath = typeof img === 'string' ? img : img.path; + const filename = + typeof img === 'string' + ? imgPath.split('/').pop() + : (img as { filename?: string }).filename || imgPath.split('/').pop(); + return ` ${idx + 1}. ${filename}\n Path: ${imgPath}`; + }) + .join('\n'); + + prompt += `\n**Context Images Attached:**\n${imagesList}\n`; + } + + if (feature.skipTests) { + prompt += ` +## Instructions + +Implement this feature by: +1. Explore the codebase to understand the existing structure +2. Plan your implementation approach +3. Write the necessary code changes +4. Ensure the code follows existing patterns + +When done, wrap your final summary in tags.`; + } else { + prompt += ` +## Instructions + +Implement and verify this feature: +1. Explore the codebase +2. Plan your approach +3. Write the code changes +4. Verify with Playwright tests + +When done, wrap your final summary in tags.`; + } + + return prompt; +} + +/** + * Build a follow-up prompt for continuing work on a feature + * + * @param feature - The feature being followed up on + * @param previousContext - Previous agent work context + * @param followUpInstructions - New instructions from user + * @returns The formatted follow-up prompt + */ +export function buildFollowUpPrompt( + feature: Feature | null, + featureId: string, + previousContext: string, + followUpInstructions: string +): string { + let prompt = `## Follow-up on Feature Implementation\n\n`; + + if (feature) { + prompt += buildFeaturePrompt(feature) + '\n'; + } else { + prompt += `**Feature ID:** ${featureId}\n`; + } + + if (previousContext) { + prompt += `\n## Previous Agent Work\n${previousContext}\n`; + } + + prompt += `\n## Follow-up Instructions\n${followUpInstructions}\n\n## Task\nAddress the follow-up instructions above.`; + + return prompt; +} + +/** + * Build a continuation prompt for resuming work + * + * @param feature - The feature to continue + * @param context - Previous work context + * @returns The continuation prompt + */ +export function buildContinuationPrompt(feature: Feature, context: string): string { + return `## Continuing Feature Implementation + +${buildFeaturePrompt(feature)} + +## Previous Context +${context} + +## Instructions +Review the previous work and continue the implementation.`; +} diff --git a/libs/prompts/src/index.ts b/libs/prompts/src/index.ts index 8ee2c058..b63e2c65 100644 --- a/libs/prompts/src/index.ts +++ b/libs/prompts/src/index.ts @@ -21,5 +21,31 @@ export { getAvailableEnhancementModes, } from './enhancement.js'; +// Planning prompts (spec-driven development) +export { + PLANNING_PROMPTS, + getPlanningPrompt, + getPlanningPromptPrefix, + parseTasksFromSpec, + parseTaskLine, + buildTaskPrompt, + isSpecGeneratingMode, + canRequireApproval, + getPlanningModeDisplayName, +} from './planning.js'; + +// Feature prompts (implementation) +export { + buildFeaturePrompt, + buildFollowUpPrompt, + buildContinuationPrompt, + extractTitleFromDescription, +} from './feature-prompt.js'; + // Re-export types from @automaker/types -export type { EnhancementMode, EnhancementExample } from '@automaker/types'; +export type { + EnhancementMode, + EnhancementExample, + PlanningMode, + ParsedTask, +} from '@automaker/types'; diff --git a/libs/prompts/src/planning.ts b/libs/prompts/src/planning.ts new file mode 100644 index 00000000..7ac59891 --- /dev/null +++ b/libs/prompts/src/planning.ts @@ -0,0 +1,411 @@ +/** + * Planning Prompts - AI prompt templates for spec-driven development + * + * Contains planning mode prompts, task parsing utilities, and prompt builders + * for the multi-agent task execution workflow. + */ + +import type { PlanningMode, ParsedTask } from '@automaker/types'; + +/** + * Planning mode prompt templates + * + * Each mode has a specific prompt format that instructs the AI to generate + * a planning document with task breakdowns in a parseable format. + */ +export const PLANNING_PROMPTS = { + lite: `## Planning Phase (Lite Mode) + +IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan. + +Create a brief planning outline: + +1. **Goal**: What are we accomplishing? (1 sentence) +2. **Approach**: How will we do it? (2-3 sentences) +3. **Files to Touch**: List files and what changes +4. **Tasks**: Numbered task list (3-7 items) +5. **Risks**: Any gotchas to watch for + +After generating the outline, output: +"[PLAN_GENERATED] Planning outline complete." + +Then proceed with implementation.`, + + lite_with_approval: `## Planning Phase (Lite Mode) + +IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan. + +Create a brief planning outline: + +1. **Goal**: What are we accomplishing? (1 sentence) +2. **Approach**: How will we do it? (2-3 sentences) +3. **Files to Touch**: List files and what changes +4. **Tasks**: Numbered task list (3-7 items) +5. **Risks**: Any gotchas to watch for + +After generating the outline, output: +"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions." + +DO NOT proceed with implementation until you receive explicit approval.`, + + spec: `## Specification Phase (Spec Mode) + +IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification. + +Generate a specification with an actionable task breakdown. WAIT for approval before implementing. + +### Specification Format + +1. **Problem**: What problem are we solving? (user perspective) + +2. **Solution**: Brief approach (1-2 sentences) + +3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format + - GIVEN [context], WHEN [action], THEN [outcome] + +4. **Files to Modify**: + | File | Purpose | Action | + |------|---------|--------| + | path/to/file | description | create/modify/delete | + +5. **Implementation Tasks**: + Use this EXACT format for each task (the system will parse these): + \`\`\`tasks + - [ ] T001: [Description] | File: [path/to/file] + - [ ] T002: [Description] | File: [path/to/file] + - [ ] T003: [Description] | File: [path/to/file] + \`\`\` + + Task ID rules: + - Sequential: T001, T002, T003, etc. + - Description: Clear action (e.g., "Create user model", "Add API endpoint") + - File: Primary file affected (helps with context) + - Order by dependencies (foundational tasks first) + +6. **Verification**: How to confirm feature works + +After generating the spec, output on its own line: +"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions." + +DO NOT proceed with implementation until you receive explicit approval. + +When approved, execute tasks SEQUENTIALLY in order. For each task: +1. BEFORE starting, output: "[TASK_START] T###: Description" +2. Implement the task +3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary" + +This allows real-time progress tracking during implementation.`, + + full: `## Full Specification Phase (Full SDD Mode) + +IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification. + +Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing. + +### Specification Format + +1. **Problem Statement**: 2-3 sentences from user perspective + +2. **User Story**: As a [user], I want [goal], so that [benefit] + +3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN + - **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome] + - **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling] + - **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response] + +4. **Technical Context**: + | Aspect | Value | + |--------|-------| + | Affected Files | list of files | + | Dependencies | external libs if any | + | Constraints | technical limitations | + | Patterns to Follow | existing patterns in codebase | + +5. **Non-Goals**: What this feature explicitly does NOT include + +6. **Implementation Tasks**: + Use this EXACT format for each task (the system will parse these): + \`\`\`tasks + ## Phase 1: Foundation + - [ ] T001: [Description] | File: [path/to/file] + - [ ] T002: [Description] | File: [path/to/file] + + ## Phase 2: Core Implementation + - [ ] T003: [Description] | File: [path/to/file] + - [ ] T004: [Description] | File: [path/to/file] + + ## Phase 3: Integration & Testing + - [ ] T005: [Description] | File: [path/to/file] + - [ ] T006: [Description] | File: [path/to/file] + \`\`\` + + Task ID rules: + - Sequential across all phases: T001, T002, T003, etc. + - Description: Clear action verb + target + - File: Primary file affected + - Order by dependencies within each phase + - Phase structure helps organize complex work + +7. **Success Metrics**: How we know it's done (measurable criteria) + +8. **Risks & Mitigations**: + | Risk | Mitigation | + |------|------------| + | description | approach | + +After generating the spec, output on its own line: +"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions." + +DO NOT proceed with implementation until you receive explicit approval. + +When approved, execute tasks SEQUENTIALLY by phase. For each task: +1. BEFORE starting, output: "[TASK_START] T###: Description" +2. Implement the task +3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary" + +After completing all tasks in a phase, output: +"[PHASE_COMPLETE] Phase N complete" + +This allows real-time progress tracking during implementation.`, +} as const; + +/** + * Get the planning prompt for a given mode + * + * @param mode - The planning mode (skip, lite, spec, full) + * @param requireApproval - Whether to use approval variant for lite mode + * @returns The prompt string, or empty string for 'skip' mode + */ +export function getPlanningPrompt(mode: PlanningMode, requireApproval?: boolean): string { + if (mode === 'skip') { + return ''; + } + + // For lite mode, use approval variant if required + if (mode === 'lite' && requireApproval) { + return PLANNING_PROMPTS.lite_with_approval; + } + + return PLANNING_PROMPTS[mode] || ''; +} + +/** + * Get the planning prompt prefix for a feature prompt + * + * Used to prepend planning instructions before the feature description. + * + * @param mode - The planning mode + * @param requireApproval - Whether approval is required + * @returns Formatted prompt prefix with separator, or empty string + */ +export function getPlanningPromptPrefix(mode: PlanningMode, requireApproval?: boolean): string { + const prompt = getPlanningPrompt(mode, requireApproval); + if (!prompt) { + return ''; + } + return prompt + '\n\n---\n\n## Feature Request\n\n'; +} + +/** + * Parse tasks from generated spec content + * + * Looks for the ```tasks code block and extracts task lines. + * Falls back to finding task lines anywhere in content if no block found. + * + * @param specContent - The full spec content string + * @returns Array of parsed tasks + */ +export function parseTasksFromSpec(specContent: string): ParsedTask[] { + const tasks: ParsedTask[] = []; + + // Extract content within ```tasks ... ``` block + const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); + if (!tasksBlockMatch) { + // Try fallback: look for task lines anywhere in content + const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); + if (!taskLines) { + return tasks; + } + // Parse fallback task lines + let currentPhase: string | undefined; + for (const line of taskLines) { + const parsed = parseTaskLine(line, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + return tasks; + } + + const tasksContent = tasksBlockMatch[1]; + const lines = tasksContent.split('\n'); + + let currentPhase: string | undefined; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Check for phase header (e.g., "## Phase 1: Foundation") + const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); + if (phaseMatch) { + currentPhase = phaseMatch[1].trim(); + continue; + } + + // Check for task line + if (trimmedLine.startsWith('- [ ]')) { + const parsed = parseTaskLine(trimmedLine, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + } + + return tasks; +} + +/** + * Parse a single task line + * + * Format: - [ ] T###: Description | File: path/to/file + * + * @param line - The task line to parse + * @param currentPhase - Optional phase context + * @returns Parsed task or null if line doesn't match format + */ +export function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { + // Match pattern: - [ ] T###: Description | File: path + const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); + if (!taskMatch) { + // Try simpler pattern without file + const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); + if (simpleMatch) { + return { + id: simpleMatch[1], + description: simpleMatch[2].trim(), + phase: currentPhase, + status: 'pending', + }; + } + return null; + } + + return { + id: taskMatch[1], + description: taskMatch[2].trim(), + filePath: taskMatch[3]?.trim(), + phase: currentPhase, + status: 'pending', + }; +} + +/** + * Build a focused prompt for executing a single task + * + * Creates a prompt that shows the current task, completed tasks, + * and remaining tasks to give the agent context while keeping focus. + * + * @param task - The current task to execute + * @param allTasks - All tasks in the spec + * @param taskIndex - Index of current task in allTasks + * @param planContent - The full approved plan content + * @param userFeedback - Optional user feedback to incorporate + * @returns Formatted prompt for task execution + */ +export function buildTaskPrompt( + task: ParsedTask, + allTasks: ParsedTask[], + taskIndex: number, + planContent: string, + userFeedback?: string +): string { + const completedTasks = allTasks.slice(0, taskIndex); + const remainingTasks = allTasks.slice(taskIndex + 1); + + let prompt = `# Task Execution: ${task.id} + +You are executing a specific task as part of a larger feature implementation. + +## Your Current Task + +**Task ID:** ${task.id} +**Description:** ${task.description} +${task.filePath ? `**Primary File:** ${task.filePath}` : ''} +${task.phase ? `**Phase:** ${task.phase}` : ''} + +## Context + +`; + + // Show what's already done + if (completedTasks.length > 0) { + prompt += `### Already Completed (${completedTasks.length} tasks) +${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')} + +`; + } + + // Show remaining tasks + if (remainingTasks.length > 0) { + prompt += `### Coming Up Next (${remainingTasks.length} tasks remaining) +${remainingTasks + .slice(0, 3) + .map((t) => `- [ ] ${t.id}: ${t.description}`) + .join('\n')} +${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ''} + +`; + } + + // Add user feedback if any + if (userFeedback) { + prompt += `### User Feedback +${userFeedback} + +`; + } + + // Add relevant excerpt from plan (just the task-related part to save context) + prompt += `### Reference: Full Plan +
+${planContent} +
+ +## Instructions + +1. Focus ONLY on completing task ${task.id}: "${task.description}" +2. Do not work on other tasks +3. Use the existing codebase patterns +4. When done, summarize what you implemented + +Begin implementing task ${task.id} now.`; + + return prompt; +} + +/** + * Check if a planning mode requires spec generation + */ +export function isSpecGeneratingMode(mode: PlanningMode): boolean { + return mode === 'spec' || mode === 'full' || mode === 'lite'; +} + +/** + * Check if a planning mode can require approval + */ +export function canRequireApproval(mode: PlanningMode): boolean { + return mode !== 'skip'; +} + +/** + * Get display name for a planning mode + */ +export function getPlanningModeDisplayName(mode: PlanningMode): string { + const names: Record = { + skip: 'Skip Planning', + lite: 'Lite Planning', + spec: 'Specification', + full: 'Full SDD', + }; + return names[mode] || mode; +} diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index 593b626b..7ad478bd 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -3,6 +3,7 @@ */ import type { PlanningMode } from './settings.js'; +import type { PlanSpec } from './planning.js'; export interface FeatureImagePath { id: string; @@ -41,20 +42,21 @@ export interface Feature { thinkingLevel?: string; planningMode?: PlanningMode; requirePlanApproval?: boolean; - planSpec?: { - status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; - content?: string; - version: number; - generatedAt?: string; - approvedAt?: string; - reviewedByUser: boolean; - tasksCompleted?: number; - tasksTotal?: number; - }; + /** Specification state for spec-driven development modes */ + planSpec?: PlanSpec; error?: string; summary?: string; startedAt?: string; [key: string]: unknown; // Keep catch-all for extensibility } -export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified'; +export type FeatureStatus = + | 'pending' + | 'ready' + | 'backlog' + | 'in_progress' + | 'running' + | 'completed' + | 'failed' + | 'verified' + | 'waiting_approval'; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 6e173075..592adf7e 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -81,3 +81,15 @@ export { THINKING_LEVEL_LABELS, getModelDisplayName, } from './model-display.js'; + +// Planning types (spec-driven development) +export type { + TaskStatus, + PlanSpecStatus, + ParsedTask, + PlanSpec, + AutoModeEventType, + AutoModeEventPayload, + TaskProgressPayload, + PlanApprovalPayload, +} from './planning.js'; diff --git a/libs/types/src/planning.ts b/libs/types/src/planning.ts new file mode 100644 index 00000000..7285ceec --- /dev/null +++ b/libs/types/src/planning.ts @@ -0,0 +1,141 @@ +/** + * Planning Types - Types for spec-driven development and task execution + * + * These types support the planning/specification workflow in auto-mode: + * - PlanningMode: skip, lite, spec, full + * - ParsedTask: Individual tasks extracted from specs + * - PlanSpec: Specification state and content + * - AutoModeEventType: Type-safe event names for auto-mode + */ + +import type { PlanningMode } from './settings.js'; + +// Re-export PlanningMode for convenience +export type { PlanningMode }; + +/** + * TaskStatus - Status of an individual task within a spec + */ +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +/** + * PlanSpecStatus - Status of a plan/specification document + */ +export type PlanSpecStatus = 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; + +/** + * ParsedTask - A single task extracted from a generated specification + * + * Tasks are identified by ID (e.g., "T001") and may belong to a phase. + * Format in spec: `- [ ] T###: Description | File: path/to/file` + */ +export interface ParsedTask { + /** Task identifier, e.g., "T001", "T002" */ + id: string; + /** Human-readable description of what the task accomplishes */ + description: string; + /** Primary file affected by this task (optional) */ + filePath?: string; + /** Phase this task belongs to, e.g., "Phase 1: Foundation" (for full mode) */ + phase?: string; + /** Current execution status of the task */ + status: TaskStatus; +} + +/** + * PlanSpec - Specification/plan state for a feature + * + * Tracks the generated spec content, approval status, and task progress. + * Stored in feature.json as `planSpec` property. + */ +export interface PlanSpec { + /** Current status of the spec */ + status: PlanSpecStatus; + /** The spec/plan content (markdown) */ + content?: string; + /** Version number, incremented on each revision */ + version: number; + /** ISO timestamp when spec was first generated */ + generatedAt?: string; + /** ISO timestamp when spec was approved */ + approvedAt?: string; + /** Whether user has reviewed (approved/rejected) the spec */ + reviewedByUser: boolean; + /** Number of tasks completed during execution */ + tasksCompleted?: number; + /** Total number of tasks parsed from spec */ + tasksTotal?: number; + /** ID of the task currently being executed */ + currentTaskId?: string; + /** All parsed tasks from the spec */ + tasks?: ParsedTask[]; +} + +/** + * AutoModeEventType - Type-safe event names emitted by auto-mode service + * + * All events are wrapped as `auto-mode:event` with `type` field containing + * one of these values. + */ +export type AutoModeEventType = + // Auto-loop lifecycle events + | 'auto_mode_started' + | 'auto_mode_stopped' + | 'auto_mode_idle' + // Feature execution events + | 'auto_mode_feature_start' + | 'auto_mode_feature_complete' + | 'auto_mode_progress' + | 'auto_mode_tool' + | 'auto_mode_error' + // Task execution events (multi-agent) + | 'auto_mode_task_started' + | 'auto_mode_task_complete' + | 'auto_mode_phase_complete' + // Planning/spec events + | 'planning_started' + | 'plan_approval_required' + | 'plan_approved' + | 'plan_rejected' + | 'plan_auto_approved' + | 'plan_revision_requested'; + +/** + * AutoModeEvent - Base event payload structure + */ +export interface AutoModeEventPayload { + /** The specific event type */ + type: AutoModeEventType; + /** Feature ID this event relates to */ + featureId?: string; + /** Project path */ + projectPath?: string; + /** Additional event-specific data */ + [key: string]: unknown; +} + +/** + * TaskProgressPayload - Event payload for task progress events + */ +export interface TaskProgressPayload { + type: 'auto_mode_task_started' | 'auto_mode_task_complete'; + featureId: string; + projectPath: string; + taskId: string; + taskDescription?: string; + taskIndex: number; + tasksTotal: number; + tasksCompleted?: number; +} + +/** + * PlanApprovalPayload - Event payload for plan approval events + */ +export interface PlanApprovalPayload { + type: 'plan_approval_required'; + featureId: string; + projectPath: string; + planContent: string; + planningMode: PlanningMode; + planVersion: number; +} diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index c3e39b33..0abb0dd4 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -54,3 +54,15 @@ export { type ContextFilesResult, type LoadContextFilesOptions, } from './context-loader.js'; + +// Stream processing +export { + processStream, + collectStreamText, + processStreamWithProgress, + hasMarker, + extractBeforeMarker, + sleep, + type StreamHandlers, + type StreamResult, +} from './stream-processor.js'; diff --git a/libs/utils/src/stream-processor.ts b/libs/utils/src/stream-processor.ts new file mode 100644 index 00000000..71fa5b83 --- /dev/null +++ b/libs/utils/src/stream-processor.ts @@ -0,0 +1,173 @@ +/** + * Stream Processor - Unified stream handling for provider messages + * + * Eliminates duplication of the stream processing pattern for handling + * async generators from AI providers. + */ + +import type { ProviderMessage, ContentBlock } from '@automaker/types'; + +/** + * Callbacks for handling different stream events + */ +export interface StreamHandlers { + /** Called for each text block in the stream */ + onText?: (text: string) => void | Promise; + /** Called for each tool use in the stream */ + onToolUse?: (name: string, input: unknown) => void | Promise; + /** Called when an error occurs in the stream */ + onError?: (error: string) => void | Promise; + /** Called when the stream completes successfully */ + onComplete?: (result: string) => void | Promise; + /** Called for thinking blocks (if present) */ + onThinking?: (thinking: string) => void | Promise; +} + +/** + * Result from processing a stream + */ +export interface StreamResult { + /** All accumulated text from the stream */ + text: string; + /** Whether the stream completed successfully */ + success: boolean; + /** Error message if stream failed */ + error?: string; + /** Final result message if stream completed */ + result?: string; +} + +/** + * Process a provider message stream with unified handling + * + * @param stream - The async generator from provider.executeQuery() + * @param handlers - Callbacks for different event types + * @returns Accumulated result with text and status + */ +export async function processStream( + stream: AsyncGenerator, + handlers: StreamHandlers +): Promise { + let accumulatedText = ''; + let success = true; + let errorMessage: string | undefined; + let resultMessage: string | undefined; + + try { + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + await processContentBlock(block, handlers, (text) => { + accumulatedText += text; + }); + } + } else if (msg.type === 'error') { + success = false; + errorMessage = msg.error || 'Unknown error'; + if (handlers.onError) { + await handlers.onError(errorMessage); + } + throw new Error(errorMessage); + } else if (msg.type === 'result' && msg.subtype === 'success') { + resultMessage = msg.result || ''; + if (handlers.onComplete) { + await handlers.onComplete(resultMessage); + } + } + } + } catch (error) { + if (!errorMessage) { + success = false; + errorMessage = error instanceof Error ? error.message : String(error); + } + throw error; + } + + return { + text: accumulatedText, + success, + error: errorMessage, + result: resultMessage, + }; +} + +/** + * Process a single content block + */ +async function processContentBlock( + block: ContentBlock, + handlers: StreamHandlers, + appendText: (text: string) => void +): Promise { + switch (block.type) { + case 'text': + if (block.text) { + appendText(block.text); + if (handlers.onText) { + await handlers.onText(block.text); + } + } + break; + + case 'tool_use': + if (block.name && handlers.onToolUse) { + await handlers.onToolUse(block.name, block.input); + } + break; + + case 'thinking': + if (block.thinking && handlers.onThinking) { + await handlers.onThinking(block.thinking); + } + break; + + // tool_result blocks are handled internally by the SDK + case 'tool_result': + break; + } +} + +/** + * Create a simple stream processor that just collects text + */ +export async function collectStreamText(stream: AsyncGenerator): Promise { + const result = await processStream(stream, {}); + return result.text; +} + +/** + * Process stream with progress callback + */ +export async function processStreamWithProgress( + stream: AsyncGenerator, + onProgress: (text: string) => void +): Promise { + return processStream(stream, { + onText: onProgress, + }); +} + +/** + * Check if a stream result contains a specific marker + */ +export function hasMarker(result: StreamResult, marker: string): boolean { + return result.text.includes(marker); +} + +/** + * Extract content before a marker + */ +export function extractBeforeMarker(text: string, marker: string): string | null { + const index = text.indexOf(marker); + if (index === -1) { + return null; + } + return text.substring(0, index).trim(); +} + +/** + * Sleep utility - delay execution for specified milliseconds + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +}