feat: implement pipeline feature for automated workflow steps

- Introduced a new pipeline service to manage custom workflow steps that execute after a feature is marked "In Progress".
- Added API endpoints for configuring, saving, adding, updating, deleting, and reordering pipeline steps.
- Enhanced the UI to support pipeline settings, including a dialog for managing steps and integration with the Kanban board.
- Updated the application state management to handle pipeline configurations per project.
- Implemented dynamic column generation in the Kanban board to display pipeline steps between "In Progress" and "Waiting Approval".
- Added documentation for the new pipeline feature, including usage instructions and configuration details.

This feature allows for a more structured workflow, enabling automated processes such as code reviews and testing after feature implementation.
This commit is contained in:
Test User
2025-12-27 23:57:15 -05:00
parent 4a708aa305
commit e9b366fa18
28 changed files with 2409 additions and 61 deletions

172
CLAUDE.md Normal file
View File

@@ -0,0 +1,172 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees.
## Common Commands
```bash
# Development
npm run dev # Interactive launcher (choose web or electron)
npm run dev:web # Web browser mode (localhost:3007)
npm run dev:electron # Desktop app mode
npm run dev:electron:debug # Desktop with DevTools open
# Building
npm run build # Build web application
npm run build:packages # Build all shared packages (required before other builds)
npm run build:electron # Build desktop app for current platform
npm run build:server # Build server only
# Testing
npm run test # E2E tests (Playwright, headless)
npm run test:headed # E2E tests with browser visible
npm run test:server # Server unit tests (Vitest)
npm run test:packages # All shared package tests
npm run test:all # All tests (packages + server)
# Single test file
npm run test:server -- tests/unit/specific.test.ts
# Linting and formatting
npm run lint # ESLint
npm run format # Prettier write
npm run format:check # Prettier check
```
## Architecture
### Monorepo Structure
```
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend (port 3007)
│ └── server/ # Express + WebSocket backend (port 3008)
└── libs/ # Shared packages (@automaker/*)
├── types/ # Core TypeScript definitions (no dependencies)
├── utils/ # Logging, errors, image processing, context loading
├── prompts/ # AI prompt templates
├── platform/ # Path management, security, process spawning
├── model-resolver/ # Claude model alias resolution
├── dependency-resolver/ # Feature dependency ordering
└── git-utils/ # Git operations & worktree management
```
### Package Dependency Chain
Packages can only depend on packages above them:
```
@automaker/types (no dependencies)
@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver
@automaker/git-utils
@automaker/server, @automaker/ui
```
### Key Technologies
- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4
- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty
- **Testing**: Playwright (E2E), Vitest (unit)
### Server Architecture
The server (`apps/server/src/`) follows a modular pattern:
- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.)
- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService)
- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK)
- `lib/` - Utilities (events, auth, worktree metadata)
### Frontend Architecture
The UI (`apps/ui/src/`) uses:
- `routes/` - TanStack Router file-based routing
- `components/views/` - Main view components (board, settings, terminal, etc.)
- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts)
- `hooks/` - Custom React hooks
- `lib/` - Utilities and API client
## Data Storage
### Per-Project Data (`.automaker/`)
```
.automaker/
├── features/ # Feature JSON files and images
│ └── {featureId}/
│ ├── feature.json
│ ├── agent-output.md
│ └── images/
├── context/ # Context files for AI agents (CLAUDE.md, etc.)
├── settings.json # Project-specific settings
├── spec.md # Project specification
└── analysis.json # Project structure analysis
```
### Global Data (`DATA_DIR`, default `./data`)
```
data/
├── settings.json # Global settings, profiles, shortcuts
├── credentials.json # API keys
├── sessions-metadata.json # Chat session metadata
└── agent-sessions/ # Conversation histories
```
## Import Conventions
Always import from shared packages, never from old paths:
```typescript
// ✅ Correct
import type { Feature, ExecuteOptions } from '@automaker/types';
import { createLogger, classifyError } from '@automaker/utils';
import { getEnhancementPrompt } from '@automaker/prompts';
import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform';
import { resolveModelString } from '@automaker/model-resolver';
import { resolveDependencies } from '@automaker/dependency-resolver';
import { getGitRepositoryDiffs } from '@automaker/git-utils';
// ❌ Never import from old paths
import { Feature } from '../services/feature-loader'; // Wrong
import { createLogger } from '../lib/logger'; // Wrong
```
## Key Patterns
### Event-Driven Architecture
All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`.
### Git Worktree Isolation
Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution.
### Context Files
Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`.
### Model Resolution
Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases:
- `haiku``claude-haiku-4-5`
- `sonnet``claude-sonnet-4-20250514`
- `opus``claude-opus-4-5-20251101`
## Environment Variables
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing

View File

@@ -50,6 +50,8 @@ import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
import { createPipelineRoutes } from './routes/pipeline/index.js';
import { pipelineService } from './services/pipeline-service.js';
// Load environment variables
dotenv.config();
@@ -162,6 +164,7 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
// Create HTTP server
const server = createServer(app);

View File

@@ -0,0 +1,21 @@
/**
* Common utilities for pipeline routes
*
* Provides logger and error handling utilities shared across all pipeline endpoints.
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for pipeline-related operations */
export const logger = createLogger('Pipeline');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,77 @@
/**
* Pipeline routes - HTTP API for pipeline configuration management
*
* Provides endpoints for:
* - Getting pipeline configuration
* - Saving pipeline configuration
* - Adding, updating, deleting, and reordering pipeline steps
*
* All endpoints use handler factories that receive the PipelineService instance.
* Mounted at /api/pipeline in the main server.
*/
import { Router } from 'express';
import type { PipelineService } from '../../services/pipeline-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGetConfigHandler } from './routes/get-config.js';
import { createSaveConfigHandler } from './routes/save-config.js';
import { createAddStepHandler } from './routes/add-step.js';
import { createUpdateStepHandler } from './routes/update-step.js';
import { createDeleteStepHandler } from './routes/delete-step.js';
import { createReorderStepsHandler } from './routes/reorder-steps.js';
/**
* Create pipeline router with all endpoints
*
* Endpoints:
* - POST /config - Get pipeline configuration
* - POST /config/save - Save entire pipeline configuration
* - POST /steps/add - Add a new pipeline step
* - POST /steps/update - Update an existing pipeline step
* - POST /steps/delete - Delete a pipeline step
* - POST /steps/reorder - Reorder pipeline steps
*
* @param pipelineService - Instance of PipelineService for file I/O
* @returns Express Router configured with all pipeline endpoints
*/
export function createPipelineRoutes(pipelineService: PipelineService): Router {
const router = Router();
// Get pipeline configuration
router.post(
'/config',
validatePathParams('projectPath'),
createGetConfigHandler(pipelineService)
);
// Save entire pipeline configuration
router.post(
'/config/save',
validatePathParams('projectPath'),
createSaveConfigHandler(pipelineService)
);
// Pipeline step operations
router.post(
'/steps/add',
validatePathParams('projectPath'),
createAddStepHandler(pipelineService)
);
router.post(
'/steps/update',
validatePathParams('projectPath'),
createUpdateStepHandler(pipelineService)
);
router.post(
'/steps/delete',
validatePathParams('projectPath'),
createDeleteStepHandler(pipelineService)
);
router.post(
'/steps/reorder',
validatePathParams('projectPath'),
createReorderStepsHandler(pipelineService)
);
return router;
}

View File

@@ -0,0 +1,54 @@
/**
* POST /api/pipeline/steps/add - Add a new pipeline step
*
* Adds a new step to the pipeline configuration.
*
* Request body: { projectPath: string, step: { name, order, instructions, colorClass } }
* Response: { success: true, step: PipelineStep }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineStep } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createAddStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, step } = req.body as {
projectPath: string;
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!step) {
res.status(400).json({ success: false, error: 'step is required' });
return;
}
if (!step.name) {
res.status(400).json({ success: false, error: 'step.name is required' });
return;
}
if (step.instructions === undefined) {
res.status(400).json({ success: false, error: 'step.instructions is required' });
return;
}
const newStep = await pipelineService.addStep(projectPath, step);
res.json({
success: true,
step: newStep,
});
} catch (error) {
logError(error, 'Add pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,42 @@
/**
* POST /api/pipeline/steps/delete - Delete a pipeline step
*
* Removes a step from the pipeline configuration.
*
* Request body: { projectPath: string, stepId: string }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepId } = req.body as {
projectPath: string;
stepId: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepId) {
res.status(400).json({ success: false, error: 'stepId is required' });
return;
}
await pipelineService.deleteStep(projectPath, stepId);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Delete pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,35 @@
/**
* POST /api/pipeline/config - Get pipeline configuration
*
* Returns the pipeline configuration for a project.
*
* Request body: { projectPath: string }
* Response: { success: true, config: PipelineConfig }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetConfigHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const config = await pipelineService.getPipelineConfig(projectPath);
res.json({
success: true,
config,
});
} catch (error) {
logError(error, 'Get pipeline config failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,42 @@
/**
* POST /api/pipeline/steps/reorder - Reorder pipeline steps
*
* Reorders the steps in the pipeline configuration.
*
* Request body: { projectPath: string, stepIds: string[] }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createReorderStepsHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepIds } = req.body as {
projectPath: string;
stepIds: string[];
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepIds || !Array.isArray(stepIds)) {
res.status(400).json({ success: false, error: 'stepIds array is required' });
return;
}
await pipelineService.reorderSteps(projectPath, stepIds);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Reorder pipeline steps failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,43 @@
/**
* POST /api/pipeline/config/save - Save entire pipeline configuration
*
* Saves the complete pipeline configuration for a project.
*
* Request body: { projectPath: string, config: PipelineConfig }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineConfig } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createSaveConfigHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, config } = req.body as {
projectPath: string;
config: PipelineConfig;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!config) {
res.status(400).json({ success: false, error: 'config is required' });
return;
}
await pipelineService.savePipelineConfig(projectPath, config);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Save pipeline config failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,50 @@
/**
* POST /api/pipeline/steps/update - Update an existing pipeline step
*
* Updates a step in the pipeline configuration.
*
* Request body: { projectPath: string, stepId: string, updates: Partial<PipelineStep> }
* Response: { success: true, step: PipelineStep }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineStep } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createUpdateStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepId, updates } = req.body as {
projectPath: string;
stepId: string;
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepId) {
res.status(400).json({ success: false, error: 'stepId is required' });
return;
}
if (!updates || Object.keys(updates).length === 0) {
res.status(400).json({ success: false, error: 'updates is required' });
return;
}
const updatedStep = await pipelineService.updateStep(projectPath, stepId, updates);
res.json({
success: true,
step: updatedStep,
});
} catch (error) {
logError(error, 'Update pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -10,7 +10,7 @@
*/
import { ProviderFactory } from '../providers/provider-factory.js';
import type { ExecuteOptions, Feature } from '@automaker/types';
import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types';
import {
buildPromptWithImages,
isAbortError,
@@ -32,6 +32,7 @@ import {
} from '../lib/sdk-options.js';
import { FeatureLoader } from './feature-loader.js';
import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
@@ -631,6 +632,23 @@ 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);
if (sortedSteps.length > 0) {
// Execute pipeline steps sequentially
await this.executePipelineSteps(
projectPath,
featureId,
feature,
sortedSteps,
workDir,
abortController,
autoLoadClaudeMd
);
}
// Determine final status based on testing mode:
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
@@ -674,6 +692,143 @@ export class AutoModeService {
}
}
/**
* Execute pipeline steps sequentially after initial feature implementation
*/
private async executePipelineSteps(
projectPath: string,
featureId: string,
feature: Feature,
steps: PipelineStep[],
workDir: string,
abortController: AbortController,
autoLoadClaudeMd: boolean
): Promise<void> {
console.log(`[AutoMode] Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
// Load context files once
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Load previous agent output for context continuity
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let previousContext = '';
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No previous context
}
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const pipelineStatus = `pipeline_${step.id}`;
// Update feature status to current pipeline step
await this.updateFeatureStatus(projectPath, featureId, pipelineStatus);
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
projectPath,
});
this.emitAutoModeEvent('pipeline_step_started', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
// Build prompt for this pipeline step
const prompt = this.buildPipelineStepPrompt(step, feature, previousContext);
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
// Run the agent for this pipeline step
await this.runAgent(
workDir,
featureId,
prompt,
abortController,
projectPath,
undefined, // no images for pipeline steps
model,
{
projectPath,
planningMode: 'skip', // Pipeline steps don't need planning
requirePlanApproval: false,
previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
}
);
// Load updated context for next step
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No context update
}
this.emitAutoModeEvent('pipeline_step_complete', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
console.log(
`[AutoMode] Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
);
}
console.log(`[AutoMode] All pipeline steps completed for feature ${featureId}`);
}
/**
* Build the prompt for a pipeline step
*/
private buildPipelineStepPrompt(
step: PipelineStep,
feature: Feature,
previousContext: string
): string {
let prompt = `## Pipeline Step: ${step.name}
This is an automated pipeline step following the initial feature implementation.
### Feature Context
${this.buildFeaturePrompt(feature)}
`;
if (previousContext) {
prompt += `### Previous Work
The following is the output from the previous work on this feature:
${previousContext}
`;
}
prompt += `### Pipeline Step Instructions
${step.instructions}
### Task
Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`;
return prompt;
}
/**
* Stop a specific feature
*/

View File

@@ -0,0 +1,320 @@
/**
* Pipeline Service - Handles reading/writing pipeline configuration
*
* Provides persistent storage for:
* - Pipeline configuration ({projectPath}/.automaker/pipeline.json)
*/
import path from 'path';
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import { ensureAutomakerDir } from '@automaker/platform';
import type { PipelineConfig, PipelineStep, FeatureStatusWithPipeline } from '@automaker/types';
const logger = createLogger('PipelineService');
// Default empty pipeline config
const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
version: 1,
steps: [],
};
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Generate a unique ID for pipeline steps
*/
function generateStepId(): string {
return `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
}
/**
* Get the pipeline config file path for a project
*/
function getPipelineConfigPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'pipeline.json');
}
/**
* PipelineService - Manages pipeline configuration for workflow automation
*
* Handles reading and writing pipeline config to JSON files with atomic operations.
* Pipeline steps define custom columns that appear between "in_progress" and
* "waiting_approval/verified" columns in the kanban board.
*/
export class PipelineService {
/**
* Get pipeline configuration for a project
*
* @param projectPath - Absolute path to the project
* @returns Promise resolving to PipelineConfig (empty steps array if no config exists)
*/
async getPipelineConfig(projectPath: string): Promise<PipelineConfig> {
const configPath = getPipelineConfigPath(projectPath);
const config = await readJsonFile<PipelineConfig>(configPath, DEFAULT_PIPELINE_CONFIG);
// Ensure version is set
return {
...DEFAULT_PIPELINE_CONFIG,
...config,
};
}
/**
* Save entire pipeline configuration
*
* @param projectPath - Absolute path to the project
* @param config - Complete PipelineConfig to save
*/
async savePipelineConfig(projectPath: string, config: PipelineConfig): Promise<void> {
await ensureAutomakerDir(projectPath);
const configPath = getPipelineConfigPath(projectPath);
await atomicWriteJson(configPath, config);
logger.info(`Pipeline config saved for project: ${projectPath}`);
}
/**
* Add a new pipeline step
*
* @param projectPath - Absolute path to the project
* @param step - Step data (without id, createdAt, updatedAt)
* @returns Promise resolving to the created PipelineStep
*/
async addStep(
projectPath: string,
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
): Promise<PipelineStep> {
const config = await this.getPipelineConfig(projectPath);
const now = new Date().toISOString();
const newStep: PipelineStep = {
...step,
id: generateStepId(),
createdAt: now,
updatedAt: now,
};
config.steps.push(newStep);
// Normalize order values
config.steps.sort((a, b) => a.order - b.order);
config.steps.forEach((s, index) => {
s.order = index;
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step added: ${newStep.name} (${newStep.id})`);
return newStep;
}
/**
* Update an existing pipeline step
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to update
* @param updates - Partial step data to merge
*/
async updateStep(
projectPath: string,
stepId: string,
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
): Promise<PipelineStep> {
const config = await this.getPipelineConfig(projectPath);
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
if (stepIndex === -1) {
throw new Error(`Pipeline step not found: ${stepId}`);
}
config.steps[stepIndex] = {
...config.steps[stepIndex],
...updates,
updatedAt: new Date().toISOString(),
};
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step updated: ${stepId}`);
return config.steps[stepIndex];
}
/**
* Delete a pipeline step
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to delete
*/
async deleteStep(projectPath: string, stepId: string): Promise<void> {
const config = await this.getPipelineConfig(projectPath);
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
if (stepIndex === -1) {
throw new Error(`Pipeline step not found: ${stepId}`);
}
config.steps.splice(stepIndex, 1);
// Normalize order values after deletion
config.steps.forEach((s, index) => {
s.order = index;
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step deleted: ${stepId}`);
}
/**
* Reorder pipeline steps
*
* @param projectPath - Absolute path to the project
* @param stepIds - Array of step IDs in the desired order
*/
async reorderSteps(projectPath: string, stepIds: string[]): Promise<void> {
const config = await this.getPipelineConfig(projectPath);
// Validate all step IDs exist
const existingIds = new Set(config.steps.map((s) => s.id));
for (const id of stepIds) {
if (!existingIds.has(id)) {
throw new Error(`Pipeline step not found: ${id}`);
}
}
// Create a map for quick lookup
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
// Reorder steps based on stepIds array
config.steps = stepIds.map((id, index) => {
const step = stepMap.get(id)!;
return { ...step, order: index, updatedAt: new Date().toISOString() };
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline steps reordered`);
}
/**
* Get the next status in the pipeline flow
*
* Determines what status a feature should transition to based on current status.
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
*
* @param currentStatus - Current feature status
* @param config - Pipeline configuration (or null if no pipeline)
* @param skipTests - Whether to skip tests (affects final status)
* @returns The next status in the pipeline flow
*/
getNextStatus(
currentStatus: FeatureStatusWithPipeline,
config: PipelineConfig | null,
skipTests: boolean
): FeatureStatusWithPipeline {
const steps = config?.steps || [];
// Sort steps by order
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
// If no pipeline steps, use original logic
if (sortedSteps.length === 0) {
if (currentStatus === 'in_progress') {
return skipTests ? 'waiting_approval' : 'verified';
}
return currentStatus;
}
// Coming from in_progress -> go to first pipeline step
if (currentStatus === 'in_progress') {
return `pipeline_${sortedSteps[0].id}`;
}
// Coming from a pipeline step -> go to next 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
return skipTests ? 'waiting_approval' : 'verified';
}
if (currentIndex < sortedSteps.length - 1) {
// Go to next step
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
}
// Last step completed, go to final status
return skipTests ? 'waiting_approval' : 'verified';
}
// For other statuses, don't change
return currentStatus;
}
/**
* Get a specific pipeline step by ID
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to retrieve
* @returns The pipeline step or null if not found
*/
async getStep(projectPath: string, stepId: string): Promise<PipelineStep | null> {
const config = await this.getPipelineConfig(projectPath);
return config.steps.find((s) => s.id === stepId) || null;
}
/**
* Check if a status is a pipeline status
*/
isPipelineStatus(status: FeatureStatusWithPipeline): boolean {
return status.startsWith('pipeline_');
}
/**
* Extract step ID from a pipeline status
*/
getStepIdFromStatus(status: FeatureStatusWithPipeline): string | null {
if (!this.isPipelineStatus(status)) {
return null;
}
return status.replace('pipeline_', '');
}
}
// Export singleton instance
export const pipelineService = new PipelineService();

View File

@@ -8,6 +8,7 @@ import {
} from '@dnd-kit/core';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { AutoModeEvent } from '@/types/electron';
import type { BacklogPlanResult } from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
@@ -36,6 +37,7 @@ import {
FollowUpDialog,
PlanApprovalDialog,
} from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
@@ -85,7 +87,10 @@ export function BoardView() {
enableDependencyBlocking,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
} = useAppStore();
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
@@ -130,6 +135,9 @@ export function BoardView() {
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
// Pipeline settings dialog state
const [showPipelineSettings, setShowPipelineSettings] = useState(false);
// Follow-up state hook
const {
showFollowUpDialog,
@@ -201,6 +209,25 @@ export function BoardView() {
setFeaturesWithContext,
});
// Load pipeline config when project changes
useEffect(() => {
if (!currentProject?.path) return;
const loadPipelineConfig = async () => {
try {
const api = getHttpApiClient();
const result = await api.pipeline.getConfig(currentProject.path);
if (result.success && result.config) {
setPipelineConfig(currentProject.path, result.config);
}
} catch (error) {
console.error('[Board] Failed to load pipeline config:', error);
}
};
loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]);
// Auto mode hook
const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project)
@@ -1094,6 +1121,10 @@ export function BoardView() {
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
/>
) : (
<GraphView
@@ -1206,6 +1237,22 @@ export function BoardView() {
}}
/>
{/* Pipeline Settings Dialog */}
<PipelineSettingsDialog
open={showPipelineSettings}
onClose={() => setShowPipelineSettings(false)}
projectPath={currentProject.path}
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
onSave={async (config) => {
const api = getHttpApiClient();
const result = await api.pipeline.saveConfig(currentProject.path, config);
if (!result.success) {
throw new Error(result.error || 'Failed to save pipeline config');
}
setPipelineConfig(currentProject.path, config);
}}
/>
{/* Follow-Up Prompt Dialog */}
<FollowUpDialog
open={showFollowUpDialog}

View File

@@ -1,14 +1,28 @@
import { Feature } from '@/store/app-store';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
export interface Column {
id: FeatureStatusWithPipeline;
title: string;
colorClass: string;
isPipelineStep?: boolean;
pipelineStepId?: string;
}
// Base columns (start)
const BASE_COLUMNS: Column[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
{
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
},
];
// End columns (after pipeline)
const END_COLUMNS: Column[] = [
{
id: 'waiting_approval',
title: 'Waiting Approval',
@@ -20,3 +34,58 @@ export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
colorClass: 'bg-[var(--status-success)]',
},
];
// Static COLUMNS for backwards compatibility (no pipeline)
export const COLUMNS: Column[] = [...BASE_COLUMNS, ...END_COLUMNS];
/**
* Generate columns including pipeline steps
*/
export function getColumnsWithPipeline(pipelineConfig: PipelineConfig | null): Column[] {
const pipelineSteps = pipelineConfig?.steps || [];
if (pipelineSteps.length === 0) {
return COLUMNS;
}
// Sort steps by order
const sortedSteps = [...pipelineSteps].sort((a, b) => a.order - b.order);
// Convert pipeline steps to columns (filter out invalid steps)
const pipelineColumns: Column[] = sortedSteps
.filter((step) => step && step.id) // Only include valid steps with an id
.map((step) => ({
id: `pipeline_${step.id}` as FeatureStatusWithPipeline,
title: step.name || 'Pipeline Step',
colorClass: step.colorClass || 'bg-[var(--status-in-progress)]',
isPipelineStep: true,
pipelineStepId: step.id,
}));
return [...BASE_COLUMNS, ...pipelineColumns, ...END_COLUMNS];
}
/**
* Get the index where pipeline columns should be inserted
* (after in_progress, before waiting_approval)
*/
export function getPipelineInsertIndex(): number {
return BASE_COLUMNS.length;
}
/**
* Check if a status is a pipeline status
*/
export function isPipelineStatus(status: string): boolean {
return status.startsWith('pipeline_');
}
/**
* Extract step ID from a pipeline status
*/
export function getStepIdFromStatus(status: string): string | null {
if (!isPipelineStatus(status)) {
return null;
}
return status.replace('pipeline_', '');
}

View File

@@ -131,7 +131,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground'
)}
>
{dep.status.replace(/_/g, ' ')}
{(dep.status || 'backlog').replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -177,7 +177,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground'
)}
>
{dependent.status.replace(/_/g, ' ')}
{(dependent.status || 'backlog').replace(/_/g, ' ')}
</span>
</div>
</div>

View File

@@ -0,0 +1,736 @@
import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
// Pre-built step templates with well-designed prompts
const STEP_TEMPLATES = [
{
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review
Please perform a thorough code review of the changes made in this feature. Focus on:
### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
### Action Required
After reviewing, make any necessary improvements directly. If you find issues:
1. Fix them immediately if they are straightforward
2. For complex issues, document them clearly with suggested solutions
Provide a brief summary of changes made or issues found.`,
},
{
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
### Action Required
1. Fix any security vulnerabilities immediately
2. For complex security issues, document them with severity levels
3. Add security-related comments where appropriate
Provide a security assessment summary with any issues found and fixes applied.`,
},
{
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
Please ensure comprehensive test coverage for the changes made in this feature.
### Unit Tests
- Write unit tests for all new functions and methods
- Ensure edge cases are covered
- Test error handling paths
- Aim for high code coverage on new code
### Integration Tests
- Test interactions between components/modules
- Verify API endpoints work correctly
- Test database operations if applicable
### Test Quality
- Tests should be readable and well-documented
- Each test should have a clear purpose
- Use descriptive test names that explain the scenario
- Follow the Arrange-Act-Assert pattern
### Run Tests
After writing tests, run the full test suite and ensure:
1. All new tests pass
2. No existing tests are broken
3. Test coverage meets project standards
Provide a summary of tests added and any issues found during testing.`,
},
{
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
Please ensure all changes are properly documented.
### Code Documentation
- Add/update JSDoc or docstrings for new functions and classes
- Document complex algorithms or business logic
- Add inline comments for non-obvious code
### API Documentation
- Document any new or modified API endpoints
- Include request/response examples
- Document error responses
### README Updates
- Update README if new setup steps are required
- Document any new environment variables
- Update architecture diagrams if applicable
### Changelog
- Document notable changes for the changelog
- Include breaking changes if any
Provide a summary of documentation added or updated.`,
},
{
id: 'optimization',
name: 'Performance Optimization',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
Review and optimize the performance of the changes made in this feature.
### Code Performance
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
- Remove unnecessary computations or redundant operations
- Optimize loops and iterations
- Use appropriate data structures
### Memory Usage
- Check for memory leaks
- Optimize memory-intensive operations
- Ensure proper cleanup of resources
### Database/API
- Optimize database queries (add indexes, reduce N+1 queries)
- Implement caching where appropriate
- Batch API calls when possible
### Frontend (if applicable)
- Minimize bundle size
- Optimize render performance
- Implement lazy loading where appropriate
- Use memoization for expensive computations
### Action Required
1. Profile the code to identify bottlenecks
2. Apply optimizations
3. Measure improvements
Provide a summary of optimizations applied and performance improvements achieved.`,
},
];
// Helper to get template color class
const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || COLOR_OPTIONS[0].value;
};
interface PipelineSettingsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
pipelineConfig: PipelineConfig | null;
onSave: (config: PipelineConfig) => Promise<void>;
}
interface EditingStep {
id?: string;
name: string;
instructions: string;
colorClass: string;
order: number;
}
export function PipelineSettingsDialog({
open,
onClose,
projectPath,
pipelineConfig,
onSave,
}: PipelineSettingsDialogProps) {
// Filter and validate steps to ensure all required properties exist
const validateSteps = (steps: PipelineStep[] | undefined): PipelineStep[] => {
if (!Array.isArray(steps)) return [];
return steps.filter(
(step): step is PipelineStep =>
step != null &&
typeof step.id === 'string' &&
typeof step.name === 'string' &&
typeof step.instructions === 'string'
);
};
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync steps when dialog opens or pipelineConfig changes
useEffect(() => {
if (open) {
setSteps(validateSteps(pipelineConfig?.steps));
}
}, [open, pipelineConfig]);
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleAddStep = () => {
setEditingStep({
name: '',
instructions: '',
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
order: steps.length,
});
};
const handleEditStep = (step: PipelineStep) => {
setEditingStep({
id: step.id,
name: step.name,
instructions: step.instructions,
colorClass: step.colorClass,
order: step.order,
});
};
const handleDeleteStep = (stepId: string) => {
const newSteps = steps.filter((s) => s.id !== stepId);
// Reorder remaining steps
newSteps.forEach((s, index) => {
s.order = index;
});
setSteps(newSteps);
};
const handleMoveStep = (stepId: string, direction: 'up' | 'down') => {
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
if (
(direction === 'up' && stepIndex === 0) ||
(direction === 'down' && stepIndex === sortedSteps.length - 1)
) {
return;
}
const newSteps = [...sortedSteps];
const targetIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
// Swap orders
const temp = newSteps[stepIndex].order;
newSteps[stepIndex].order = newSteps[targetIndex].order;
newSteps[targetIndex].order = temp;
setSteps(newSteps);
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
toast.success('Instructions loaded from file');
} catch (error) {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveStep = () => {
if (!editingStep) return;
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing step
setSteps((prev) =>
prev.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
)
);
} else {
// Add new step
const newStep: PipelineStep = {
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: steps.length,
createdAt: now,
updatedAt: now,
};
setSteps((prev) => [...prev, newStep]);
}
setEditingStep(null);
};
const handleSaveConfig = async () => {
setIsSubmitting(true);
try {
// If the user is currently editing a step and clicks "Save Configuration",
// include that step in the config (common expectation) instead of silently dropping it.
let effectiveSteps = steps;
if (editingStep) {
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing (or add if missing for some reason)
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
if (existingIdx >= 0) {
effectiveSteps = effectiveSteps.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
);
} else {
effectiveSteps = [
...effectiveSteps,
{
id: editingStep.id,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
} else {
// Add new step
effectiveSteps = [
...effectiveSteps,
{
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
// Keep local UI state consistent with what we are saving.
setSteps(effectiveSteps);
setEditingStep(null);
}
const sortedEffectiveSteps = [...effectiveSteps].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const config: PipelineConfig = {
version: 1,
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
};
await onSave(config);
toast.success('Pipeline configuration saved');
onClose();
} catch (error) {
toast.error('Failed to save pipeline configuration');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
<Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" />
Add Pipeline Step
</Button>
)}
{/* Edit/Add Step Form */}
{editingStep && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
<div className="flex items-center justify-between">
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingStep(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Template Selector - only show for new steps */}
{!editingStep.id && (
<div className="space-y-2">
<Label>Start from Template</Label>
<Select
onValueChange={(templateId) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setEditingStep((prev) =>
prev
? {
...prev,
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -23,7 +23,8 @@ export function useBoardColumnFeatures({
}: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
const map: Record<ColumnId, Feature[]> = {
// Use a more flexible type to support dynamic pipeline statuses
const map: Record<string, Feature[]> = {
backlog: [],
in_progress: [],
waiting_approval: [],
@@ -76,31 +77,56 @@ export function useBoardColumnFeatures({
matchesWorktree = featureBranch === effectiveBranch;
}
// Use the feature's status (fallback to backlog for unknown statuses)
const status = f.status || 'backlog';
// IMPORTANT:
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
if (isRunning) {
// Only show running tasks if they match the current worktree
if (matchesWorktree) {
if (!matchesWorktree) return;
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];
map[status].push(f);
return;
}
// If it's running and has a known non-backlog status, keep it in that status.
// Otherwise, fallback to in_progress as the "active work" column.
if (status !== 'backlog' && map[status]) {
map[status].push(f);
} else {
map.in_progress.push(f);
}
} else {
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
const status = f.status as ColumnId;
return;
}
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
// Not running: place by status (and worktree filter)
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else if (status.startsWith('pipeline_')) {
// Handle pipeline statuses - initialize array if needed
if (matchesWorktree) {
if (!map[status]) {
map[status] = [];
}
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
}
}
});
@@ -147,7 +173,7 @@ export function useBoardColumnFeatures({
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
return columnFeaturesMap[columnId];
return columnFeaturesMap[columnId] || [];
},
[columnFeaturesMap]
);

View File

@@ -203,6 +203,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// This ensures the feature card shows the "Approve Plan" button
console.log('[Board] Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
console.log('[Board] Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log('[Board] Feature error, reloading features...', event.error);

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@@ -5,10 +6,11 @@ import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive } from 'lucide-react';
import { FastForward, Lightbulb, Archive, Plus, Settings2 } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { COLUMNS, ColumnId } from './constants';
import { getColumnsWithPipeline, type Column, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
interface KanbanBoardProps {
sensors: any;
@@ -49,6 +51,8 @@ interface KanbanBoardProps {
onShowSuggestions: () => void;
suggestionsCount: number;
onArchiveAllVerified: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
}
export function KanbanBoard({
@@ -82,13 +86,18 @@ export function KanbanBoard({
onShowSuggestions,
suggestionsCount,
onArchiveAllVerified,
pipelineConfig,
onOpenPipelineSettings,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Use responsive column widths based on window size
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return (
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -96,8 +105,8 @@ export function KanbanBoard({
onDragEnd={onDragEnd}
>
<div className="h-full py-1" style={containerStyle}>
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<KanbanColumn
key={column.id}
@@ -156,6 +165,28 @@ export function KanbanBoard({
</HotkeyButton>
)}
</div>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
>

View File

@@ -76,7 +76,10 @@ const priorityConfig = {
};
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
const config = statusConfig[data.status] || statusConfig.backlog;
// Handle pipeline statuses by treating them like in_progress
const status = data.status || 'backlog';
const statusKey = status.startsWith('pipeline_') ? 'in_progress' : status;
const config = statusConfig[statusKey as keyof typeof statusConfig] || statusConfig.backlog;
const StatusIcon = config.icon;
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;

View File

@@ -174,14 +174,12 @@ export function useResponsiveKanban(
// Calculate total board width for container sizing
const totalBoardWidth = columnWidth * columnCount + gap * (columnCount - 1);
// Container style to center content
// Use flex layout with justify-center to naturally center columns
// The parent container has px-4 padding which provides equal left/right margins
// Container style for horizontal scrolling support
const containerStyle: React.CSSProperties = {
display: 'flex',
gap: `${gap}px`,
height: '100%',
justifyContent: 'center',
width: 'max-content', // Expand to fit all columns, enabling horizontal scroll when needed
minHeight: '100%', // Ensure full height
};
return {

View File

@@ -1125,6 +1125,102 @@ export class HttpApiClient implements ElectronAPI {
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
},
};
// Pipeline API - custom workflow pipeline steps
pipeline = {
getConfig: (
projectPath: string
): Promise<{
success: boolean;
config?: {
version: 1;
steps: Array<{
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
}>;
};
error?: string;
}> => this.post('/api/pipeline/config', { projectPath }),
saveConfig: (
projectPath: string,
config: {
version: 1;
steps: Array<{
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
}>;
}
): Promise<{ success: boolean; error?: string }> =>
this.post('/api/pipeline/config/save', { projectPath, config }),
addStep: (
projectPath: string,
step: {
name: string;
order: number;
instructions: string;
colorClass: string;
}
): Promise<{
success: boolean;
step?: {
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
};
error?: string;
}> => this.post('/api/pipeline/steps/add', { projectPath, step }),
updateStep: (
projectPath: string,
stepId: string,
updates: Partial<{
name: string;
order: number;
instructions: string;
colorClass: string;
}>
): Promise<{
success: boolean;
step?: {
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
};
error?: string;
}> => this.post('/api/pipeline/steps/update', { projectPath, stepId, updates }),
deleteStep: (
projectPath: string,
stepId: string
): Promise<{ success: boolean; error?: string }> =>
this.post('/api/pipeline/steps/delete', { projectPath, stepId }),
reorderSteps: (
projectPath: string,
stepIds: string[]
): Promise<{ success: boolean; error?: string }> =>
this.post('/api/pipeline/steps/reorder', { projectPath, stepIds }),
};
}
// Singleton instance

View File

@@ -37,19 +37,13 @@ const STATIC_PORT = 3007;
// ============================================
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
// With sidebar expanded (288px): 1220 + 288 = 1508px
// With sidebar collapsed (64px): 1220 + 64 = 1284px
const COLUMN_MIN_WIDTH = 280;
const COLUMN_COUNT = 4;
const GAP_SIZE = 20;
const BOARD_PADDING = 40; // px-5 on both sides = 40px (matches gap between columns)
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
const SIDEBAR_EXPANDED = 288;
const SIDEBAR_COLLAPSED = 64;
const BOARD_CONTENT_MIN =
COLUMN_MIN_WIDTH * COLUMN_COUNT + GAP_SIZE * (COLUMN_COUNT - 1) + BOARD_PADDING;
const MIN_WIDTH_EXPANDED = BOARD_CONTENT_MIN + SIDEBAR_EXPANDED; // 1500px
const MIN_WIDTH_COLLAPSED = BOARD_CONTENT_MIN + SIDEBAR_COLLAPSED; // 1276px
const MIN_HEIGHT = 650; // Ensures sidebar content fits without scrolling
const MIN_WIDTH_EXPANDED = 800; // Reduced - horizontal scrolling handles overflow
const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
const MIN_HEIGHT = 500; // Reduced to allow more flexibility
const DEFAULT_WIDTH = 1600;
const DEFAULT_HEIGHT = 950;
@@ -199,7 +193,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds {
// Ensure minimum dimensions
return {
...bounds,
width: Math.max(bounds.width, MIN_WIDTH_EXPANDED),
width: Math.max(bounds.width, MIN_WIDTH_COLLAPSED),
height: Math.max(bounds.height, MIN_HEIGHT),
};
}
@@ -420,7 +414,7 @@ function createWindow(): void {
height: validBounds?.height ?? DEFAULT_HEIGHT,
x: validBounds?.x,
y: validBounds?.y,
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
minWidth: MIN_WIDTH_COLLAPSED, // Small minimum - horizontal scrolling handles overflow
minHeight: MIN_HEIGHT,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
@@ -673,15 +667,10 @@ ipcMain.handle('server:getUrl', async () => {
});
// Window management - update minimum width based on sidebar state
ipcMain.handle('window:updateMinWidth', (_, sidebarExpanded: boolean) => {
// Now uses a fixed small minimum since horizontal scrolling handles overflow
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
const minWidth = sidebarExpanded ? MIN_WIDTH_EXPANDED : MIN_WIDTH_COLLAPSED;
mainWindow.setMinimumSize(minWidth, MIN_HEIGHT);
// If current width is below new minimum, resize window
const currentBounds = mainWindow.getBounds();
if (currentBounds.width < minWidth) {
mainWindow.setSize(minWidth, currentBounds.height);
}
// Always use the smaller minimum width - horizontal scrolling handles any overflow
mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
});

View File

@@ -7,6 +7,9 @@ import type {
AgentModel,
PlanningMode,
AIProfile,
FeatureStatusWithPipeline,
PipelineConfig,
PipelineStep,
} from '@automaker/types';
// Re-export ThemeMode for convenience
@@ -263,7 +266,7 @@ export interface Feature extends Omit<
category: string;
description: string;
steps: string[]; // Required in UI (not optional)
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
status: FeatureStatusWithPipeline;
images?: FeatureImage[]; // UI-specific base64 images
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
@@ -534,6 +537,9 @@ export interface AppState {
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
claudeUsage: ClaudeUsage | null;
claudeUsageLastUpdated: number | null;
// Pipeline Configuration (per-project, keyed by project path)
pipelineConfigByProject: Record<string, PipelineConfig>;
}
// Claude Usage interface matching the server response
@@ -857,6 +863,21 @@ export interface AppActions {
} | null
) => void;
// Pipeline actions
setPipelineConfig: (projectPath: string, config: PipelineConfig) => void;
getPipelineConfig: (projectPath: string) => PipelineConfig | null;
addPipelineStep: (
projectPath: string,
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
) => PipelineStep;
updatePipelineStep: (
projectPath: string,
stepId: string,
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
) => void;
deletePipelineStep: (projectPath: string, stepId: string) => void;
reorderPipelineSteps: (projectPath: string, stepIds: string[]) => void;
// Reset
reset: () => void;
}
@@ -961,6 +982,10 @@ const initialState: AppState = {
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
pipelineConfigByProject: {},
};
export const useAppStore = create<AppState & AppActions>()(
@@ -2597,6 +2622,105 @@ export const useAppStore = create<AppState & AppActions>()(
claudeUsageLastUpdated: usage ? Date.now() : null,
}),
// Pipeline actions
setPipelineConfig: (projectPath, config) => {
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: config,
},
});
},
getPipelineConfig: (projectPath) => {
return get().pipelineConfigByProject[projectPath] || null;
},
addPipelineStep: (projectPath, step) => {
const config = get().pipelineConfigByProject[projectPath] || { version: 1, steps: [] };
const now = new Date().toISOString();
const newStep: PipelineStep = {
...step,
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
createdAt: now,
updatedAt: now,
};
const newSteps = [...config.steps, newStep].sort((a, b) => a.order - b.order);
newSteps.forEach((s, index) => {
s.order = index;
});
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: { ...config, steps: newSteps },
},
});
return newStep;
},
updatePipelineStep: (projectPath, stepId, updates) => {
const config = get().pipelineConfigByProject[projectPath];
if (!config) return;
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
if (stepIndex === -1) return;
const updatedSteps = [...config.steps];
updatedSteps[stepIndex] = {
...updatedSteps[stepIndex],
...updates,
updatedAt: new Date().toISOString(),
};
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: { ...config, steps: updatedSteps },
},
});
},
deletePipelineStep: (projectPath, stepId) => {
const config = get().pipelineConfigByProject[projectPath];
if (!config) return;
const newSteps = config.steps.filter((s) => s.id !== stepId);
newSteps.forEach((s, index) => {
s.order = index;
});
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: { ...config, steps: newSteps },
},
});
},
reorderPipelineSteps: (projectPath, stepIds) => {
const config = get().pipelineConfigByProject[projectPath];
if (!config) return;
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
const reorderedSteps = stepIds
.map((id, index) => {
const step = stepMap.get(id);
if (!step) return null;
return { ...step, order: index, updatedAt: new Date().toISOString() };
})
.filter((s): s is PipelineStep => s !== null);
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: { ...config, steps: reorderedSteps },
},
});
},
// Reset
reset: () => set(initialState),
}),

View File

@@ -190,6 +190,24 @@ export type AutoModeEvent =
passes: boolean;
message: string;
}
| {
type: 'pipeline_step_started';
featureId: string;
projectPath?: string;
stepId: string;
stepName: string;
stepIndex: number;
totalSteps: number;
}
| {
type: 'pipeline_step_complete';
featureId: string;
projectPath?: string;
stepId: string;
stepName: string;
stepIndex: number;
totalSteps: number;
}
| {
type: 'auto_mode_error';
error: string;

156
docs/pipeline-feature.md Normal file
View File

@@ -0,0 +1,156 @@
# Pipeline Feature
Custom pipeline steps that run automatically after a feature completes "In Progress", creating a sequential workflow for code review, security audits, testing, and more.
## Overview
The pipeline feature allows users to define custom workflow steps that execute automatically after the main implementation phase. Each step prompts the agent with specific instructions while maintaining the full conversation context.
## How It Works
1. **Feature completes "In Progress"** - When the agent finishes implementing a feature
2. **Pipeline steps execute sequentially** - Each configured step runs in order
3. **Agent receives instructions** - The step's instructions are sent to the agent
4. **Context preserved** - Full chat history is maintained between steps
5. **Final status** - After all steps complete, the feature moves to "Waiting Approval" or "Verified"
## Configuration
### Accessing Pipeline Settings
- Click the **gear icon** on the "In Progress" column header
- Or click the gear icon on any pipeline step column
### Adding Pipeline Steps
1. Click **"Add Pipeline Step"**
2. Optionally select a **pre-built template** from the dropdown:
- Code Review
- Security Review
- Testing
- Documentation
- Performance Optimization
3. Customize the **Step Name**
4. Choose a **Color** for the column
5. Write or modify the **Agent Instructions**
6. Click **"Add Step"**
### Managing Steps
- **Reorder**: Use the up/down arrows to change step order
- **Edit**: Click the pencil icon to modify a step
- **Delete**: Click the trash icon to remove a step
- **Load from file**: Upload a `.md` or `.txt` file for instructions
## Storage
Pipeline configuration is stored per-project at:
```
{project}/.automaker/pipeline.json
```
## Pre-built Templates
### Code Review
Comprehensive code quality review covering:
- Readability and maintainability
- DRY principle and single responsibility
- Best practices and conventions
- Performance considerations
- Test coverage
### Security Review
OWASP-focused security audit including:
- Input validation and sanitization
- SQL injection and XSS prevention
- Authentication and authorization
- Data protection
- Common vulnerability checks (OWASP Top 10)
### Testing
Test coverage verification:
- Unit test requirements
- Integration testing
- Test quality standards
- Running and validating tests
### Documentation
Documentation requirements:
- Code documentation (JSDoc/docstrings)
- API documentation
- README updates
- Changelog entries
### Performance Optimization
Performance review covering:
- Algorithm optimization
- Memory usage
- Database/API optimization
- Frontend performance (if applicable)
## UI Changes
### Kanban Board
- Pipeline columns appear between "In Progress" and "Waiting Approval"
- Each pipeline column shows features currently in that step
- Gear icon on columns opens pipeline settings
### Horizontal Scrolling
- Board supports horizontal scrolling when many columns exist
- Minimum window width reduced to 600px to accommodate various screen sizes
## Technical Details
### Files Modified
**Types:**
- `libs/types/src/pipeline.ts` - PipelineStep, PipelineConfig types
- `libs/types/src/index.ts` - Export pipeline types
**Server:**
- `apps/server/src/services/pipeline-service.ts` - CRUD operations, status transitions
- `apps/server/src/routes/pipeline/` - API endpoints
- `apps/server/src/services/auto-mode-service.ts` - Pipeline execution integration
**UI:**
- `apps/ui/src/store/app-store.ts` - Pipeline state management
- `apps/ui/src/lib/http-api-client.ts` - Pipeline API client
- `apps/ui/src/components/views/board-view/constants.ts` - Dynamic column generation
- `apps/ui/src/components/views/board-view/kanban-board.tsx` - Pipeline props, scrolling
- `apps/ui/src/components/views/board-view/dialogs/pipeline-settings-dialog.tsx` - Settings UI
- `apps/ui/src/hooks/use-responsive-kanban.ts` - Scroll support
### API Endpoints
```
POST /api/pipeline/config - Get pipeline config
POST /api/pipeline/config/save - Save pipeline config
POST /api/pipeline/steps/add - Add a step
POST /api/pipeline/steps/update - Update a step
POST /api/pipeline/steps/delete - Delete a step
POST /api/pipeline/steps/reorder - Reorder steps
```
### Status Flow
```
backlog → in_progress → pipeline_step1 → pipeline_step2 → ... → verified/waiting_approval
```
Pipeline statuses use the format `pipeline_{stepId}` to support unlimited dynamic steps.

View File

@@ -105,3 +105,11 @@ export type {
BacklogPlanRequest,
BacklogPlanApplyResult,
} from './backlog-plan.js';
// Pipeline types
export type {
PipelineStep,
PipelineConfig,
PipelineStatus,
FeatureStatusWithPipeline,
} from './pipeline.js';

View File

@@ -0,0 +1,28 @@
/**
* Pipeline types for AutoMaker custom workflow steps
*/
export interface PipelineStep {
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
}
export interface PipelineConfig {
version: 1;
steps: PipelineStep[];
}
export type PipelineStatus = `pipeline_${string}`;
export type FeatureStatusWithPipeline =
| 'backlog'
| 'in_progress'
| 'waiting_approval'
| 'verified'
| 'completed'
| PipelineStatus;