diff --git a/apps/server/package.json b/apps/server/package.json index e214eb022..9bed86456 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/server", - "version": "0.12.0", + "version": "0.13.0", "description": "Backend server for Automaker - provides API for both web and Electron modes", "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", diff --git a/apps/server/src/providers/index.ts b/apps/server/src/providers/index.ts index b53695f67..f04aa61d5 100644 --- a/apps/server/src/providers/index.ts +++ b/apps/server/src/providers/index.ts @@ -16,6 +16,16 @@ export type { ProviderMessage, InstallationStatus, ModelDefinition, + AgentDefinition, + ReasoningEffort, + SystemPromptPreset, + ConversationMessage, + ContentBlock, + ValidationResult, + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, } from './types.js'; // Claude provider diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts index b995d0fb0..5d439091b 100644 --- a/apps/server/src/providers/types.ts +++ b/apps/server/src/providers/types.ts @@ -19,4 +19,7 @@ export type { InstallationStatus, ValidationResult, ModelDefinition, + AgentDefinition, + ReasoningEffort, + SystemPromptPreset, } from '@automaker/types'; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 9468f2b47..2736e1982 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1281,7 +1281,11 @@ export class AutoModeService { // Check for pipeline steps and execute them const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); - const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order); + // Filter out excluded pipeline steps and sort by order + const excludedStepIds = new Set(feature.excludedPipelineSteps || []); + const sortedSteps = [...(pipelineConfig?.steps || [])] + .sort((a, b) => a.order - b.order) + .filter((step) => !excludedStepIds.has(step.id)); if (sortedSteps.length > 0) { // Execute pipeline steps sequentially @@ -1743,15 +1747,76 @@ Complete the pipeline step instructions above. Review the previous work and appl ): Promise { const featureId = feature.id; - const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); + // Sort all steps first + const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); - // Validate step index - if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) { + // Get the current step we're resuming from (using the index from unfiltered list) + if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) { throw new Error(`Invalid step index: ${startFromStepIndex}`); } + const currentStep = allSortedSteps[startFromStepIndex]; - // Get steps to execute (from startFromStepIndex onwards) - const stepsToExecute = sortedSteps.slice(startFromStepIndex); + // Filter out excluded pipeline steps + const excludedStepIds = new Set(feature.excludedPipelineSteps || []); + + // Check if the current step is excluded + // If so, use getNextStatus to find the appropriate next step + if (excludedStepIds.has(currentStep.id)) { + console.log( + `[AutoMode] Current step ${currentStep.id} is excluded for feature ${featureId}, finding next valid step` + ); + const nextStatus = pipelineService.getNextStatus( + `pipeline_${currentStep.id}`, + pipelineConfig, + feature.skipTests ?? false, + feature.excludedPipelineSteps + ); + + // If next status is not a pipeline step, feature is done + if (!pipelineService.isPipelineStatus(nextStatus)) { + await this.updateFeatureStatus(projectPath, featureId, nextStatus); + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + passes: true, + message: 'Pipeline completed (remaining steps excluded)', + projectPath, + }); + return; + } + + // Find the next step and update the start index + const nextStepId = pipelineService.getStepIdFromStatus(nextStatus); + const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId); + if (nextStepIndex === -1) { + throw new Error(`Next step ${nextStepId} not found in pipeline config`); + } + startFromStepIndex = nextStepIndex; + } + + // Get steps to execute (from startFromStepIndex onwards, excluding excluded steps) + const stepsToExecute = allSortedSteps + .slice(startFromStepIndex) + .filter((step) => !excludedStepIds.has(step.id)); + + // If no steps left to execute, complete the feature + if (stepsToExecute.length === 0) { + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + passes: true, + message: 'Pipeline completed (all remaining steps excluded)', + projectPath, + }); + return; + } + + // Use the filtered steps for counting + const sortedSteps = allSortedSteps.filter((step) => !excludedStepIds.has(step.id)); console.log( `[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}` diff --git a/apps/server/src/services/pipeline-service.ts b/apps/server/src/services/pipeline-service.ts index 407f34ced..fb885d807 100644 --- a/apps/server/src/services/pipeline-service.ts +++ b/apps/server/src/services/pipeline-service.ts @@ -234,51 +234,75 @@ export class PipelineService { * * Determines what status a feature should transition to based on current status. * Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status + * Steps in the excludedStepIds array will be skipped. * * @param currentStatus - Current feature status * @param config - Pipeline configuration (or null if no pipeline) * @param skipTests - Whether to skip tests (affects final status) + * @param excludedStepIds - Optional array of step IDs to skip * @returns The next status in the pipeline flow */ getNextStatus( currentStatus: FeatureStatusWithPipeline, config: PipelineConfig | null, - skipTests: boolean + skipTests: boolean, + excludedStepIds?: string[] ): FeatureStatusWithPipeline { const steps = config?.steps || []; + const exclusions = new Set(excludedStepIds || []); - // Sort steps by order - const sortedSteps = [...steps].sort((a, b) => a.order - b.order); + // Sort steps by order and filter out excluded steps + const sortedSteps = [...steps] + .sort((a, b) => a.order - b.order) + .filter((step) => !exclusions.has(step.id)); - // If no pipeline steps, use original logic + // If no pipeline steps (or all excluded), use original logic if (sortedSteps.length === 0) { - if (currentStatus === 'in_progress') { + // If coming from in_progress or already in a pipeline step, go to final status + if (currentStatus === 'in_progress' || currentStatus.startsWith('pipeline_')) { return skipTests ? 'waiting_approval' : 'verified'; } return currentStatus; } - // Coming from in_progress -> go to first pipeline step + // Coming from in_progress -> go to first non-excluded pipeline step if (currentStatus === 'in_progress') { return `pipeline_${sortedSteps[0].id}`; } - // Coming from a pipeline step -> go to next step or final status + // Coming from a pipeline step -> go to next non-excluded step or final status if (currentStatus.startsWith('pipeline_')) { const currentStepId = currentStatus.replace('pipeline_', ''); const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId); if (currentIndex === -1) { - // Step not found, go to final status + // Current step not found in filtered list (might be excluded or invalid) + // Find next valid step after this one from the original sorted list + const allSortedSteps = [...steps].sort((a, b) => a.order - b.order); + const originalIndex = allSortedSteps.findIndex((s) => s.id === currentStepId); + + if (originalIndex === -1) { + // Step truly doesn't exist, go to final status + return skipTests ? 'waiting_approval' : 'verified'; + } + + // Find the next non-excluded step after the current one + for (let i = originalIndex + 1; i < allSortedSteps.length; i++) { + if (!exclusions.has(allSortedSteps[i].id)) { + return `pipeline_${allSortedSteps[i].id}`; + } + } + + // No more non-excluded steps, go to final status return skipTests ? 'waiting_approval' : 'verified'; } if (currentIndex < sortedSteps.length - 1) { - // Go to next step + // Go to next non-excluded step return `pipeline_${sortedSteps[currentIndex + 1].id}`; } - // Last step completed, go to final status + // Last non-excluded step completed, go to final status return skipTests ? 'waiting_approval' : 'verified'; } diff --git a/apps/server/tests/unit/services/pipeline-service.test.ts b/apps/server/tests/unit/services/pipeline-service.test.ts index c8917c978..66297afe8 100644 --- a/apps/server/tests/unit/services/pipeline-service.test.ts +++ b/apps/server/tests/unit/services/pipeline-service.test.ts @@ -788,6 +788,367 @@ describe('pipeline-service.ts', () => { const nextStatus = pipelineService.getNextStatus('in_progress', config, false); expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2 }); + + describe('with exclusions', () => { + it('should skip excluded step when coming from in_progress', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, ['step1']); + expect(nextStatus).toBe('pipeline_step2'); // Should skip step1 and go to step2 + }); + + it('should skip excluded step when moving between steps', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 2, + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step2', + ]); + expect(nextStatus).toBe('pipeline_step3'); // Should skip step2 and go to step3 + }); + + it('should go to final status when all remaining steps are excluded', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step2', + ]); + expect(nextStatus).toBe('verified'); // No more steps after exclusion + }); + + it('should go to waiting_approval when all remaining steps excluded and skipTests is true', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true, ['step2']); + expect(nextStatus).toBe('waiting_approval'); + }); + + it('should go to final status when all steps are excluded from in_progress', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [ + 'step1', + 'step2', + ]); + expect(nextStatus).toBe('verified'); + }); + + it('should handle empty exclusions array like no exclusions', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, []); + expect(nextStatus).toBe('pipeline_step1'); + }); + + it('should handle undefined exclusions like no exclusions', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, undefined); + expect(nextStatus).toBe('pipeline_step1'); + }); + + it('should skip multiple excluded steps in sequence', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 2, + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step4', + name: 'Step 4', + order: 3, + instructions: 'Instructions', + colorClass: 'yellow', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + // Exclude step2 and step3 + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step2', + 'step3', + ]); + expect(nextStatus).toBe('pipeline_step4'); // Should skip step2 and step3 + }); + + it('should handle exclusion of non-existent step IDs gracefully', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + // Exclude a non-existent step - should have no effect + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [ + 'nonexistent', + ]); + expect(nextStatus).toBe('pipeline_step1'); + }); + + it('should find next valid step when current step becomes excluded mid-flow', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 2, + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + // Feature is at step1 but step1 is now excluded - should find next valid step + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step1', + 'step2', + ]); + expect(nextStatus).toBe('pipeline_step3'); + }); + + it('should go to final status when current step is excluded and no steps remain', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + // Feature is at step1 but both steps are excluded + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step1', + 'step2', + ]); + expect(nextStatus).toBe('verified'); + }); + }); }); describe('getStep', () => { diff --git a/apps/ui/package.json b/apps/ui/package.json index e66433fdc..1e2a0d02e 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/ui", - "version": "0.12.0", + "version": "0.13.0", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "homepage": "https://github.com/AutoMaker-Org/automaker", "repository": { diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2624514a3..2ed3ba989 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1489,6 +1489,7 @@ export function BoardView() { branchSuggestions={branchSuggestions} branchCardCounts={branchCardCounts} currentBranch={currentWorktreeBranch || undefined} + projectPath={currentProject?.path} /> {/* Board Background Modal */} @@ -1538,6 +1539,7 @@ export function BoardView() { isMaximized={isMaximized} parentFeature={spawnParentFeature} allFeatures={hookFeatures} + projectPath={currentProject?.path} // When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode selectedNonMainWorktreeBranch={ addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null @@ -1568,6 +1570,7 @@ export function BoardView() { currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} allFeatures={hookFeatures} + projectPath={currentProject?.path} /> {/* Agent Output Modal */} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx index e2673415f..4563bc06d 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx @@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react'; import { Feature, useAppStore } from '@/store/app-store'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react'; +import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { useShallow } from 'zustand/react/shallow'; +import { usePipelineConfig } from '@/hooks/queries/use-pipeline'; /** Uniform badge style for all card badges */ const uniformBadgeClass = @@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps) interface PriorityBadgesProps { feature: Feature; + projectPath?: string; } -export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) { +export const PriorityBadges = memo(function PriorityBadges({ + feature, + projectPath, +}: PriorityBadgesProps) { const { enableDependencyBlocking, features } = useAppStore( useShallow((state) => ({ enableDependencyBlocking: state.enableDependencyBlocking, @@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority ); const [currentTime, setCurrentTime] = useState(() => Date.now()); + // Fetch pipeline config to check if there are pipelines to exclude + const { data: pipelineConfig } = usePipelineConfig(projectPath); + // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies) const blockingDependencies = useMemo(() => { if (!enableDependencyBlocking || feature.status !== 'backlog') { @@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority const showManualVerification = feature.skipTests && !feature.error && feature.status === 'backlog'; - const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished; + // Check if feature has excluded pipeline steps + const excludedStepCount = feature.excludedPipelineSteps?.length || 0; + const totalPipelineSteps = pipelineConfig?.steps?.length || 0; + const hasPipelineExclusions = + excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog'; + const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps; + + const showBadges = + feature.priority || + showManualVerification || + isBlocked || + isJustFinished || + hasPipelineExclusions; if (!showBadges) { return null; @@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority )} + + {/* Pipeline exclusion badge */} + {hasPipelineExclusions && ( + + + +
+ +
+
+ +

+ {allPipelinesExcluded + ? 'All pipelines skipped' + : `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`} +

+

+ {allPipelinesExcluded + ? 'This feature will skip all custom pipeline steps' + : 'Some custom pipeline steps will be skipped for this feature'} +

+
+
+
+ )} ); }); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index ba1dd97e7..8748dad63 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -236,7 +236,7 @@ export const KanbanCard = memo(function KanbanCard({ {/* Priority and Manual Verification badges */} - + {/* Card Header */} ([]); const [childDependencies, setChildDependencies] = useState([]); + // Pipeline exclusion state + const [excludedPipelineSteps, setExcludedPipelineSteps] = useState([]); + // Get defaults from store const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } = useAppStore(); @@ -234,6 +244,9 @@ export function AddFeatureDialog({ // Reset dependency selections setParentDependencies([]); setChildDependencies([]); + + // Reset pipeline exclusions (all pipelines enabled by default) + setExcludedPipelineSteps([]); } }, [ open, @@ -328,6 +341,7 @@ export function AddFeatureDialog({ requirePlanApproval, dependencies: finalDependencies, childDependencies: childDependencies.length > 0 ? childDependencies : undefined, + excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined, workMode, }; }; @@ -354,6 +368,7 @@ export function AddFeatureDialog({ setDescriptionHistory([]); setParentDependencies([]); setChildDependencies([]); + setExcludedPipelineSteps([]); onOpenChange(false); }; @@ -696,6 +711,16 @@ export function AddFeatureDialog({ )} + + {/* Pipeline Exclusion Controls */} +
+ +
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 1a5c187dd..7d25c4a50 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 @@ -36,6 +36,7 @@ import { PlanningModeSelect, EnhanceWithAI, EnhancementHistoryButton, + PipelineExclusionControls, type EnhancementMode, } from '../shared'; import type { WorkMode } from '../shared'; @@ -67,6 +68,7 @@ interface EditFeatureDialogProps { requirePlanApproval: boolean; dependencies?: string[]; childDependencies?: string[]; // Feature IDs that should depend on this feature + excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature }, descriptionHistorySource?: 'enhance' | 'edit', enhancementMode?: EnhancementMode, @@ -78,6 +80,7 @@ interface EditFeatureDialogProps { currentBranch?: string; isMaximized: boolean; allFeatures: Feature[]; + projectPath?: string; } export function EditFeatureDialog({ @@ -90,6 +93,7 @@ export function EditFeatureDialog({ currentBranch, isMaximized, allFeatures, + projectPath, }: EditFeatureDialogProps) { const navigate = useNavigate(); const [editingFeature, setEditingFeature] = useState(feature); @@ -146,6 +150,11 @@ export function EditFeatureDialog({ return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id); }); + // Pipeline exclusion state + const [excludedPipelineSteps, setExcludedPipelineSteps] = useState( + feature?.excludedPipelineSteps ?? [] + ); + useEffect(() => { setEditingFeature(feature); if (feature) { @@ -171,6 +180,8 @@ export function EditFeatureDialog({ .map((f) => f.id); setChildDependencies(childDeps); setOriginalChildDependencies(childDeps); + // Reset pipeline exclusion state + setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []); } else { setEditFeaturePreviewMap(new Map()); setDescriptionChangeSource(null); @@ -179,6 +190,7 @@ export function EditFeatureDialog({ setParentDependencies([]); setChildDependencies([]); setOriginalChildDependencies([]); + setExcludedPipelineSteps([]); } }, [feature, allFeatures]); @@ -232,6 +244,7 @@ export function EditFeatureDialog({ workMode, dependencies: parentDependencies, childDependencies: childDepsChanged ? childDependencies : undefined, + excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined, }; // Determine if description changed and what source to use @@ -618,6 +631,16 @@ export function EditFeatureDialog({ )} + + {/* Pipeline Exclusion Controls */} +
+ +
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 996124331..07189e87b 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 @@ -13,7 +13,13 @@ import { Label } from '@/components/ui/label'; import { AlertCircle } from 'lucide-react'; import { modelSupportsThinking } from '@/lib/utils'; import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store'; -import { TestingTabContent, PrioritySelect, PlanningModeSelect, WorkModeSelector } from '../shared'; +import { + TestingTabContent, + PrioritySelect, + PlanningModeSelect, + WorkModeSelector, + PipelineExclusionControls, +} 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'; @@ -28,6 +34,7 @@ interface MassEditDialogProps { branchSuggestions: string[]; branchCardCounts?: Record; currentBranch?: string; + projectPath?: string; } interface ApplyState { @@ -38,11 +45,13 @@ interface ApplyState { priority: boolean; skipTests: boolean; branchName: boolean; + excludedPipelineSteps: boolean; } function getMixedValues(features: Feature[]): Record { if (features.length === 0) return {}; const first = features[0]; + const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []); return { model: !features.every((f) => f.model === first.model), thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel), @@ -53,6 +62,9 @@ function getMixedValues(features: Feature[]): Record { priority: !features.every((f) => f.priority === first.priority), skipTests: !features.every((f) => f.skipTests === first.skipTests), branchName: !features.every((f) => f.branchName === first.branchName), + excludedPipelineSteps: !features.every( + (f) => JSON.stringify(f.excludedPipelineSteps || []) === firstExcludedSteps + ), }; } @@ -111,6 +123,7 @@ export function MassEditDialog({ branchSuggestions, branchCardCounts, currentBranch, + projectPath, }: MassEditDialogProps) { const [isApplying, setIsApplying] = useState(false); @@ -123,6 +136,7 @@ export function MassEditDialog({ priority: false, skipTests: false, branchName: false, + excludedPipelineSteps: false, }); // Field values @@ -146,6 +160,11 @@ export function MassEditDialog({ return getInitialValue(selectedFeatures, 'branchName', '') as string; }); + // Pipeline exclusion state + const [excludedPipelineSteps, setExcludedPipelineSteps] = useState(() => { + return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]; + }); + // Calculate mixed values const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]); @@ -160,6 +179,7 @@ export function MassEditDialog({ priority: false, skipTests: false, branchName: false, + excludedPipelineSteps: false, }); setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias); setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel); @@ -172,6 +192,10 @@ export function MassEditDialog({ const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string; setBranchName(initialBranchName); setWorkMode(initialBranchName ? 'custom' : 'current'); + // Reset pipeline exclusions + setExcludedPipelineSteps( + getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[] + ); } }, [open, selectedFeatures]); @@ -190,6 +214,10 @@ export function MassEditDialog({ // For 'custom' mode, use the specified branch name updates.branchName = workMode === 'custom' ? branchName : ''; } + if (applyState.excludedPipelineSteps) { + updates.excludedPipelineSteps = + excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined; + } if (Object.keys(updates).length === 0) { onClose(); @@ -353,6 +381,23 @@ export function MassEditDialog({ testIdPrefix="mass-edit-work-mode" /> + + {/* Pipeline Exclusion */} + + setApplyState((prev) => ({ ...prev, excludedPipelineSteps: apply })) + } + > + + diff --git a/apps/ui/src/components/views/board-view/shared/index.ts b/apps/ui/src/components/views/board-view/shared/index.ts index 5fe7b4c69..f8da6b08c 100644 --- a/apps/ui/src/components/views/board-view/shared/index.ts +++ b/apps/ui/src/components/views/board-view/shared/index.ts @@ -11,3 +11,4 @@ export * from './planning-mode-select'; export * from './ancestor-context-section'; export * from './work-mode-selector'; export * from './enhancement'; +export * from './pipeline-exclusion-controls'; diff --git a/apps/ui/src/components/views/board-view/shared/pipeline-exclusion-controls.tsx b/apps/ui/src/components/views/board-view/shared/pipeline-exclusion-controls.tsx new file mode 100644 index 000000000..bc2da027a --- /dev/null +++ b/apps/ui/src/components/views/board-view/shared/pipeline-exclusion-controls.tsx @@ -0,0 +1,113 @@ +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { GitBranch, Workflow } from 'lucide-react'; +import { usePipelineConfig } from '@/hooks/queries/use-pipeline'; +import { cn } from '@/lib/utils'; + +interface PipelineExclusionControlsProps { + projectPath: string | undefined; + excludedPipelineSteps: string[]; + onExcludedStepsChange: (excludedSteps: string[]) => void; + testIdPrefix?: string; + disabled?: boolean; +} + +/** + * Component for selecting which custom pipeline steps should be excluded for a feature. + * Each pipeline step is shown as a toggleable switch, defaulting to enabled (included). + * Disabling a step adds it to the exclusion list. + */ +export function PipelineExclusionControls({ + projectPath, + excludedPipelineSteps, + onExcludedStepsChange, + testIdPrefix = 'pipeline-exclusion', + disabled = false, +}: PipelineExclusionControlsProps) { + const { data: pipelineConfig, isLoading } = usePipelineConfig(projectPath); + + // Sort steps by order + const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order); + + // If no pipeline steps exist or loading, don't render anything + if (isLoading || sortedSteps.length === 0) { + return null; + } + + const toggleStep = (stepId: string) => { + const isCurrentlyExcluded = excludedPipelineSteps.includes(stepId); + if (isCurrentlyExcluded) { + // Remove from exclusions (enable the step) + onExcludedStepsChange(excludedPipelineSteps.filter((id) => id !== stepId)); + } else { + // Add to exclusions (disable the step) + onExcludedStepsChange([...excludedPipelineSteps, stepId]); + } + }; + + const allExcluded = sortedSteps.every((step) => excludedPipelineSteps.includes(step.id)); + + return ( +
+
+ + +
+ +
+ {sortedSteps.map((step) => { + const isIncluded = !excludedPipelineSteps.includes(step.id); + return ( +
+
+
+ + {step.name} + +
+ toggleStep(step.id)} + disabled={disabled} + data-testid={`${testIdPrefix}-step-${step.id}`} + aria-label={`${isIncluded ? 'Disable' : 'Enable'} ${step.name} pipeline step`} + /> +
+ ); + })} +
+ + {allExcluded && ( +

+ + All pipeline steps disabled. Feature will skip directly to verification. +

+ )} + +

+ Enabled steps will run after implementation. Disable steps to skip them for this feature. +

+
+ ); +} diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx index 96dffb9a8..3bb1f306c 100644 --- a/apps/ui/src/components/views/graph-view-page.tsx +++ b/apps/ui/src/components/views/graph-view-page.tsx @@ -392,6 +392,7 @@ export function GraphViewPage() { currentBranch={currentWorktreeBranch || undefined} isMaximized={false} allFeatures={hookFeatures} + projectPath={currentProject?.path} /> {/* Add Feature Dialog (for spawning) */} @@ -414,6 +415,7 @@ export function GraphViewPage() { isMaximized={false} parentFeature={spawnParentFeature} allFeatures={hookFeatures} + projectPath={currentProject?.path} // When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode selectedNonMainWorktreeBranch={ addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null diff --git a/libs/types/README.md b/libs/types/README.md index 6aa77af02..62cd28bab 100644 --- a/libs/types/README.md +++ b/libs/types/README.md @@ -28,6 +28,29 @@ import type { InstallationStatus, ValidationResult, ModelDefinition, + AgentDefinition, + ReasoningEffort, + SystemPromptPreset, + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, +} from '@automaker/types'; +``` + +### Codex CLI Types + +Types for Codex CLI integration. + +```typescript +import type { + CodexSandboxMode, + CodexApprovalPolicy, + CodexCliConfig, + CodexAuthStatus, + CodexEventType, + CodexItemType, + CodexEvent, } from '@automaker/types'; ``` diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index 7ba4dc81a..b9d2664f9 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -49,6 +49,7 @@ export interface Feature { // Branch info - worktree path is derived at runtime from branchName branchName?: string; // Name of the feature branch (undefined = use current worktree) skipTests?: boolean; + excludedPipelineSteps?: string[]; // Array of pipeline step IDs to skip for this feature thinkingLevel?: ThinkingLevel; reasoningEffort?: ReasoningEffort; planningMode?: PlanningMode; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a8f2644db..a1b484341 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -19,6 +19,8 @@ export type { McpHttpServerConfig, AgentDefinition, ReasoningEffort, + // System prompt configuration for CLAUDE.md auto-loading + SystemPromptPreset, } from './provider.js'; // Provider constants and utilities @@ -34,6 +36,10 @@ export type { CodexApprovalPolicy, CodexCliConfig, CodexAuthStatus, + // Event types for CLI event parsing + CodexEventType, + CodexItemType, + CodexEvent, } from './codex.js'; export * from './codex-models.js'; diff --git a/package.json b/package.json index f7388410e..1a772c33b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "automaker", - "version": "0.12.0rc", + "version": "0.13.0", "private": true, "engines": { "node": ">=22.0.0 <23.0.0"