From cec5f91a86f49c192691814d71ff2782c9ec131c Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 24 Jan 2026 17:58:04 +0100 Subject: [PATCH 1/7] fix: Complete fix for plan mode system across all providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #671 (Complete fix for the plan mode system inside automaker) Related: #619, #627, #531, #660 ## Issues Fixed ### 1. Non-Claude Provider Support - Removed Claude model restriction from planning mode UI selectors - Added `detectSpecFallback()` function to detect specs without `[SPEC_GENERATED]` marker - All providers (OpenAI, Gemini, Cursor, etc.) can now use spec and full planning modes - Fallback detection looks for structural elements: tasks block, acceptance criteria, problem statement, implementation plan, etc. ### 2. Crash/Restart Recovery - Added `resetStuckFeatures()` to clean up transient states on auto-mode start - Features stuck in `in_progress` are reset to `ready` or `backlog` - Tasks stuck in `in_progress` are reset to `pending` - Plan generation stuck in `generating` is reset to `pending` - `loadPendingFeatures()` now includes recovery cases for interrupted executions - Persisted task status in `planSpec.tasks` array allows resuming from last completed task ### 3. Spec Todo List UI Updates - Added `ParsedTask` and `PlanSpec` types to `@automaker/types` for consistent typing - New `auto_mode_task_status` event emitted when task status changes - New `auto_mode_summary` event emitted when summary is extracted - Query invalidation triggers on task status updates for real-time UI refresh - Task markers (`[TASK_START]`, `[TASK_COMPLETE]`, `[PHASE_COMPLETE]`) are detected and persisted to planSpec.tasks for UI display ### 4. Summary Extraction - Added `extractSummary()` function to parse summaries from multiple formats: - `` tags (explicit) - `## Summary` sections (markdown) - `**Goal**:` sections (lite mode) - `**Problem**:` sections (spec/full modes) - `**Solution**:` sections (fallback) - Summary is saved to `feature.summary` field after execution - Summary is extracted from plan content during spec generation ### 5. Worktree Mode Support (#619) - Recovery logic properly handles branchName filtering - Features in worktrees maintain correct association during recovery ## Files Changed - libs/types/src/feature.ts - Added ParsedTask and PlanSpec interfaces - libs/types/src/index.ts - Export new types - apps/server/src/services/auto-mode-service.ts - Core fixes for all issues - apps/server/tests/unit/services/auto-mode-task-parsing.test.ts - New tests - apps/ui/src/store/app-store.ts - Import types from @automaker/types - apps/ui/src/hooks/use-auto-mode.ts - Handle new events - apps/ui/src/hooks/use-query-invalidation.ts - Invalidate on task updates - apps/ui/src/types/electron.d.ts - New event type definitions - apps/ui/src/components/views/board-view/dialogs/*.tsx - Enable planning for all models 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/services/auto-mode-service.ts | 530 ++++++++++++++++-- .../services/auto-mode-task-parsing.test.ts | 241 +++++++- .../board-view/dialogs/add-feature-dialog.tsx | 6 +- .../dialogs/edit-feature-dialog.tsx | 6 +- .../board-view/dialogs/mass-edit-dialog.tsx | 5 +- apps/ui/src/hooks/use-auto-mode.ts | 27 + apps/ui/src/hooks/use-query-invalidation.ts | 4 +- apps/ui/src/store/app-store.ts | 27 +- apps/ui/src/types/electron.d.ts | 20 + .../planning-mode-fix-verification.spec.ts | 131 +++++ libs/types/src/feature.ts | 55 +- libs/types/src/index.ts | 2 + 12 files changed, 970 insertions(+), 84 deletions(-) create mode 100644 apps/ui/tests/features/planning-mode-fix-verification.spec.ts diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 1f5407c8..c4549136 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -20,6 +20,8 @@ import type { PipelineConfig, ThinkingLevel, PlanningMode, + ParsedTask, + PlanSpec, } from '@automaker/types'; import { DEFAULT_PHASE_MODELS, @@ -90,28 +92,7 @@ async function getCurrentBranch(projectPath: string): Promise { } } -// PlanningMode type is imported from @automaker/types - -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[]; -} +// ParsedTask and PlanSpec types are imported from @automaker/types /** * Information about pipeline status when resuming a feature. @@ -217,6 +198,130 @@ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { }; } +/** + * Detect [TASK_START] marker in text and extract task ID + * Format: [TASK_START] T###: Description + */ +function detectTaskStartMarker(text: string): string | null { + const match = text.match(/\[TASK_START\]\s*(T\d{3})/); + return match ? match[1] : null; +} + +/** + * Detect [TASK_COMPLETE] marker in text and extract task ID + * Format: [TASK_COMPLETE] T###: Brief summary + */ +function detectTaskCompleteMarker(text: string): string | null { + const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/); + return match ? match[1] : null; +} + +/** + * Detect [PHASE_COMPLETE] marker in text and extract phase number + * Format: [PHASE_COMPLETE] Phase N complete + */ +function detectPhaseCompleteMarker(text: string): number | null { + const match = text.match(/\[PHASE_COMPLETE\]\s*Phase\s*(\d+)/i); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Fallback spec detection when [SPEC_GENERATED] marker is missing + * Looks for structural elements that indicate a spec was generated. + * This is especially important for non-Claude models that may not output + * the explicit [SPEC_GENERATED] marker. + * + * @param text - The text content to check for spec structure + * @returns true if the text appears to be a generated spec + */ +function detectSpecFallback(text: string): boolean { + // Check for key structural elements of a spec + const hasTasksBlock = /```tasks[\s\S]*```/.test(text); + const hasTaskLines = /- \[ \] T\d{3}:/.test(text); + + // Check for common spec sections (case-insensitive) + const hasAcceptanceCriteria = /acceptance criteria/i.test(text); + const hasTechnicalContext = /technical context/i.test(text); + const hasProblemStatement = /problem statement/i.test(text); + const hasUserStory = /user story/i.test(text); + // Additional patterns for different model outputs + const hasGoal = /\*\*Goal\*\*:/i.test(text); + const hasSolution = /\*\*Solution\*\*:/i.test(text); + const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text); + const hasOverview = /##\s*(overview|summary)/i.test(text); + + // Spec is detected if we have task structure AND at least some spec content + const hasTaskStructure = hasTasksBlock || hasTaskLines; + const hasSpecContent = + hasAcceptanceCriteria || + hasTechnicalContext || + hasProblemStatement || + hasUserStory || + hasGoal || + hasSolution || + hasImplementation || + hasOverview; + + return hasTaskStructure && hasSpecContent; +} + +/** + * Extract summary from text content + * Checks for multiple formats in order of priority: + * 1. Explicit tags + * 2. ## Summary section (markdown) + * 3. **Goal**: section (lite planning mode) + * 4. **Problem**: or **Problem Statement**: section (spec/full modes) + * 5. **Solution**: section as fallback + * + * @param text - The text content to extract summary from + * @returns The extracted summary string, or null if no summary found + */ +function extractSummary(text: string): string | null { + // Check for explicit tags first + const summaryMatch = text.match(/([\s\S]*?)<\/summary>/); + if (summaryMatch) { + return summaryMatch[1].trim(); + } + + // Check for ## Summary section + const sectionMatch = text.match(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/i); + if (sectionMatch) { + const content = sectionMatch[1].trim(); + // Take first paragraph or up to 500 chars + const firstPara = content.split(/\n\n/)[0]; + return firstPara.length > 500 ? firstPara.substring(0, 500) + '...' : firstPara; + } + + // Check for **Goal**: section (lite mode) + const goalMatch = text.match(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/i); + if (goalMatch) { + return goalMatch[1].trim(); + } + + // Check for **Problem**: or **Problem Statement**: section (spec/full modes) + const problemMatch = text.match( + /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/i + ); + if (problemMatch) { + const content = problemMatch[1].trim(); + // Take first paragraph or up to 500 chars + const firstPara = content.split(/\n\n/)[0]; + return firstPara.length > 500 ? firstPara.substring(0, 500) + '...' : firstPara; + } + + // Check for **Solution**: section as fallback + const solutionMatch = text.match(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/i); + if (solutionMatch) { + const content = solutionMatch[1].trim(); + // Take first paragraph or up to 300 chars + const firstPara = content.split(/\n\n/)[0]; + return firstPara.length > 300 ? firstPara.substring(0, 300) + '...' : firstPara; + } + + return null; +} + // Feature type is imported from feature-loader.js // Extended type with planning fields for local use interface FeatureWithPlanning extends Feature { @@ -334,6 +439,76 @@ export class AutoModeService { this.settingsService = settingsService ?? null; } + /** + * Reset features that were stuck in transient states due to server crash + * Called when auto mode is enabled to clean up from previous session + * @param projectPath - The project path to reset features for + */ + async resetStuckFeatures(projectPath: string): Promise { + const featuresDir = getFeaturesDir(projectPath); + + try { + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + const feature = result.data; + if (!feature) continue; + + let needsUpdate = false; + + // Reset in_progress features back to ready/backlog + if (feature.status === 'in_progress') { + const hasApprovedPlan = feature.planSpec?.status === 'approved'; + feature.status = hasApprovedPlan ? 'ready' : 'backlog'; + needsUpdate = true; + logger.info( + `[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}` + ); + } + + // Reset generating planSpec status back to pending (spec generation was interrupted) + if (feature.planSpec?.status === 'generating') { + feature.planSpec.status = 'pending'; + needsUpdate = true; + logger.info( + `[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending` + ); + } + + // Reset any in_progress tasks back to pending (task execution was interrupted) + if (feature.planSpec?.tasks) { + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'pending'; + needsUpdate = true; + logger.info( + `[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` + ); + } + } + } + + if (needsUpdate) { + feature.updatedAt = new Date().toISOString(); + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + } + } + } catch (error) { + // If features directory doesn't exist, that's fine + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error); + } + } + } + /** * Track a failure and check if we should pause due to consecutive failures. * This handles cases where the SDK doesn't return useful error messages. @@ -606,6 +781,14 @@ export class AutoModeService { `Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` ); + // Reset any features that were stuck in transient states due to previous server crash + try { + await this.resetStuckFeatures(projectPath); + } catch (error) { + logger.warn(`[startAutoLoopForProject] Error resetting stuck features:`, error); + // Don't fail startup due to reset errors + } + this.emitAutoModeEvent('auto_mode_started', { message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, projectPath, @@ -1315,7 +1498,7 @@ export class AutoModeService { // Record success to reset consecutive failure tracking this.recordSuccess(); - // Record learnings and memory usage after successful feature completion + // Record learnings, memory usage, and extract summary after successful feature completion try { const featureDir = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDir, 'agent-output.md'); @@ -1328,6 +1511,15 @@ export class AutoModeService { // Agent output might not exist yet } + // Extract and save summary from agent output + if (agentOutput) { + const summary = extractSummary(agentOutput); + if (summary) { + logger.info(`Extracted summary for feature ${featureId}`); + await this.saveFeatureSummary(projectPath, featureId, summary); + } + } + // Record memory usage if we loaded any memory files if (contextResult.memoryFiles.length > 0 && agentOutput) { await recordMemoryUsage( @@ -3035,6 +3227,162 @@ Format your response as a structured markdown document.`; } } + /** + * Save the extracted summary to a feature's summary field. + * This is called after agent execution completes to save a summary + * extracted from the agent's output using tags. + * + * Note: This is different from updateFeatureSummary which updates + * the description field during plan generation. + * + * @param projectPath - The project path + * @param featureId - The feature ID + * @param summary - The summary text to save + */ + private async saveFeatureSummary( + projectPath: string, + featureId: string, + summary: string + ): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } + + feature.summary = summary; + feature.updatedAt = new Date().toISOString(); + + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + this.emitAutoModeEvent('auto_mode_summary', { + featureId, + projectPath, + summary, + }); + } catch (error) { + logger.error(`Failed to save summary for ${featureId}:`, error); + } + } + + /** + * Update the status of a specific task within planSpec.tasks + */ + private async updateTaskStatus( + projectPath: string, + featureId: string, + taskId: string, + status: ParsedTask['status'] + ): Promise { + // Use getFeatureDir helper for consistent path resolution + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + // Use recovery-enabled read for corrupted file handling + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature || !feature.planSpec?.tasks) { + logger.warn(`Feature ${featureId} not found or has no tasks`); + return; + } + + // Find and update the task + const task = feature.planSpec.tasks.find((t) => t.id === taskId); + if (task) { + task.status = status; + feature.updatedAt = new Date().toISOString(); + + // Use atomic write with backup support + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Emit event for UI update + this.emitAutoModeEvent('auto_mode_task_status', { + featureId, + projectPath, + taskId, + status, + tasks: feature.planSpec.tasks, + }); + } + } catch (error) { + logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error); + } + } + + /** + * Update the description of a feature based on extracted summary from plan content. + * This is called when a plan is generated during spec/full planning modes. + * + * Only updates the description if it's short (<50 chars), same as title, + * or starts with generic verbs like "implement/add/create/fix/update". + * + * Note: This is different from saveFeatureSummary which saves to the + * separate summary field after agent execution. + * + * @param projectPath - The project path + * @param featureId - The feature ID + * @param summary - The summary text extracted from the plan + */ + private async updateFeatureSummary( + projectPath: string, + featureId: string, + summary: string + ): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found`); + return; + } + + // Only update if the feature doesn't already have a detailed description + // (Don't overwrite user-provided descriptions with extracted summaries) + const currentDesc = feature.description || ''; + const isShortOrGeneric = + currentDesc.length < 50 || + currentDesc === feature.title || + /^(implement|add|create|fix|update)\s/i.test(currentDesc); + + if (isShortOrGeneric) { + feature.description = summary; + feature.updatedAt = new Date().toISOString(); + + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + logger.info(`Updated feature ${featureId} description with extracted summary`); + } + } catch (error) { + logger.error(`Failed to update summary for ${featureId}:`, error); + } + } + /** * Load pending features for a specific project/worktree * @param projectPath - The project path @@ -3082,13 +3430,22 @@ Format your response as a structured markdown document.`; // Track pending features separately, filtered by worktree/branch // Note: waiting_approval is NOT included - those features have completed execution // and are waiting for user review, they should not be picked up again - if ( + // + // Recovery cases: + // 1. Standard pending/ready/backlog statuses + // 2. Features with approved plans that have incomplete tasks (crash recovery) + // 3. Features stuck in 'in_progress' status (crash recovery) + // 4. Features with 'generating' planSpec status (spec generation was interrupted) + const needsRecovery = feature.status === 'pending' || feature.status === 'ready' || feature.status === 'backlog' || + feature.status === 'in_progress' || // Recover features that were in progress when server crashed (feature.planSpec?.status === 'approved' && - (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) - ) { + (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) || + feature.planSpec?.status === 'generating'; // Recover interrupted spec generation + + if (needsRecovery) { // Filter by branchName: // - If branchName is null (main worktree), include features with: // - branchName === null, OR @@ -3123,7 +3480,7 @@ Format your response as a structured markdown document.`; const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/approved_with_pending_tasks) for ${worktreeDesc}` + `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/approved_with_pending_tasks/generating) for ${worktreeDesc}` ); if (pendingFeatures.length === 0) { @@ -3388,6 +3745,21 @@ You can use the Read tool to view these images at any time during implementation (planningMode === 'lite' && options?.requirePlanApproval === true); const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true; + // Check if feature already has an approved plan with tasks (recovery scenario) + // If so, we should skip spec detection and use persisted task status + let existingApprovedPlan: Feature['planSpec'] | undefined; + let persistedTasks: ParsedTask[] | undefined; + if (planningModeRequiresApproval) { + const feature = await this.loadFeature(projectPath, featureId); + if (feature?.planSpec?.status === 'approved' && feature.planSpec.tasks) { + existingApprovedPlan = feature.planSpec; + persistedTasks = feature.planSpec.tasks; + logger.info( + `Recovery: Using persisted tasks for feature ${featureId} (${persistedTasks.length} tasks, ${persistedTasks.filter((t) => t.status === 'completed').length} completed)` + ); + } + } + // 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') { @@ -3552,7 +3924,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. let responseText = previousContent ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` : ''; - let specDetected = false; + // Skip spec detection if we already have an approved plan (recovery scenario) + let specDetected = !!existingApprovedPlan; // Agent output goes to .automaker directory // Note: We use projectPath here, not workDir, because workDir might be a worktree path @@ -3691,16 +4064,28 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. scheduleWrite(); // Check for [SPEC_GENERATED] marker in planning modes (spec or full) + // Also support fallback detection for non-Claude models that may not output the marker + const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'); + const hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText); if ( planningModeRequiresApproval && !specDetected && - responseText.includes('[SPEC_GENERATED]') + (hasExplicitMarker || hasFallbackSpec) ) { specDetected = true; - // Extract plan content (everything before the marker) - const markerIndex = responseText.indexOf('[SPEC_GENERATED]'); - const planContent = responseText.substring(0, markerIndex).trim(); + // Extract plan content (everything before the marker, or full content for fallback) + let planContent: string; + if (hasExplicitMarker) { + const markerIndex = responseText.indexOf('[SPEC_GENERATED]'); + planContent = responseText.substring(0, markerIndex).trim(); + } else { + // Fallback: use all accumulated content as the plan + planContent = responseText.trim(); + logger.info( + `Using fallback spec detection for feature ${featureId} (no [SPEC_GENERATED] marker)` + ); + } // Parse tasks from the generated spec (for spec and full modes) // Use let since we may need to update this after plan revision @@ -3724,6 +4109,14 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. tasksCompleted: 0, }); + // Extract and save summary from the plan content + const planSummary = extractSummary(planContent); + if (planSummary) { + logger.info(`Extracted summary from plan: ${planSummary.substring(0, 100)}...`); + // Update the feature with the extracted summary + await this.updateFeatureSummary(projectPath, featureId, planSummary); + } + let approvedPlanContent = planContent; let userFeedback: string | undefined; let currentPlanContent = planContent; @@ -3955,6 +4348,12 @@ After generating the revised spec, output: for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) { const task = parsedTasks[taskIndex]; + // Skip tasks that are already completed (for recovery after restart) + if (task.status === 'completed') { + logger.info(`Skipping already completed task ${task.id}`); + continue; + } + // Check for abort if (abortController.signal.aborted) { throw new Error('Feature execution aborted'); @@ -4001,19 +4400,74 @@ After generating the revised spec, output: }); let taskOutput = ''; + let taskStartDetected = false; + let taskCompleteDetected = false; // 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 || ''; + const text = block.text || ''; + taskOutput += text; + responseText += text; this.emitAutoModeEvent('auto_mode_progress', { featureId, branchName, - content: block.text, + content: text, }); + + // Detect [TASK_START] marker + if (!taskStartDetected) { + const startTaskId = detectTaskStartMarker(taskOutput); + if (startTaskId) { + taskStartDetected = true; + logger.info(`[TASK_START] detected for ${startTaskId}`); + // Update task status to in_progress in planSpec.tasks + await this.updateTaskStatus( + projectPath, + featureId, + startTaskId, + 'in_progress' + ); + this.emitAutoModeEvent('auto_mode_task_start', { + featureId, + projectPath, + branchName, + taskId: startTaskId, + taskIndex, + tasksTotal: parsedTasks.length, + }); + } + } + + // Detect [TASK_COMPLETE] marker + if (!taskCompleteDetected) { + const completeTaskId = detectTaskCompleteMarker(taskOutput); + if (completeTaskId) { + taskCompleteDetected = true; + logger.info(`[TASK_COMPLETE] detected for ${completeTaskId}`); + // Update task status to completed in planSpec.tasks + await this.updateTaskStatus( + projectPath, + featureId, + completeTaskId, + 'completed' + ); + } + } + + // Detect [PHASE_COMPLETE] marker + const phaseNumber = detectPhaseCompleteMarker(text); + if (phaseNumber !== null) { + logger.info(`[PHASE_COMPLETE] detected for Phase ${phaseNumber}`); + this.emitAutoModeEvent('auto_mode_phase_complete', { + featureId, + projectPath, + branchName, + phaseNumber, + }); + } } else if (block.type === 'tool_use') { this.emitAutoModeEvent('auto_mode_tool', { featureId, @@ -4031,6 +4485,12 @@ After generating the revised spec, output: } } + // If no [TASK_COMPLETE] marker was detected, still mark as completed + // (for models that don't output markers) + if (!taskCompleteDetected) { + await this.updateTaskStatus(projectPath, featureId, task.id, 'completed'); + } + // Emit task completed logger.info(`Task ${task.id} completed for feature ${featureId}`); this.emitAutoModeEvent('auto_mode_task_complete', { 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..f5a660ba 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,18 +1,11 @@ import { describe, it, expect } from 'vitest'; +import type { ParsedTask } from '@automaker/types'; /** * 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*(.+))?$/); @@ -342,4 +335,236 @@ Some other text expect(fullModeOutput).toContain('[SPEC_GENERATED]'); }); }); + + describe('detectSpecFallback - non-Claude model support', () => { + /** + * Reimplementation of detectSpecFallback for testing + * This mirrors the logic in auto-mode-service.ts for detecting specs + * when the [SPEC_GENERATED] marker is missing (common with non-Claude models) + */ + function detectSpecFallback(text: string): boolean { + // Check for key structural elements of a spec + const hasTasksBlock = /```tasks[\s\S]*```/.test(text); + const hasTaskLines = /- \[ \] T\d{3}:/.test(text); + + // Check for common spec sections (case-insensitive) + const hasAcceptanceCriteria = /acceptance criteria/i.test(text); + const hasTechnicalContext = /technical context/i.test(text); + const hasProblemStatement = /problem statement/i.test(text); + const hasUserStory = /user story/i.test(text); + // Additional patterns for different model outputs + const hasGoal = /\*\*Goal\*\*:/i.test(text); + const hasSolution = /\*\*Solution\*\*:/i.test(text); + const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text); + const hasOverview = /##\s*(overview|summary)/i.test(text); + + // Spec is detected if we have task structure AND at least some spec content + const hasTaskStructure = hasTasksBlock || hasTaskLines; + const hasSpecContent = + hasAcceptanceCriteria || + hasTechnicalContext || + hasProblemStatement || + hasUserStory || + hasGoal || + hasSolution || + hasImplementation || + hasOverview; + + return hasTaskStructure && hasSpecContent; + } + + it('should detect spec with tasks block and acceptance criteria', () => { + const content = ` +## Acceptance Criteria +- GIVEN a user, WHEN they login, THEN they see the dashboard + +\`\`\`tasks +- [ ] T001: Create login form | File: src/Login.tsx +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with task lines and problem statement', () => { + const content = ` +## Problem Statement +Users cannot currently log in to the application. + +## Implementation Plan +- [ ] T001: Add authentication endpoint +- [ ] T002: Create login UI +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Goal section (lite planning mode style)', () => { + const content = ` +**Goal**: Implement user authentication + +**Solution**: Use JWT tokens for session management + +- [ ] T001: Setup auth middleware +- [ ] T002: Create token service +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with User Story format', () => { + const content = ` +## User Story +As a user, I want to reset my password, so that I can regain access. + +## Technical Context +This will modify the auth module. + +\`\`\`tasks +- [ ] T001: Add reset endpoint +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Overview section', () => { + const content = ` +## Overview +This feature adds dark mode support. + +\`\`\`tasks +- [ ] T001: Add theme toggle +- [ ] T002: Update CSS variables +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Summary section', () => { + const content = ` +## Summary +Adding a new dashboard component. + +- [ ] T001: Create dashboard layout +- [ ] T002: Add widgets +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation plan', () => { + const content = ` +## Implementation Plan +We will add the feature in two phases. + +- [ ] T001: Phase 1 setup +- [ ] T002: Phase 2 implementation +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation steps', () => { + const content = ` +## Implementation Steps +Follow these steps: + +- [ ] T001: Step one +- [ ] T002: Step two +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation approach', () => { + const content = ` +## Implementation Approach +We will use a modular approach. + +- [ ] T001: Create modules +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should NOT detect spec without task structure', () => { + const content = ` +## Problem Statement +Users cannot log in. + +## Acceptance Criteria +- GIVEN a user, WHEN they try to login, THEN it works +`; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should NOT detect spec without spec content sections', () => { + const content = ` +Here are some tasks: + +- [ ] T001: Do something +- [ ] T002: Do another thing +`; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should NOT detect random text as spec', () => { + const content = 'Just some random text without any structure'; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should handle case-insensitive matching for spec sections', () => { + const content = ` +## ACCEPTANCE CRITERIA +All caps section header + +- [ ] T001: Task +`; + expect(detectSpecFallback(content)).toBe(true); + + const content2 = ` +## acceptance criteria +Lower case section header + +- [ ] T001: Task +`; + expect(detectSpecFallback(content2)).toBe(true); + }); + + it('should detect OpenAI-style output without explicit marker', () => { + // Non-Claude models may format specs differently but still have the key elements + const openAIStyleOutput = ` +# Feature Specification: User Authentication + +**Goal**: Allow users to securely log into the application + +**Solution**: Implement JWT-based authentication with refresh tokens + +## Acceptance Criteria +1. Users can log in with email and password +2. Invalid credentials show error message +3. Sessions persist across page refreshes + +## Implementation Tasks +\`\`\`tasks +- [ ] T001: Create auth service | File: src/services/auth.ts +- [ ] T002: Build login component | File: src/components/Login.tsx +- [ ] T003: Add protected routes | File: src/App.tsx +\`\`\` +`; + expect(detectSpecFallback(openAIStyleOutput)).toBe(true); + }); + + it('should detect Gemini-style output without explicit marker', () => { + const geminiStyleOutput = ` +## Overview + +This specification describes the implementation of a user profile page. + +## Technical Context +- Framework: React +- State: Redux + +## Tasks + +- [ ] T001: Create ProfilePage component +- [ ] T002: Add profile API endpoint +- [ ] T003: Style the profile page +`; + expect(detectSpecFallback(geminiStyleOutput)).toBe(true); + }); + }); }); diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index c8ff7825..22c9db96 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -36,7 +36,7 @@ import { Feature, } from '@/store/app-store'; import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types'; -import { supportsReasoningEffort, isClaudeModel } from '@automaker/types'; +import { supportsReasoningEffort } from '@automaker/types'; import { TestingTabContent, PrioritySelector, @@ -179,8 +179,8 @@ export function AddFeatureDialog({ // Model selection state const [modelEntry, setModelEntry] = useState({ model: 'claude-opus' }); - // Check if current model supports planning mode (Claude/Anthropic only) - const modelSupportsPlanningMode = isClaudeModel(modelEntry.model); + // All models support planning mode via marker-based instructions in prompts + const modelSupportsPlanningMode = true; // Planning mode state const [planningMode, setPlanningMode] = useState('skip'); diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index 7d25c4a5..b569fb83 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -43,7 +43,7 @@ import type { WorkMode } from '../shared'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { DependencyTreeDialog } from './dependency-tree-dialog'; -import { isClaudeModel, supportsReasoningEffort } from '@automaker/types'; +import { supportsReasoningEffort } from '@automaker/types'; const logger = createLogger('EditFeatureDialog'); @@ -119,8 +119,8 @@ export function EditFeatureDialog({ reasoningEffort: feature?.reasoningEffort || 'none', })); - // Check if current model supports planning mode (Claude/Anthropic only) - const modelSupportsPlanningMode = isClaudeModel(modelEntry.model); + // All models support planning mode via marker-based instructions in prompts + const modelSupportsPlanningMode = true; // Track the source of description changes for history const [descriptionChangeSource, setDescriptionChangeSource] = useState< diff --git a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx index 07189e87..c3033603 100644 --- a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx @@ -22,7 +22,7 @@ import { } from '../shared'; import type { WorkMode } from '../shared'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; -import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types'; +import { isCursorModel, type PhaseModelEntry } from '@automaker/types'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -236,7 +236,8 @@ export function MassEditDialog({ const hasAnyApply = Object.values(applyState).some(Boolean); const isCurrentModelCursor = isCursorModel(model); const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model); - const modelSupportsPlanningMode = isClaudeModel(model); + // All models support planning mode via marker-based instructions in prompts + const modelSupportsPlanningMode = true; return ( !open && onClose()}> diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index 2a337c50..29fe1fe8 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -492,6 +492,33 @@ export function useAutoMode(worktree?: WorktreeInfo) { }); } break; + + case 'auto_mode_task_status': + // Task status updated - update planSpec.tasks in real-time + if (event.featureId && 'taskId' in event && 'tasks' in event) { + const statusEvent = event as Extract; + logger.debug( + `[AutoMode] Task ${statusEvent.taskId} status updated to ${statusEvent.status} for ${event.featureId}` + ); + // The planSpec.tasks array update is handled by query invalidation + // which will refetch the feature data + } + break; + + case 'auto_mode_summary': + // Summary extracted and saved + if (event.featureId && 'summary' in event) { + const summaryEvent = event as Extract; + logger.debug( + `[AutoMode] Summary saved for ${event.featureId}: ${summaryEvent.summary.substring(0, 100)}...` + ); + addAutoModeActivity({ + featureId: event.featureId, + type: 'progress', + message: `Summary: ${summaryEvent.summary.substring(0, 100)}...`, + }); + } + break; } }); diff --git a/apps/ui/src/hooks/use-query-invalidation.ts b/apps/ui/src/hooks/use-query-invalidation.ts index f331f1d3..214d6780 100644 --- a/apps/ui/src/hooks/use-query-invalidation.ts +++ b/apps/ui/src/hooks/use-query-invalidation.ts @@ -141,11 +141,13 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) { }); } - // Invalidate specific feature when it starts or has phase changes + // Invalidate specific feature when it starts, has phase changes, or task status updates if ( (event.type === 'auto_mode_feature_start' || event.type === 'auto_mode_phase' || event.type === 'auto_mode_phase_complete' || + event.type === 'auto_mode_task_status' || + event.type === 'auto_mode_summary' || event.type === 'pipeline_step_started') && 'featureId' in event ) { diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 3110bec8..ab6b26e7 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -37,6 +37,8 @@ import type { ClaudeApiProfile, ClaudeCompatibleProvider, SidebarStyle, + ParsedTask, + PlanSpec, } from '@automaker/types'; import { getAllCursorModelIds, @@ -65,6 +67,8 @@ export type { ServerLogLevel, FeatureTextFilePath, FeatureImagePath, + ParsedTask, + PlanSpec, }; export type ViewMode = @@ -469,28 +473,7 @@ export interface Feature extends Omit< planSpec?: PlanSpec; // Explicit planSpec type to override BaseFeature's index signature } -// 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 -} +// ParsedTask and PlanSpec types are now imported from @automaker/types // File tree node for project analysis export interface FileTreeNode { diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 8f674555..51164962 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -334,6 +334,26 @@ export type AutoModeEvent = projectPath?: string; phaseNumber: number; } + | { + type: 'auto_mode_task_status'; + featureId: string; + projectPath?: string; + taskId: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + tasks: Array<{ + id: string; + description: string; + filePath?: string; + phase?: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + }>; + } + | { + type: 'auto_mode_summary'; + featureId: string; + projectPath?: string; + summary: string; + } | { type: 'auto_mode_resuming_features'; message: string; diff --git a/apps/ui/tests/features/planning-mode-fix-verification.spec.ts b/apps/ui/tests/features/planning-mode-fix-verification.spec.ts new file mode 100644 index 00000000..193f6e0d --- /dev/null +++ b/apps/ui/tests/features/planning-mode-fix-verification.spec.ts @@ -0,0 +1,131 @@ +/** + * Planning Mode Fix Verification E2E Test + * + * Verifies GitHub issue #671 fixes: + * 1. Planning mode selector is enabled for all models (not restricted to Claude) + * 2. All planning mode options are accessible + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + clickAddFeature, + authenticateForTests, + handleLoginScreenIfPresent, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('planning-mode-verification-test'); + +test.describe('Planning Mode Fix Verification (GitHub #671)', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for planning mode verification.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('planning mode selector should be enabled and accessible in add feature dialog', async ({ + page, + }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + + await authenticateForTests(page); + await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({ + timeout: 5000, + }); + + // Open the add feature dialog + await clickAddFeature(page); + + // Wait for dialog to be visible + await expect(page.locator('[data-testid="add-feature-dialog"]')).toBeVisible({ + timeout: 5000, + }); + + // Find the planning mode select trigger + const planningModeSelectTrigger = page.locator( + '[data-testid="add-feature-planning-select-trigger"]' + ); + + // Verify the planning mode selector is visible + await expect(planningModeSelectTrigger).toBeVisible({ timeout: 5000 }); + + // Verify the planning mode selector is NOT disabled + // This is the key check for GitHub #671 - planning mode should be enabled for all models + await expect(planningModeSelectTrigger).not.toBeDisabled(); + + // Click the trigger to open the dropdown + await planningModeSelectTrigger.click(); + + // Wait for dropdown to open + await page.waitForTimeout(300); + + // Verify all planning mode options are visible + const skipOption = page.locator('[data-testid="add-feature-planning-option-skip"]'); + const liteOption = page.locator('[data-testid="add-feature-planning-option-lite"]'); + const specOption = page.locator('[data-testid="add-feature-planning-option-spec"]'); + const fullOption = page.locator('[data-testid="add-feature-planning-option-full"]'); + + await expect(skipOption).toBeVisible({ timeout: 3000 }); + await expect(liteOption).toBeVisible({ timeout: 3000 }); + await expect(specOption).toBeVisible({ timeout: 3000 }); + await expect(fullOption).toBeVisible({ timeout: 3000 }); + + // Select 'spec' mode to verify interaction works + await specOption.click(); + await page.waitForTimeout(200); + + // Verify the selection changed (the trigger should now show "Spec") + await expect(planningModeSelectTrigger).toContainText('Spec'); + + // Check that require approval checkbox appears for spec/full modes + const requireApprovalCheckbox = page.locator( + '[data-testid="add-feature-planning-require-approval-checkbox"]' + ); + await expect(requireApprovalCheckbox).toBeVisible({ timeout: 3000 }); + await expect(requireApprovalCheckbox).not.toBeDisabled(); + + // Close the dialog + await page.keyboard.press('Escape'); + }); +}); diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index a5b358fb..a053345b 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -32,6 +32,50 @@ export interface FeatureTextFilePath { [key: string]: unknown; } +/** + * A parsed task extracted from a spec/plan + * Used for spec and full planning modes to track individual task progress + */ +export interface ParsedTask { + /** Task ID, e.g., "T001" */ + id: string; + /** Task description, e.g., "Create user model" */ + description: string; + /** Optional file path for the task, e.g., "src/models/user.ts" */ + filePath?: string; + /** Optional phase name for full mode, e.g., "Phase 1: Foundation" */ + phase?: string; + /** Task execution status */ + status: 'pending' | 'in_progress' | 'completed' | 'failed'; +} + +/** + * Plan specification status for feature planning modes + * Tracks the plan generation and approval workflow + */ +export interface PlanSpec { + /** Current status of the plan */ + status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; + /** The actual spec/plan markdown content */ + content?: string; + /** Version number for tracking plan revisions */ + version: number; + /** ISO timestamp when the spec was generated */ + generatedAt?: string; + /** ISO timestamp when the spec was approved */ + approvedAt?: string; + /** True if user has reviewed the spec */ + reviewedByUser: boolean; + /** Number of completed tasks */ + tasksCompleted?: number; + /** Total number of tasks in the spec */ + tasksTotal?: number; + /** ID of the task currently being worked on */ + currentTaskId?: string; + /** Parsed tasks from the spec content */ + tasks?: ParsedTask[]; +} + export interface Feature { id: string; title?: string; @@ -54,16 +98,7 @@ export interface Feature { reasoningEffort?: ReasoningEffort; 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; - }; + planSpec?: PlanSpec; error?: string; summary?: string; startedAt?: string; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a4a7635e..65a50a01 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -67,6 +67,8 @@ export type { FeatureExport, FeatureImport, FeatureImportResult, + ParsedTask, + PlanSpec, } from './feature.js'; // Session types From b1060c6a1164441aab058022798218527974d8e9 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 24 Jan 2026 18:45:05 +0100 Subject: [PATCH 2/7] fix: adress pr comments --- apps/server/src/services/auto-mode-service.ts | 222 ++++++++++++++++-- .../board-view/dialogs/add-feature-dialog.tsx | 55 +---- .../dialogs/edit-feature-dialog.tsx | 55 +---- .../board-view/dialogs/mass-edit-dialog.tsx | 85 ++----- apps/ui/src/types/electron.d.ts | 11 +- 5 files changed, 244 insertions(+), 184 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index c4549136..e2ed550e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -274,49 +274,60 @@ function detectSpecFallback(text: string): boolean { * 4. **Problem**: or **Problem Statement**: section (spec/full modes) * 5. **Solution**: section as fallback * + * Note: Uses last match for each pattern to avoid stale summaries + * when agent output accumulates across multiple runs. + * * @param text - The text content to extract summary from * @returns The extracted summary string, or null if no summary found */ function extractSummary(text: string): string | null { - // Check for explicit tags first - const summaryMatch = text.match(/([\s\S]*?)<\/summary>/); + // Helper to truncate content to first paragraph with max length + const truncate = (content: string, maxLength: number): string => { + const firstPara = content.split(/\n\n/)[0]; + return firstPara.length > maxLength ? `${firstPara.substring(0, maxLength)}...` : firstPara; + }; + + // Helper to get last match from matchAll results + const getLastMatch = (matches: IterableIterator): RegExpMatchArray | null => { + const arr = [...matches]; + return arr.length > 0 ? arr[arr.length - 1] : null; + }; + + // Check for explicit tags first (use last match to avoid stale summaries) + const summaryMatches = text.matchAll(/([\s\S]*?)<\/summary>/g); + const summaryMatch = getLastMatch(summaryMatches); if (summaryMatch) { return summaryMatch[1].trim(); } - // Check for ## Summary section - const sectionMatch = text.match(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/i); + // Check for ## Summary section (use last match) + const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi); + const sectionMatch = getLastMatch(sectionMatches); if (sectionMatch) { - const content = sectionMatch[1].trim(); - // Take first paragraph or up to 500 chars - const firstPara = content.split(/\n\n/)[0]; - return firstPara.length > 500 ? firstPara.substring(0, 500) + '...' : firstPara; + return truncate(sectionMatch[1].trim(), 500); } - // Check for **Goal**: section (lite mode) - const goalMatch = text.match(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/i); + // Check for **Goal**: section (lite mode, use last match) + const goalMatches = text.matchAll(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/gi); + const goalMatch = getLastMatch(goalMatches); if (goalMatch) { return goalMatch[1].trim(); } - // Check for **Problem**: or **Problem Statement**: section (spec/full modes) - const problemMatch = text.match( - /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/i + // Check for **Problem**: or **Problem Statement**: section (spec/full modes, use last match) + const problemMatches = text.matchAll( + /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi ); + const problemMatch = getLastMatch(problemMatches); if (problemMatch) { - const content = problemMatch[1].trim(); - // Take first paragraph or up to 500 chars - const firstPara = content.split(/\n\n/)[0]; - return firstPara.length > 500 ? firstPara.substring(0, 500) + '...' : firstPara; + return truncate(problemMatch[1].trim(), 500); } - // Check for **Solution**: section as fallback - const solutionMatch = text.match(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/i); + // Check for **Solution**: section as fallback (use last match) + const solutionMatches = text.matchAll(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi); + const solutionMatch = getLastMatch(solutionMatches); if (solutionMatch) { - const content = solutionMatch[1].trim(); - // Take first paragraph or up to 300 chars - const firstPara = content.split(/\n\n/)[0]; - return firstPara.length > 300 ? firstPara.substring(0, 300) + '...' : firstPara; + return truncate(solutionMatch[1].trim(), 300); } return null; @@ -4008,6 +4019,168 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ); }, STREAM_HEARTBEAT_MS); + // RECOVERY PATH: If we have an approved plan with persisted tasks, skip spec generation + // and directly execute the remaining tasks + if (existingApprovedPlan && persistedTasks && persistedTasks.length > 0) { + logger.info( + `Recovery: Resuming task execution for feature ${featureId} with ${persistedTasks.length} tasks` + ); + + // Get customized prompts for task execution + const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const approvedPlanContent = existingApprovedPlan.content || ''; + + // Execute each task with a separate agent + for (let taskIndex = 0; taskIndex < persistedTasks.length; taskIndex++) { + const task = persistedTasks[taskIndex]; + + // Skip tasks that are already completed + if (task.status === 'completed') { + logger.info(`Skipping already completed task ${task.id}`); + continue; + } + + // Check for abort + if (abortController.signal.aborted) { + throw new Error('Feature execution aborted'); + } + + // Mark task as in_progress immediately (even without TASK_START marker) + await this.updateTaskStatus(projectPath, featureId, task.id, 'in_progress'); + + // Emit task started + logger.info(`Starting task ${task.id}: ${task.description}`); + this.emitAutoModeEvent('auto_mode_task_started', { + featureId, + projectPath, + branchName, + taskId: task.id, + taskDescription: task.description, + taskIndex, + tasksTotal: persistedTasks.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, + persistedTasks, + taskIndex, + approvedPlanContent, + taskPrompts.taskExecution.taskPromptTemplate, + undefined + ); + + // Execute task with dedicated agent + const taskStream = provider.executeQuery({ + prompt: taskPrompt, + model: bareModel, + maxTurns: Math.min(maxTurns || 100, 50), + cwd: workDir, + allowedTools: allowedTools, + abortController, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + credentials, + claudeCompatibleProvider, + }); + + let taskOutput = ''; + let taskCompleteDetected = false; + + // 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') { + const text = block.text || ''; + taskOutput += text; + responseText += text; + this.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: text, + }); + scheduleWrite(); + + // Detect [TASK_COMPLETE] marker + if (!taskCompleteDetected) { + const completeTaskId = detectTaskCompleteMarker(taskOutput); + if (completeTaskId) { + taskCompleteDetected = true; + logger.info(`[TASK_COMPLETE] detected for ${completeTaskId}`); + await this.updateTaskStatus( + projectPath, + featureId, + completeTaskId, + 'completed' + ); + } + } + } else if (block.type === 'tool_use') { + this.emitAutoModeEvent('auto_mode_tool', { + featureId, + branchName, + 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 || ''; + } + } + + // If no [TASK_COMPLETE] marker was detected, still mark as completed + if (!taskCompleteDetected) { + await this.updateTaskStatus(projectPath, featureId, task.id, 'completed'); + } + + // Emit task completed + logger.info(`Task ${task.id} completed for feature ${featureId}`); + this.emitAutoModeEvent('auto_mode_task_complete', { + featureId, + projectPath, + branchName, + taskId: task.id, + tasksCompleted: taskIndex + 1, + tasksTotal: persistedTasks.length, + }); + + // Update planSpec with progress + await this.updateFeaturePlanSpec(projectPath, featureId, { + tasksCompleted: taskIndex + 1, + }); + } + + logger.info(`Recovery: All tasks completed for feature ${featureId}`); + + // Extract and save final summary + const summary = extractSummary(responseText); + if (summary) { + await this.saveFeatureSummary(projectPath, featureId, summary); + this.emitAutoModeEvent('auto_mode_summary', { + featureId, + projectPath, + summary, + }); + } + + // Final write and cleanup + clearInterval(streamHeartbeat); + if (writeTimeout) { + clearTimeout(writeTimeout); + } + await writeToFile(); + return; + } + // Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort try { streamLoop: for await (const msg of stream) { @@ -4359,6 +4532,9 @@ After generating the revised spec, output: throw new Error('Feature execution aborted'); } + // Mark task as in_progress immediately (even without TASK_START marker) + await this.updateTaskStatus(projectPath, featureId, task.id, 'in_progress'); + // Emit task started logger.info(`Starting task ${task.id}: ${task.description}`); this.emitAutoModeEvent('auto_mode_task_started', { diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 22c9db96..040b2c8d 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -179,9 +179,6 @@ export function AddFeatureDialog({ // Model selection state const [modelEntry, setModelEntry] = useState({ model: 'claude-opus' }); - // All models support planning mode via marker-based instructions in prompts - const modelSupportsPlanningMode = true; - // Planning mode state const [planningMode, setPlanningMode] = useState('skip'); const [requirePlanApproval, setRequirePlanApproval] = useState(false); @@ -562,41 +559,13 @@ export function AddFeatureDialog({
- - {modelSupportsPlanningMode ? ( - - ) : ( - - - -
- {}} - testIdPrefix="add-feature-planning" - compact - disabled - /> -
-
- -

Planning modes are only available for Claude Provider

-
-
-
- )} + +
@@ -620,20 +589,14 @@ export function AddFeatureDialog({ id="add-feature-require-approval" checked={requirePlanApproval} onCheckedChange={(checked) => setRequirePlanApproval(!!checked)} - disabled={ - !modelSupportsPlanningMode || - planningMode === 'skip' || - planningMode === 'lite' - } + disabled={planningMode === 'skip' || planningMode === 'lite'} data-testid="add-feature-require-approval-checkbox" />