Merge pull request #637 from AutoMaker-Org/feature/v0.13.0rc-1768936017583-e6ni

feat: implement pipeline step exclusion functionality
This commit is contained in:
Shirone
2026-01-21 11:59:08 +00:00
committed by GitHub
20 changed files with 782 additions and 24 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@automaker/server", "name": "@automaker/server",
"version": "0.12.0", "version": "0.13.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes", "description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team", "author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",

View File

@@ -16,6 +16,16 @@ export type {
ProviderMessage, ProviderMessage,
InstallationStatus, InstallationStatus,
ModelDefinition, ModelDefinition,
AgentDefinition,
ReasoningEffort,
SystemPromptPreset,
ConversationMessage,
ContentBlock,
ValidationResult,
McpServerConfig,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
} from './types.js'; } from './types.js';
// Claude provider // Claude provider

View File

@@ -19,4 +19,7 @@ export type {
InstallationStatus, InstallationStatus,
ValidationResult, ValidationResult,
ModelDefinition, ModelDefinition,
AgentDefinition,
ReasoningEffort,
SystemPromptPreset,
} from '@automaker/types'; } from '@automaker/types';

View File

@@ -1281,7 +1281,11 @@ export class AutoModeService {
// Check for pipeline steps and execute them // Check for pipeline steps and execute them
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); 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) { if (sortedSteps.length > 0) {
// Execute pipeline steps sequentially // Execute pipeline steps sequentially
@@ -1743,15 +1747,76 @@ Complete the pipeline step instructions above. Review the previous work and appl
): Promise<void> { ): Promise<void> {
const featureId = feature.id; 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 // Get the current step we're resuming from (using the index from unfiltered list)
if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) { if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) {
throw new Error(`Invalid step index: ${startFromStepIndex}`); throw new Error(`Invalid step index: ${startFromStepIndex}`);
} }
const currentStep = allSortedSteps[startFromStepIndex];
// Get steps to execute (from startFromStepIndex onwards) // Filter out excluded pipeline steps
const stepsToExecute = sortedSteps.slice(startFromStepIndex); 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( console.log(
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}` `[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`

View File

@@ -234,51 +234,75 @@ export class PipelineService {
* *
* Determines what status a feature should transition to based on current status. * Determines what status a feature should transition to based on current status.
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final 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 currentStatus - Current feature status
* @param config - Pipeline configuration (or null if no pipeline) * @param config - Pipeline configuration (or null if no pipeline)
* @param skipTests - Whether to skip tests (affects final status) * @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 * @returns The next status in the pipeline flow
*/ */
getNextStatus( getNextStatus(
currentStatus: FeatureStatusWithPipeline, currentStatus: FeatureStatusWithPipeline,
config: PipelineConfig | null, config: PipelineConfig | null,
skipTests: boolean skipTests: boolean,
excludedStepIds?: string[]
): FeatureStatusWithPipeline { ): FeatureStatusWithPipeline {
const steps = config?.steps || []; const steps = config?.steps || [];
const exclusions = new Set(excludedStepIds || []);
// Sort steps by order // Sort steps by order and filter out excluded steps
const sortedSteps = [...steps].sort((a, b) => a.order - b.order); 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 (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 skipTests ? 'waiting_approval' : 'verified';
} }
return currentStatus; 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') { if (currentStatus === 'in_progress') {
return `pipeline_${sortedSteps[0].id}`; 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_')) { if (currentStatus.startsWith('pipeline_')) {
const currentStepId = currentStatus.replace('pipeline_', ''); const currentStepId = currentStatus.replace('pipeline_', '');
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId); const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
if (currentIndex === -1) { 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'; return skipTests ? 'waiting_approval' : 'verified';
} }
if (currentIndex < sortedSteps.length - 1) { if (currentIndex < sortedSteps.length - 1) {
// Go to next step // Go to next non-excluded step
return `pipeline_${sortedSteps[currentIndex + 1].id}`; 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'; return skipTests ? 'waiting_approval' : 'verified';
} }

View File

@@ -788,6 +788,367 @@ describe('pipeline-service.ts', () => {
const nextStatus = pipelineService.getNextStatus('in_progress', config, false); const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2 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', () => { describe('getStep', () => {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@automaker/ui", "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", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker", "homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": { "repository": {

View File

@@ -1489,6 +1489,7 @@ export function BoardView() {
branchSuggestions={branchSuggestions} branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts} branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined} currentBranch={currentWorktreeBranch || undefined}
projectPath={currentProject?.path}
/> />
{/* Board Background Modal */} {/* Board Background Modal */}
@@ -1538,6 +1539,7 @@ export function BoardView() {
isMaximized={isMaximized} isMaximized={isMaximized}
parentFeature={spawnParentFeature} parentFeature={spawnParentFeature}
allFeatures={hookFeatures} 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 // When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
selectedNonMainWorktreeBranch={ selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
@@ -1568,6 +1570,7 @@ export function BoardView() {
currentBranch={currentWorktreeBranch || undefined} currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized} isMaximized={isMaximized}
allFeatures={hookFeatures} allFeatures={hookFeatures}
projectPath={currentProject?.path}
/> />
{/* Agent Output Modal */} {/* Agent Output Modal */}

View File

@@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store'; import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 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 { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
/** Uniform badge style for all card badges */ /** Uniform badge style for all card badges */
const uniformBadgeClass = const uniformBadgeClass =
@@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
interface PriorityBadgesProps { interface PriorityBadgesProps {
feature: Feature; 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( const { enableDependencyBlocking, features } = useAppStore(
useShallow((state) => ({ useShallow((state) => ({
enableDependencyBlocking: state.enableDependencyBlocking, enableDependencyBlocking: state.enableDependencyBlocking,
@@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
); );
const [currentTime, setCurrentTime] = useState(() => Date.now()); 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) // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => { const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== 'backlog') { if (!enableDependencyBlocking || feature.status !== 'backlog') {
@@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
const showManualVerification = const showManualVerification =
feature.skipTests && !feature.error && feature.status === 'backlog'; 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) { if (!showBadges) {
return null; return null;
@@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
{/* Pipeline exclusion badge */}
{hasPipelineExclusions && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
allPipelinesExcluded
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
)}
data-testid={`pipeline-exclusion-badge-${feature.id}`}
>
<SkipForward className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
{allPipelinesExcluded
? 'All pipelines skipped'
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
</p>
<p className="text-muted-foreground">
{allPipelinesExcluded
? 'This feature will skip all custom pipeline steps'
: 'Some custom pipeline steps will be skipped for this feature'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
); );
}); });

View File

@@ -236,7 +236,7 @@ export const KanbanCard = memo(function KanbanCard({
</div> </div>
{/* Priority and Manual Verification badges */} {/* Priority and Manual Verification badges */}
<PriorityBadges feature={feature} /> <PriorityBadges feature={feature} projectPath={currentProject?.path} />
{/* Card Header */} {/* Card Header */}
<CardHeaderSection <CardHeaderSection

View File

@@ -45,6 +45,7 @@ import {
AncestorContextSection, AncestorContextSection,
EnhanceWithAI, EnhanceWithAI,
EnhancementHistoryButton, EnhancementHistoryButton,
PipelineExclusionControls,
type BaseHistoryEntry, type BaseHistoryEntry,
} from '../shared'; } from '../shared';
import type { WorkMode } from '../shared'; import type { WorkMode } from '../shared';
@@ -101,6 +102,7 @@ type FeatureData = {
requirePlanApproval: boolean; requirePlanApproval: boolean;
dependencies?: string[]; dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature childDependencies?: string[]; // Feature IDs that should depend on this feature
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
workMode: WorkMode; workMode: WorkMode;
}; };
@@ -118,6 +120,10 @@ interface AddFeatureDialogProps {
isMaximized: boolean; isMaximized: boolean;
parentFeature?: Feature | null; parentFeature?: Feature | null;
allFeatures?: Feature[]; allFeatures?: Feature[];
/**
* Path to the current project for loading pipeline config.
*/
projectPath?: string;
/** /**
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch. * When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
* When set, the dialog will default to 'custom' work mode with this branch pre-filled. * When set, the dialog will default to 'custom' work mode with this branch pre-filled.
@@ -151,6 +157,7 @@ export function AddFeatureDialog({
isMaximized, isMaximized,
parentFeature = null, parentFeature = null,
allFeatures = [], allFeatures = [],
projectPath,
selectedNonMainWorktreeBranch, selectedNonMainWorktreeBranch,
forceCurrentBranchMode, forceCurrentBranchMode,
}: AddFeatureDialogProps) { }: AddFeatureDialogProps) {
@@ -194,6 +201,9 @@ export function AddFeatureDialog({
const [parentDependencies, setParentDependencies] = useState<string[]>([]); const [parentDependencies, setParentDependencies] = useState<string[]>([]);
const [childDependencies, setChildDependencies] = useState<string[]>([]); const [childDependencies, setChildDependencies] = useState<string[]>([]);
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>([]);
// Get defaults from store // Get defaults from store
const { const {
defaultPlanningMode, defaultPlanningMode,
@@ -242,6 +252,9 @@ export function AddFeatureDialog({
// Reset dependency selections // Reset dependency selections
setParentDependencies([]); setParentDependencies([]);
setChildDependencies([]); setChildDependencies([]);
// Reset pipeline exclusions (all pipelines enabled by default)
setExcludedPipelineSteps([]);
} }
}, [ }, [
open, open,
@@ -336,6 +349,7 @@ export function AddFeatureDialog({
requirePlanApproval, requirePlanApproval,
dependencies: finalDependencies, dependencies: finalDependencies,
childDependencies: childDependencies.length > 0 ? childDependencies : undefined, childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
workMode, workMode,
}; };
}; };
@@ -362,6 +376,7 @@ export function AddFeatureDialog({
setDescriptionHistory([]); setDescriptionHistory([]);
setParentDependencies([]); setParentDependencies([]);
setChildDependencies([]); setChildDependencies([]);
setExcludedPipelineSteps([]);
onOpenChange(false); onOpenChange(false);
}; };
@@ -704,6 +719,16 @@ export function AddFeatureDialog({
</div> </div>
</div> </div>
)} )}
{/* Pipeline Exclusion Controls */}
<div className="pt-2">
<PipelineExclusionControls
projectPath={projectPath}
excludedPipelineSteps={excludedPipelineSteps}
onExcludedStepsChange={setExcludedPipelineSteps}
testIdPrefix="add-feature-pipeline"
/>
</div>
</div> </div>
</div> </div>

View File

@@ -36,6 +36,7 @@ import {
PlanningModeSelect, PlanningModeSelect,
EnhanceWithAI, EnhanceWithAI,
EnhancementHistoryButton, EnhancementHistoryButton,
PipelineExclusionControls,
type EnhancementMode, type EnhancementMode,
} from '../shared'; } from '../shared';
import type { WorkMode } from '../shared'; import type { WorkMode } from '../shared';
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
requirePlanApproval: boolean; requirePlanApproval: boolean;
dependencies?: string[]; dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature childDependencies?: string[]; // Feature IDs that should depend on this feature
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
}, },
descriptionHistorySource?: 'enhance' | 'edit', descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: EnhancementMode, enhancementMode?: EnhancementMode,
@@ -78,6 +80,7 @@ interface EditFeatureDialogProps {
currentBranch?: string; currentBranch?: string;
isMaximized: boolean; isMaximized: boolean;
allFeatures: Feature[]; allFeatures: Feature[];
projectPath?: string;
} }
export function EditFeatureDialog({ export function EditFeatureDialog({
@@ -90,6 +93,7 @@ export function EditFeatureDialog({
currentBranch, currentBranch,
isMaximized, isMaximized,
allFeatures, allFeatures,
projectPath,
}: EditFeatureDialogProps) { }: EditFeatureDialogProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature); const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
@@ -146,6 +150,11 @@ export function EditFeatureDialog({
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id); return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
}); });
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(
feature?.excludedPipelineSteps ?? []
);
useEffect(() => { useEffect(() => {
setEditingFeature(feature); setEditingFeature(feature);
if (feature) { if (feature) {
@@ -171,6 +180,8 @@ export function EditFeatureDialog({
.map((f) => f.id); .map((f) => f.id);
setChildDependencies(childDeps); setChildDependencies(childDeps);
setOriginalChildDependencies(childDeps); setOriginalChildDependencies(childDeps);
// Reset pipeline exclusion state
setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []);
} else { } else {
setEditFeaturePreviewMap(new Map()); setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null); setDescriptionChangeSource(null);
@@ -179,6 +190,7 @@ export function EditFeatureDialog({
setParentDependencies([]); setParentDependencies([]);
setChildDependencies([]); setChildDependencies([]);
setOriginalChildDependencies([]); setOriginalChildDependencies([]);
setExcludedPipelineSteps([]);
} }
}, [feature, allFeatures]); }, [feature, allFeatures]);
@@ -232,6 +244,7 @@ export function EditFeatureDialog({
workMode, workMode,
dependencies: parentDependencies, dependencies: parentDependencies,
childDependencies: childDepsChanged ? childDependencies : undefined, childDependencies: childDepsChanged ? childDependencies : undefined,
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
}; };
// Determine if description changed and what source to use // Determine if description changed and what source to use
@@ -618,6 +631,16 @@ export function EditFeatureDialog({
</div> </div>
</div> </div>
)} )}
{/* Pipeline Exclusion Controls */}
<div className="pt-2">
<PipelineExclusionControls
projectPath={projectPath}
excludedPipelineSteps={excludedPipelineSteps}
onExcludedStepsChange={setExcludedPipelineSteps}
testIdPrefix="edit-feature-pipeline"
/>
</div>
</div> </div>
</div> </div>

View File

@@ -13,7 +13,13 @@ import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils'; import { modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store'; 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 type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types'; import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
@@ -28,6 +34,7 @@ interface MassEditDialogProps {
branchSuggestions: string[]; branchSuggestions: string[];
branchCardCounts?: Record<string, number>; branchCardCounts?: Record<string, number>;
currentBranch?: string; currentBranch?: string;
projectPath?: string;
} }
interface ApplyState { interface ApplyState {
@@ -38,11 +45,13 @@ interface ApplyState {
priority: boolean; priority: boolean;
skipTests: boolean; skipTests: boolean;
branchName: boolean; branchName: boolean;
excludedPipelineSteps: boolean;
} }
function getMixedValues(features: Feature[]): Record<string, boolean> { function getMixedValues(features: Feature[]): Record<string, boolean> {
if (features.length === 0) return {}; if (features.length === 0) return {};
const first = features[0]; const first = features[0];
const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []);
return { return {
model: !features.every((f) => f.model === first.model), model: !features.every((f) => f.model === first.model),
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel), thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
@@ -53,6 +62,9 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
priority: !features.every((f) => f.priority === first.priority), priority: !features.every((f) => f.priority === first.priority),
skipTests: !features.every((f) => f.skipTests === first.skipTests), skipTests: !features.every((f) => f.skipTests === first.skipTests),
branchName: !features.every((f) => f.branchName === first.branchName), 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, branchSuggestions,
branchCardCounts, branchCardCounts,
currentBranch, currentBranch,
projectPath,
}: MassEditDialogProps) { }: MassEditDialogProps) {
const [isApplying, setIsApplying] = useState(false); const [isApplying, setIsApplying] = useState(false);
@@ -123,6 +136,7 @@ export function MassEditDialog({
priority: false, priority: false,
skipTests: false, skipTests: false,
branchName: false, branchName: false,
excludedPipelineSteps: false,
}); });
// Field values // Field values
@@ -146,6 +160,11 @@ export function MassEditDialog({
return getInitialValue(selectedFeatures, 'branchName', '') as string; return getInitialValue(selectedFeatures, 'branchName', '') as string;
}); });
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(() => {
return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[];
});
// Calculate mixed values // Calculate mixed values
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]); const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
@@ -160,6 +179,7 @@ export function MassEditDialog({
priority: false, priority: false,
skipTests: false, skipTests: false,
branchName: false, branchName: false,
excludedPipelineSteps: false,
}); });
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias); setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel); setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
@@ -172,6 +192,10 @@ export function MassEditDialog({
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string; const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
setBranchName(initialBranchName); setBranchName(initialBranchName);
setWorkMode(initialBranchName ? 'custom' : 'current'); setWorkMode(initialBranchName ? 'custom' : 'current');
// Reset pipeline exclusions
setExcludedPipelineSteps(
getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]
);
} }
}, [open, selectedFeatures]); }, [open, selectedFeatures]);
@@ -190,6 +214,10 @@ export function MassEditDialog({
// For 'custom' mode, use the specified branch name // For 'custom' mode, use the specified branch name
updates.branchName = workMode === 'custom' ? branchName : ''; updates.branchName = workMode === 'custom' ? branchName : '';
} }
if (applyState.excludedPipelineSteps) {
updates.excludedPipelineSteps =
excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined;
}
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length === 0) {
onClose(); onClose();
@@ -353,6 +381,23 @@ export function MassEditDialog({
testIdPrefix="mass-edit-work-mode" testIdPrefix="mass-edit-work-mode"
/> />
</FieldWrapper> </FieldWrapper>
{/* Pipeline Exclusion */}
<FieldWrapper
label="Pipeline Steps"
isMixed={mixedValues.excludedPipelineSteps}
willApply={applyState.excludedPipelineSteps}
onApplyChange={(apply) =>
setApplyState((prev) => ({ ...prev, excludedPipelineSteps: apply }))
}
>
<PipelineExclusionControls
projectPath={projectPath}
excludedPipelineSteps={excludedPipelineSteps}
onExcludedStepsChange={setExcludedPipelineSteps}
testIdPrefix="mass-edit-pipeline"
/>
</FieldWrapper>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@@ -11,3 +11,4 @@ export * from './planning-mode-select';
export * from './ancestor-context-section'; export * from './ancestor-context-section';
export * from './work-mode-selector'; export * from './work-mode-selector';
export * from './enhancement'; export * from './enhancement';
export * from './pipeline-exclusion-controls';

View File

@@ -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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Workflow className="w-4 h-4 text-muted-foreground" />
<Label className="text-sm font-medium">Custom Pipeline Steps</Label>
</div>
<div className="space-y-2">
{sortedSteps.map((step) => {
const isIncluded = !excludedPipelineSteps.includes(step.id);
return (
<div
key={step.id}
className={cn(
'flex items-center justify-between gap-3 px-3 py-2 rounded-md border',
isIncluded
? 'border-border/50 bg-muted/30'
: 'border-border/30 bg-muted/10 opacity-60'
)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div
className={cn(
'w-2 h-2 rounded-full flex-shrink-0',
step.colorClass || 'bg-gray-400'
)}
style={{
backgroundColor: step.colorClass?.startsWith('#') ? step.colorClass : undefined,
}}
/>
<span
className={cn(
'text-sm truncate',
isIncluded ? 'text-foreground' : 'text-muted-foreground'
)}
>
{step.name}
</span>
</div>
<Switch
checked={isIncluded}
onCheckedChange={() => toggleStep(step.id)}
disabled={disabled}
data-testid={`${testIdPrefix}-step-${step.id}`}
aria-label={`${isIncluded ? 'Disable' : 'Enable'} ${step.name} pipeline step`}
/>
</div>
);
})}
</div>
{allExcluded && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5" />
All pipeline steps disabled. Feature will skip directly to verification.
</p>
)}
<p className="text-xs text-muted-foreground">
Enabled steps will run after implementation. Disable steps to skip them for this feature.
</p>
</div>
);
}

View File

@@ -392,6 +392,7 @@ export function GraphViewPage() {
currentBranch={currentWorktreeBranch || undefined} currentBranch={currentWorktreeBranch || undefined}
isMaximized={false} isMaximized={false}
allFeatures={hookFeatures} allFeatures={hookFeatures}
projectPath={currentProject?.path}
/> />
{/* Add Feature Dialog (for spawning) */} {/* Add Feature Dialog (for spawning) */}
@@ -414,6 +415,7 @@ export function GraphViewPage() {
isMaximized={false} isMaximized={false}
parentFeature={spawnParentFeature} parentFeature={spawnParentFeature}
allFeatures={hookFeatures} 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 // When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
selectedNonMainWorktreeBranch={ selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null

View File

@@ -28,6 +28,29 @@ import type {
InstallationStatus, InstallationStatus,
ValidationResult, ValidationResult,
ModelDefinition, 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'; } from '@automaker/types';
``` ```

View File

@@ -49,6 +49,7 @@ export interface Feature {
// Branch info - worktree path is derived at runtime from branchName // Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree) branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean; skipTests?: boolean;
excludedPipelineSteps?: string[]; // Array of pipeline step IDs to skip for this feature
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
planningMode?: PlanningMode; planningMode?: PlanningMode;

View File

@@ -19,6 +19,8 @@ export type {
McpHttpServerConfig, McpHttpServerConfig,
AgentDefinition, AgentDefinition,
ReasoningEffort, ReasoningEffort,
// System prompt configuration for CLAUDE.md auto-loading
SystemPromptPreset,
} from './provider.js'; } from './provider.js';
// Provider constants and utilities // Provider constants and utilities
@@ -34,6 +36,10 @@ export type {
CodexApprovalPolicy, CodexApprovalPolicy,
CodexCliConfig, CodexCliConfig,
CodexAuthStatus, CodexAuthStatus,
// Event types for CLI event parsing
CodexEventType,
CodexItemType,
CodexEvent,
} from './codex.js'; } from './codex.js';
export * from './codex-models.js'; export * from './codex-models.js';

View File

@@ -1,6 +1,6 @@
{ {
"name": "automaker", "name": "automaker",
"version": "0.12.0rc", "version": "0.13.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=22.0.0 <23.0.0" "node": ">=22.0.0 <23.0.0"