Compare commits

..

8 Commits

Author SHA1 Message Date
SuperComboGamer
2eb92a0402 feat: Introduce new UI layout with floating dock, visual effects, and expanded theme options. 2025-12-22 21:10:12 -05:00
Web Dev Cody
524a9736b4 Merge pull request #222 from JBotwina/claude/task-dependency-graph-iPz1k
feat: task dependency graph view
2025-12-22 17:30:52 -05:00
Test User
036a7d9d26 refactor: update e2e tests to use 'load' state for page navigation
- Changed instances of `waitForLoadState('networkidle')` to `waitForLoadState('load')` across multiple test files and utility functions to improve test reliability in applications with persistent connections.
- Added documentation to the e2e testing guide explaining the rationale behind using 'load' state instead of 'networkidle' to prevent timeouts and flaky tests.
2025-12-22 17:16:55 -05:00
Test User
c4df2c141a Merge branch 'main' of github.com:AutoMaker-Org/automaker into claude/task-dependency-graph-iPz1k 2025-12-22 17:01:18 -05:00
James
7c75c24b5c fix: graph nodes now respect theme colors
Override React Flow's default node styling (white background) with
transparent to allow the TaskNode component's bg-card class to show
through with the correct theme colors.
2025-12-22 15:53:15 -05:00
James
2588ecaafa Merge remote-tracking branch 'origin/main' into claude/task-dependency-graph-iPz1k 2025-12-22 15:37:24 -05:00
James Botwina
a071097c0d Merge branch 'AutoMaker-Org:main' into claude/task-dependency-graph-iPz1k 2025-12-22 14:23:18 -05:00
Claude
b930091c42 feat: add dependency graph view for task visualization
Add a new interactive graph view alongside the kanban board for visualizing
task dependencies. The graph view uses React Flow with dagre auto-layout to
display tasks as nodes connected by dependency edges.

Key features:
- Toggle between kanban and graph view via new control buttons
- Custom TaskNode component matching existing card styling/themes
- Animated edges that flow when tasks are in progress
- Status-aware node colors (backlog, in-progress, waiting, verified)
- Blocked tasks show lock icon with dependency count tooltip
- MiniMap for navigation in large graphs
- Zoom, pan, fit-view, and lock controls
- Horizontal/vertical layout options via dagre
- Click node to view details, double-click to edit
- Respects all 32 themes via CSS variables
- Reduced motion support for animations

New dependencies: @xyflow/react, dagre
2025-12-22 19:10:32 +00:00
137 changed files with 6602 additions and 6190 deletions

View File

@@ -1,99 +0,0 @@
# 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 - a Kanban-based application where users describe features and AI agents (powered by Claude Agent SDK) automatically implement them. It runs as an Electron desktop app or in web browser mode.
## Commands
```bash
# Install dependencies
npm install
# Build shared packages (REQUIRED before running)
npm run build:packages
# Development
npm run dev # Interactive mode selector
npm run dev:electron # Electron desktop app
npm run dev:web # Web browser mode (localhost:3007)
npm run dev:server # Backend server only
# Testing
npm run test # UI E2E tests (Playwright)
npm run test:headed # UI tests with visible browser
npm run test:server # Server unit tests (Vitest)
npm run test:packages # Shared package tests
# Linting & Formatting
npm run lint # ESLint
npm run format # Prettier
npm run format:check # Check formatting
# Building
npm run build # Build Next.js app
npm run build:electron # Build Electron distribution
```
## Architecture
### Monorepo Structure (npm workspaces)
```
apps/
├── ui/ # Electron + Vite + React frontend (@automaker/ui)
└── server/ # Express + WebSocket backend (@automaker/server)
libs/ # Shared packages (@automaker/*)
├── types/ # Shared TypeScript interfaces
├── utils/ # Common utilities
├── prompts/ # AI prompt templates
├── platform/ # Platform-specific code (paths, security)
├── git-utils/ # Git operations
├── model-resolver/ # AI model configuration
└── dependency-resolver/ # Dependency management
```
### Key Patterns
**State Management (UI)**: Zustand stores in `apps/ui/src/store/`
- `app-store.ts` - Main application state (features, settings, themes)
- `setup-store.ts` - Project setup wizard state
**Routing (UI)**: TanStack Router with file-based routes in `apps/ui/src/routes/`
**Backend Services**: Express + WebSocket in `apps/server/src/`
- Services in `/services/` handle business logic
- Routes in `/routes/` define API endpoints
- Providers in `/providers/` abstract AI model integrations
**Provider Architecture**: Model-based routing via `ProviderFactory`
- `ClaudeProvider` wraps @anthropic-ai/claude-agent-sdk
- Designed for easy addition of other providers
**Feature Storage**: Features stored in `.automaker/features/{id}/feature.json`
**Communication**:
- Electron: IPC via preload script (`apps/ui/src/preload.ts`)
- Web: HTTP API client (`apps/ui/src/lib/http-api-client.ts`)
### Important Files
- `apps/ui/src/main.ts` - Electron main process
- `apps/server/src/index.ts` - Server entry point
- `apps/ui/src/lib/electron.ts` - IPC type definitions
- `apps/server/src/services/agent-service.ts` - AI agent session management
- `apps/server/src/providers/provider-factory.ts` - Model routing
## Development Notes
- Always run `npm run build:packages` after modifying any `libs/*` package
- Server runs on port 3008 by default
- UI runs on port 3007 in web mode
- Authentication: Set `ANTHROPIC_API_KEY` env var or configure via Settings

View File

@@ -0,0 +1,25 @@
/**
* Enhancement Prompts - Re-exported from @automaker/prompts
*
* This file now re-exports enhancement prompts from the shared @automaker/prompts package
* to maintain backward compatibility with existing imports in the server codebase.
*/
export {
IMPROVE_SYSTEM_PROMPT,
TECHNICAL_SYSTEM_PROMPT,
SIMPLIFY_SYSTEM_PROMPT,
ACCEPTANCE_SYSTEM_PROMPT,
IMPROVE_EXAMPLES,
TECHNICAL_EXAMPLES,
SIMPLIFY_EXAMPLES,
ACCEPTANCE_EXAMPLES,
getEnhancementPrompt,
getSystemPrompt,
getExamples,
buildUserPrompt,
isValidEnhancementMode,
getAvailableEnhancementModes,
} from '@automaker/prompts';
export type { EnhancementMode, EnhancementExample } from '@automaker/prompts';

View File

@@ -1,69 +0,0 @@
/**
* Shell execution utilities
*
* Provides cross-platform shell execution with extended PATH
* to find tools like git and gh in Electron environments.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
/**
* Promisified exec for async/await usage
*/
export const execAsync = promisify(exec);
/**
* Path separator for the current platform
*/
const pathSeparator = process.platform === 'win32' ? ';' : ':';
/**
* Additional paths to search for executables.
* Electron apps don't inherit the user's shell PATH, so we need to add
* common tool installation locations.
*/
const additionalPaths: string[] = [];
if (process.platform === 'win32') {
// Windows paths for Git and other tools
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
}
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env['ProgramFiles(x86)']) {
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
}
} else {
// Unix/Mac paths
additionalPaths.push(
'/opt/homebrew/bin', // Homebrew on Apple Silicon
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
'/home/linuxbrew/.linuxbrew/bin', // Linuxbrew
`${process.env.HOME}/.local/bin` // pipx, other user installs
);
}
/**
* Extended PATH that includes common tool installation locations.
*/
export const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
.filter(Boolean)
.join(pathSeparator);
/**
* Environment variables with extended PATH for executing shell commands.
*/
export const execEnv = {
...process.env,
PATH: extendedPath,
};
/**
* Check if an error is ENOENT (file/path not found or spawn failed)
*/
export function isENOENT(error: unknown): boolean {
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
}

View File

@@ -0,0 +1,23 @@
/**
* Re-export secure file system utilities from @automaker/platform
* This file exists for backward compatibility with existing imports
*/
import { secureFs } from '@automaker/platform';
export const {
access,
readFile,
writeFile,
mkdir,
readdir,
stat,
rm,
unlink,
copyFile,
appendFile,
rename,
lstat,
joinPath,
resolvePath,
} = secureFs;

View File

@@ -3,16 +3,26 @@
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
*/
import { secureFs } from '@automaker/platform';
import * as secureFs from './secure-fs.js';
import * as path from 'path';
import type { WorktreePRInfo, WorktreeMetadata } from '@automaker/types';
// Re-export types for convenience
export type { WorktreePRInfo, WorktreeMetadata } from '@automaker/types';
/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
export interface WorktreeMetadata {
branch: string;
createdAt: string;
pr?: WorktreePRInfo;
}
/**
* Sanitize branch name for cross-platform filesystem safety
*/

View File

@@ -9,10 +9,6 @@ import type {
InstallationStatus,
ValidationResult,
ModelDefinition,
SimpleQueryOptions,
SimpleQueryResult,
StreamingQueryOptions,
StreamingQueryResult,
} from './types.js';
/**
@@ -39,22 +35,6 @@ export abstract class BaseProvider {
*/
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
/**
* Execute a simple one-shot query and return text directly
* Use for quick completions without tools (title gen, descriptions, etc.)
* @param options Simple query options
* @returns Query result with text
*/
abstract executeSimpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult>;
/**
* Execute a streaming query with tools and/or structured output
* Use for queries that need tools, progress callbacks, or structured JSON output
* @param options Streaming query options
* @returns Query result with text and optional structured output
*/
abstract executeStreamingQuery(options: StreamingQueryOptions): Promise<StreamingQueryResult>;
/**
* Detect if the provider is installed and configured
* @returns Installation status

View File

@@ -3,26 +3,15 @@
*
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
* with the provider architecture.
*
* Provides two query methods:
* - executeQuery(): Streaming async generator for complex multi-turn sessions
* - executeSimpleQuery(): One-shot queries that return text directly (title gen, descriptions, etc.)
*/
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from './base-provider.js';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
SimpleQueryOptions,
SimpleQueryResult,
StreamingQueryOptions,
StreamingQueryResult,
PromptContentBlock,
} from './types.js';
export class ClaudeProvider extends BaseProvider {
@@ -186,225 +175,4 @@ export class ClaudeProvider extends BaseProvider {
const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
return supportedFeatures.includes(feature);
}
/**
* Execute a simple one-shot query and return text directly
*
* Use this for:
* - Title generation from description
* - Text enhancement
* - File/image description
* - Any quick, single-turn completion without tools
*
* @example
* ```typescript
* const provider = ProviderFactory.getProviderForModel('haiku');
* const result = await provider.executeSimpleQuery({
* prompt: 'Generate a title for: User authentication feature',
* systemPrompt: 'You are a title generator...',
* });
* if (result.success) console.log(result.text);
* ```
*/
async executeSimpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult> {
const { prompt, model, systemPrompt, abortController } = options;
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.haiku);
try {
const sdkOptions: Options = {
model: resolvedModel,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
abortController,
};
// Handle both string prompts and multi-part content blocks
const stream = Array.isArray(prompt)
? query({ prompt: this.createPromptGenerator(prompt), options: sdkOptions })
: query({ prompt, options: sdkOptions });
const { text } = await this.extractTextFromStream(stream);
if (!text || text.trim().length === 0) {
return {
text: '',
success: false,
error: 'Empty response from Claude',
};
}
return {
text: text.trim(),
success: true,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[ClaudeProvider] executeSimpleQuery() error:', errorMessage);
return {
text: '',
success: false,
error: errorMessage,
};
}
}
/**
* Execute a streaming query with tools and/or structured output
*
* Use this for:
* - Spec generation (with JSON schema output)
* - Feature generation from specs
* - Suggestions generation
* - Any query that needs tools or progress callbacks
*
* @example
* ```typescript
* const provider = ProviderFactory.getProviderForModel('opus');
* const result = await provider.executeStreamingQuery({
* prompt: 'Analyze this project...',
* cwd: '/path/to/project',
* allowedTools: ['Read', 'Glob', 'Grep'],
* outputFormat: { type: 'json_schema', schema: mySchema },
* onText: (chunk) => console.log('Progress:', chunk),
* });
* console.log(result.structuredOutput);
* ```
*/
async executeStreamingQuery(options: StreamingQueryOptions): Promise<StreamingQueryResult> {
const {
prompt,
model,
systemPrompt,
cwd,
maxTurns = 100,
allowedTools = ['Read', 'Glob', 'Grep'],
abortController,
outputFormat,
onText,
onToolUse,
} = options;
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.haiku);
try {
const sdkOptions: Options = {
model: resolvedModel,
systemPrompt,
maxTurns,
cwd,
allowedTools: [...allowedTools],
permissionMode: 'acceptEdits',
abortController,
...(outputFormat && { outputFormat }),
};
// Handle both string prompts and multi-part content blocks
const stream = Array.isArray(prompt)
? query({ prompt: this.createPromptGenerator(prompt), options: sdkOptions })
: query({ prompt, options: sdkOptions });
const { text, structuredOutput } = await this.extractTextFromStream(stream, {
onText,
onToolUse,
});
if (!text && !structuredOutput) {
return {
text: '',
success: false,
error: 'Empty response from Claude',
};
}
return {
text: text.trim(),
success: true,
structuredOutput,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[ClaudeProvider] executeStreamingQuery() error:', errorMessage);
return {
text: '',
success: false,
error: errorMessage,
};
}
}
/**
* Create a multi-part prompt generator for content blocks
*/
private createPromptGenerator(content: PromptContentBlock[]) {
// Return an async generator that yields SDK user messages
// The SDK expects this format for multi-part prompts
return (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content },
parent_tool_use_id: null,
};
})();
}
/**
* Extract text and structured output from SDK stream
*
* This consolidates the duplicated extractTextFromStream() function
* that was copied across 5+ route files.
*/
private async extractTextFromStream(
stream: AsyncIterable<unknown>,
handlers?: {
onText?: (text: string) => void;
onToolUse?: (name: string, input: unknown) => void;
}
): Promise<{ text: string; structuredOutput?: unknown }> {
let responseText = '';
let structuredOutput: unknown = undefined;
for await (const msg of stream) {
const message = msg as {
type: string;
subtype?: string;
result?: string;
structured_output?: unknown;
message?: {
content?: Array<{ type: string; text?: string; name?: string; input?: unknown }>;
};
};
if (message.type === 'assistant' && message.message?.content) {
for (const block of message.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
handlers?.onText?.(block.text);
} else if (block.type === 'tool_use' && block.name) {
handlers?.onToolUse?.(block.name, block.input);
}
}
} else if (message.type === 'result' && message.subtype === 'success') {
if (message.result) {
responseText = message.result;
}
if (message.structured_output) {
structuredOutput = message.structured_output;
}
} else if (message.type === 'result' && message.subtype === 'error_max_turns') {
console.warn('[ClaudeProvider] Hit max turns limit');
} else if (
message.type === 'result' &&
message.subtype === 'error_max_structured_output_retries'
) {
throw new Error('Failed to produce valid structured output after retries');
} else if (message.type === 'error') {
const errorMsg = (message as { error?: string }).error || 'Unknown error';
throw new Error(errorMsg);
}
}
return { text: responseText, structuredOutput };
}
}

View File

@@ -102,92 +102,3 @@ export interface ModelDefinition {
tier?: 'basic' | 'standard' | 'premium';
default?: boolean;
}
/**
* Content block for multi-part prompts (images, structured text)
*/
export interface TextContentBlock {
type: 'text';
text: string;
}
export interface ImageContentBlock {
type: 'image';
source: {
type: 'base64';
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
data: string;
};
}
export type PromptContentBlock = TextContentBlock | ImageContentBlock;
/**
* Options for simple one-shot queries (title generation, descriptions, text enhancement)
*
* These queries:
* - Don't need tools
* - Return text directly (no streaming)
* - Are single-turn (maxTurns=1)
*/
export interface SimpleQueryOptions {
/** The prompt - either a string or array of content blocks */
prompt: string | PromptContentBlock[];
/** Model to use (defaults to haiku) */
model?: string;
/** Optional system prompt */
systemPrompt?: string;
/** Abort controller for cancellation */
abortController?: AbortController;
}
/**
* Result from a simple query
*/
export interface SimpleQueryResult {
/** Extracted text from the response */
text: string;
/** Whether the query completed successfully */
success: boolean;
/** Error message if failed */
error?: string;
}
/**
* Options for streaming queries with tools and/or structured output
*/
export interface StreamingQueryOptions extends SimpleQueryOptions {
/** Working directory for tool execution */
cwd: string;
/** Max turns (defaults to sdk-options presets) */
maxTurns?: number;
/** Tools to allow */
allowedTools?: readonly string[];
/** JSON schema for structured output */
outputFormat?: {
type: 'json_schema';
schema: Record<string, unknown>;
};
/** Callback for text chunks */
onText?: (text: string) => void;
/** Callback for tool usage */
onToolUse?: (name: string, input: unknown) => void;
}
/**
* Result from a streaming query with structured output
*/
export interface StreamingQueryResult extends SimpleQueryResult {
/** Parsed structured output if outputFormat was specified */
structuredOutput?: unknown;
}

View File

@@ -1,14 +1,15 @@
/**
* Generate features from existing app_spec.txt
*
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath, secureFs } from '@automaker/platform';
import { getAppSpecPath } from '@automaker/platform';
const logger = createLogger('SpecRegeneration');
@@ -90,37 +91,72 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
projectPath: projectPath,
});
logger.info('Calling provider.executeStreamingQuery() for features...');
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeStreamingQuery({
prompt,
model: 'haiku',
const options = createFeatureGenerationOptions({
cwd: projectPath,
maxTurns: 50,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: text,
projectPath: projectPath,
});
},
});
if (!result.success) {
logger.error('❌ Feature generation failed:', result.error);
throw new Error(result.error || 'Feature generation failed');
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query() for features...');
logAuthStatus('Right before SDK query() for features');
let stream;
try {
stream = query({ prompt, options });
logger.debug('query() returned stream successfully');
} catch (queryError) {
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
throw queryError;
}
logger.info(`Feature response length: ${result.text.length} chars`);
let responseText = '';
let messageCount = 0;
logger.debug('Starting to iterate over feature stream...');
try {
for await (const msg of stream) {
messageCount++;
logger.debug(
`Feature stream message #${messageCount}:`,
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
);
if (msg.type === 'assistant' && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
logger.debug(`Feature text block received (${block.text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
}
}
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.debug('Received success result for features');
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from feature stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {
logger.error('❌ Error while iterating feature stream:');
logger.error('Stream error:', streamError);
throw streamError;
}
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(result.text);
logger.info(responseText);
logger.info('========== END RESPONSE TEXT ==========');
await parseAndCreateFeatures(projectPath, result.text, events);
await parseAndCreateFeatures(projectPath, responseText, events);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -1,9 +1,10 @@
/**
* Generate app_spec.txt from project overview
*
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import {
specOutputSchema,
@@ -12,9 +13,10 @@ import {
type SpecOutput,
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath, secureFs } from '@automaker/platform';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
const logger = createLogger('SpecRegeneration');
@@ -81,53 +83,105 @@ ${getStructuredSpecPromptInstruction()}`;
content: 'Starting spec generation...\n',
});
logger.info('Calling provider.executeStreamingQuery()...');
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeStreamingQuery({
prompt,
model: 'haiku',
const options = createSpecGenerationOptions({
cwd: projectPath,
maxTurns: 1000,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
outputFormat: {
type: 'json_schema',
schema: specOutputSchema,
},
onText: (text) => {
logger.info(`Text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: text,
projectPath: projectPath,
});
},
onToolUse: (name, input) => {
logger.info('Tool use:', name);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
tool: name,
input,
});
},
});
if (!result.success) {
logger.error('❌ Spec generation failed:', result.error);
throw new Error(result.error || 'Spec generation failed');
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query()...');
// Log auth status right before the SDK call
logAuthStatus('Right before SDK query()');
let stream;
try {
stream = query({ prompt, options });
logger.debug('query() returned stream successfully');
} catch (queryError) {
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
throw queryError;
}
const responseText = result.text;
const structuredOutput = result.structuredOutput as SpecOutput | undefined;
let responseText = '';
let messageCount = 0;
let structuredOutput: SpecOutput | null = null;
logger.info('Starting to iterate over stream...');
try {
for await (const msg of stream) {
messageCount++;
logger.info(
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
);
if (msg.type === 'assistant') {
const msgAny = msg as any;
if (msgAny.message?.content) {
for (const block of msgAny.message.content) {
if (block.type === 'text') {
responseText += block.text;
logger.info(
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
} else if (block.type === 'tool_use') {
logger.info('Tool use:', block.name);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
tool: block.name,
input: block.input,
});
}
}
}
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.info('Received success result');
// Check for structured output - this is the reliable way to get spec data
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as SpecOutput;
logger.info('✅ Received structured output');
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
} else {
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
}
} else if (msg.type === 'result') {
// Handle error result types
const subtype = (msg as any).subtype;
logger.info(`Result message: subtype=${subtype}`);
if (subtype === 'error_max_turns') {
logger.error('❌ Hit max turns limit!');
} else if (subtype === 'error_max_structured_output_retries') {
logger.error('❌ Failed to produce valid structured output after retries');
throw new Error('Could not produce valid spec output');
}
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
} else if (msg.type === 'user') {
// Log user messages (tool results)
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
}
}
} catch (streamError) {
logger.error('❌ Error while iterating stream:');
logger.error('Stream error:', streamError);
throw streamError;
}
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
logger.info(`Response text length: ${responseText.length} chars`);
if (structuredOutput) {
logger.info('✅ Received structured output');
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
} else {
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
}
// Determine XML content to save
let xmlContent: string;

View File

@@ -3,9 +3,10 @@
*/
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { getFeaturesDir, secureFs } from '@automaker/platform';
import { getFeaturesDir } from '@automaker/platform';
const logger = createLogger('SpecRegeneration');

View File

@@ -1,6 +1,35 @@
/**
* Claude Usage types for CLI-based usage tracking
* Re-exported from @automaker/types for convenience
*/
export type { ClaudeUsage, ClaudeStatus } from '@automaker/types';
export type ClaudeUsage = {
sessionTokensUsed: number;
sessionLimit: number;
sessionPercentage: number;
sessionResetTime: string; // ISO date string
sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)"
weeklyTokensUsed: number;
weeklyLimit: number;
weeklyPercentage: number;
weeklyResetTime: string; // ISO date string
weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)"
sonnetWeeklyTokensUsed: number;
sonnetWeeklyPercentage: number;
sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)"
costUsed: number | null;
costLimit: number | null;
costCurrency: string | null;
lastUpdated: string; // ISO date string
userTimezone: string;
};
export type ClaudeStatus = {
indicator: {
color: 'green' | 'yellow' | 'orange' | 'red' | 'gray';
};
description: string;
};

View File

@@ -18,14 +18,15 @@ export {
getGitRepositoryDiffs,
} from '@automaker/git-utils';
// Re-export error utilities from shared package
export { getErrorMessage } from '@automaker/utils';
// Re-export exec utilities
export { execAsync, execEnv, isENOENT } from '../lib/exec-utils.js';
type Logger = ReturnType<typeof createLogger>;
/**
* Get error message from error object
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error';
}
/**
* Create a logError function for a specific logger
* This ensures consistent error logging format across all routes

View File

@@ -1,8 +1,8 @@
/**
* POST /context/describe-file endpoint - Generate description for a text file
*
* Uses Claude Haiku via ClaudeProvider to analyze a text file and generate
* a concise description suitable for context file metadata.
* Uses Claude Haiku to analyze a text file and generate a concise description
* suitable for context file metadata.
*
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
* and reads file content directly (not via Claude's Read tool) to prevent
@@ -10,9 +10,12 @@
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { PathNotAllowedError, secureFs } from '@automaker/platform';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
const logger = createLogger('DescribeFile');
@@ -41,6 +44,31 @@ interface DescribeFileErrorResponse {
error: string;
}
/**
* Extract text content from Claude SDK response messages
*/
async function extractTextFromStream(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
for (const block of blocks) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
/**
* Create the describe-file request handler
*
@@ -122,39 +150,60 @@ export function createDescribeFileHandler(): (req: Request, res: Response) => Pr
const fileName = path.basename(resolvedPath);
// Build prompt with file content passed as structured data
const promptContent = [
{
type: 'text' as const,
text: `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
// The file content is included directly, not via tool invocation
const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
File: ${fileName}${truncated ? ' (truncated)' : ''}`,
},
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
const promptContent = [
{ type: 'text' as const, text: instructionText },
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
];
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeSimpleQuery({
prompt: promptContent,
model: 'haiku',
// Use the file's directory as the working directory
const cwd = path.dirname(resolvedPath);
// Use centralized SDK options with proper cwd validation
// No tools needed since we're passing file content directly
const sdkOptions = createCustomOptions({
cwd,
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
});
if (!result.success) {
logger.warn('Failed to generate description:', result.error);
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
// Extract the description from the response
const description = await extractTextFromStream(stream);
if (!description || description.trim().length === 0) {
logger.warn('Received empty response from Claude');
const response: DescribeFileErrorResponse = {
success: false,
error: result.error || 'Failed to generate description',
error: 'Failed to generate description - empty response',
};
res.status(500).json(response);
return;
}
logger.info(`Description generated, length: ${result.text.length} chars`);
logger.info(`Description generated, length: ${description.length} chars`);
const response: DescribeFileSuccessResponse = {
success: true,
description: result.text,
description: description.trim(),
};
res.json(response);
} catch (error) {

View File

@@ -1,8 +1,8 @@
/**
* POST /context/describe-image endpoint - Generate description for an image
*
* Uses Claude Haiku via ClaudeProvider to analyze an image and generate
* a concise description suitable for context file metadata.
* Uses Claude Haiku to analyze an image and generate a concise description
* suitable for context file metadata.
*
* IMPORTANT:
* The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
@@ -11,9 +11,10 @@
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { PromptContentBlock } from '../../../providers/types.js';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as fs from 'fs';
import * as path from 'path';
@@ -172,6 +173,53 @@ function mapDescribeImageError(rawMessage: string | undefined): {
return baseResponse;
}
/**
* Extract text content from Claude SDK response messages and log high-signal stream events.
*/
async function extractTextFromStream(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>,
requestId: string
): Promise<string> {
let responseText = '';
let messageCount = 0;
logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`);
for await (const msg of stream) {
messageCount++;
const msgType = msg?.type;
const msgSubtype = msg?.subtype;
// Keep this concise but informative. Full error object is logged in catch blocks.
logger.info(
`[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}`
);
if (msgType === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`);
for (const block of blocks) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
if (msgType === 'result' && msgSubtype === 'success') {
if (typeof msg.result === 'string' && msg.result.length > 0) {
responseText = msg.result;
}
}
}
logger.info(
`[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}`
);
return responseText;
}
/**
* Create the describe-image request handler
*
@@ -260,17 +308,13 @@ export function createDescribeImageHandler(): (req: Request, res: Response) => P
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
const promptContent: PromptContentBlock[] = [
{ type: 'text', text: instructionText },
const promptContent = [
{ type: 'text' as const, text: instructionText },
{
type: 'image',
type: 'image' as const,
source: {
type: 'base64',
media_type: imageData.mimeType as
| 'image/jpeg'
| 'image/png'
| 'image/gif'
| 'image/webp',
type: 'base64' as const,
media_type: imageData.mimeType,
data: imageData.base64,
},
},
@@ -278,26 +322,48 @@ export function createDescribeImageHandler(): (req: Request, res: Response) => P
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
logger.info(`[${requestId}] Calling provider.executeSimpleQuery()...`);
const queryStart = Date.now();
const cwd = path.dirname(actualPath);
logger.info(`[${requestId}] Using cwd=${cwd}`);
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeSimpleQuery({
prompt: promptContent,
model: 'haiku',
// Use the same centralized option builder used across the server (validates cwd)
const sdkOptions = createCustomOptions({
cwd,
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
});
logger.info(`[${requestId}] Query completed in ${Date.now() - queryStart}ms`);
logger.info(
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
sdkOptions.allowedTools
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
);
const description = result.success ? result.text : '';
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
if (!result.success || !description || description.trim().length === 0) {
logger.warn(
`[${requestId}] Failed to generate description: ${result.error || 'empty response'}`
);
logger.info(`[${requestId}] Calling query()...`);
const queryStart = Date.now();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
// Extract the description from the response
const extractStart = Date.now();
const description = await extractTextFromStream(stream, requestId);
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
if (!description || description.trim().length === 0) {
logger.warn(`[${requestId}] Received empty response from Claude`);
const response: DescribeImageErrorResponse = {
success: false,
error: result.error || 'Failed to generate description - empty response',
error: 'Failed to generate description - empty response',
requestId,
};
res.status(500).json(response);

View File

@@ -1,19 +1,21 @@
/**
* POST /enhance-prompt endpoint - Enhance user input text
*
* Uses Claude AI via ClaudeProvider to enhance text based on the specified
* enhancement mode. Supports modes: improve, technical, simplify, acceptance
* Uses Claude AI to enhance text based on the specified enhancement mode.
* Supports modes: improve, technical, simplify, acceptance
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import {
getSystemPrompt,
buildUserPrompt,
isValidEnhancementMode,
type EnhancementMode,
} from '@automaker/prompts';
} from '../../../lib/enhancement-prompts.js';
const logger = createLogger('EnhancePrompt');
@@ -45,6 +47,39 @@ interface EnhanceErrorResponse {
error: string;
}
/**
* Extract text content from Claude SDK response messages
*
* @param stream - The async iterable from the query function
* @returns The extracted text content
*/
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
/**
* Create the enhance request handler
*
@@ -97,30 +132,45 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
const systemPrompt = getSystemPrompt(validMode);
// Build the user prompt with few-shot examples
// This helps the model understand this is text transformation, not a coding task
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
const provider = ProviderFactory.getProviderForModel(model || 'sonnet');
const result = await provider.executeSimpleQuery({
// Resolve the model - use the passed model, default to sonnet for quality
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
logger.debug(`Using model: ${resolvedModel}`);
// Call Claude SDK with minimal configuration for text transformation
// Key: no tools, just text completion
const stream = query({
prompt: userPrompt,
model: model || 'sonnet',
systemPrompt,
options: {
model: resolvedModel,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
},
});
if (!result.success) {
logger.warn('Failed to enhance text:', result.error);
// Extract the enhanced text from the response
const enhancedText = await extractTextFromStream(stream);
if (!enhancedText || enhancedText.trim().length === 0) {
logger.warn('Received empty response from Claude');
const response: EnhanceErrorResponse = {
success: false,
error: result.error || 'Failed to generate enhanced text',
error: 'Failed to generate enhanced text - empty response',
};
res.status(500).json(response);
return;
}
logger.info(`Enhancement complete, output length: ${result.text.length} chars`);
logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`);
const response: EnhanceSuccessResponse = {
success: true,
enhancedText: result.text,
enhancedText: enhancedText.trim(),
};
res.json(response);
} catch (error) {

View File

@@ -1,12 +1,13 @@
/**
* POST /features/generate-title endpoint - Generate a concise title from description
*
* Uses Claude Haiku via ClaudeProvider to generate a short, descriptive title.
* Uses Claude Haiku to generate a short, descriptive title from feature description.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
const logger = createLogger('GenerateTitle');
@@ -33,6 +34,33 @@ Rules:
- No quotes, periods, or extra formatting
- Capture the essence of the feature in a scannable way`;
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -61,28 +89,34 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeSimpleQuery({
const stream = query({
prompt: userPrompt,
model: 'haiku',
systemPrompt: SYSTEM_PROMPT,
options: {
model: CLAUDE_MODEL_MAP.haiku,
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
},
});
if (!result.success) {
logger.warn('Failed to generate title:', result.error);
const title = await extractTextFromStream(stream);
if (!title || title.trim().length === 0) {
logger.warn('Received empty response from Claude');
const response: GenerateTitleErrorResponse = {
success: false,
error: result.error || 'Failed to generate title',
error: 'Failed to generate title - empty response',
};
res.status(500).json(response);
return;
}
logger.info(`Generated title: ${result.text}`);
logger.info(`Generated title: ${title.trim()}`);
const response: GenerateTitleSuccessResponse = {
success: true,
title: result.text,
title: title.trim(),
};
res.json(response);
} catch (error) {

View File

@@ -3,11 +3,10 @@
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError, isENOENT } from '../common.js';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('FS');
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export { isENOENT };
export const logError = createLogError(logger);

View File

@@ -3,9 +3,10 @@
*/
import type { Request, Response } from 'express';
import { secureFs, getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import os from 'os';
import path from 'path';
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createBrowseHandler() {

View File

@@ -3,9 +3,10 @@
*/
import type { Request, Response } from 'express';
import { secureFs, getBoardDir } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getBoardDir } from '@automaker/platform';
export function createDeleteBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -3,7 +3,8 @@
*/
import type { Request, Response } from 'express';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler() {

View File

@@ -3,7 +3,8 @@
*/
import type { Request, Response } from 'express';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createExistsHandler() {

View File

@@ -3,8 +3,9 @@
*/
import type { Request, Response } from 'express';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createImageHandler() {

View File

@@ -4,8 +4,9 @@
*/
import type { Request, Response } from 'express';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createMkdirHandler() {

View File

@@ -3,8 +3,9 @@
*/
import type { Request, Response } from 'express';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError, isENOENT } from '../common.js';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise
@@ -14,6 +15,10 @@ function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
}
function isENOENT(error: unknown): boolean {
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
}
export function createReadHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {

View File

@@ -3,7 +3,8 @@
*/
import type { Request, Response } from 'express';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createReaddirHandler() {

View File

@@ -3,7 +3,7 @@
*/
import type { Request, Response } from 'express';
import { secureFs } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';

View File

@@ -3,9 +3,10 @@
*/
import type { Request, Response } from 'express';
import { secureFs, getBoardDir } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getBoardDir } from '@automaker/platform';
export function createSaveBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -3,9 +3,10 @@
*/
import type { Request, Response } from 'express';
import { secureFs, getImagesDir } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getImagesDir } from '@automaker/platform';
export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -3,7 +3,8 @@
*/
import type { Request, Response } from 'express';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createStatHandler() {

View File

@@ -3,8 +3,9 @@
*/
import type { Request, Response } from 'express';
import { secureFs, isPathAllowed } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { isPathAllowed } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createValidatePathHandler() {

View File

@@ -3,8 +3,9 @@
*/
import type { Request, Response } from 'express';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';

View File

@@ -3,11 +3,14 @@
*/
import type { Request, Response } from 'express';
import type { GitHubRemoteStatus } from '@automaker/types';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
// Re-export type for convenience
export type { GitHubRemoteStatus } from '@automaker/types';
export interface GitHubRemoteStatus {
hasGitHubRemote: boolean;
remoteUrl: string | null;
owner: string | null;
repo: string | null;
}
export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemoteStatus> {
const status: GitHubRemoteStatus = {

View File

@@ -2,16 +2,34 @@
* Common utilities for GitHub routes
*/
import { createLogger } from '@automaker/utils';
import { createLogError, getErrorMessage } from '../../common.js';
import { execAsync, execEnv } from '../../../lib/exec-utils.js';
import { exec } from 'child_process';
import { promisify } from 'util';
const logger = createLogger('GitHub');
export const execAsync = promisify(exec);
// Re-export exec utilities for convenience
export { execAsync, execEnv } from '../../../lib/exec-utils.js';
// Extended PATH to include common tool installation locations
export const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
// Re-export error utilities
export { getErrorMessage } from '../../common.js';
export const execEnv = {
...process.env,
PATH: extendedPath,
};
export const logError = createLogError(logger);
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
console.error(`[GitHub] ${context}:`, error);
}

View File

@@ -3,12 +3,35 @@
*/
import type { Request, Response } from 'express';
import type { GitHubIssue, ListIssuesResult } from '@automaker/types';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
// Re-export types for convenience
export type { GitHubLabel, GitHubAuthor, GitHubIssue, ListIssuesResult } from '@automaker/types';
export interface GitHubLabel {
name: string;
color: string;
}
export interface GitHubAuthor {
login: string;
}
export interface GitHubIssue {
number: number;
title: string;
state: string;
author: GitHubAuthor;
createdAt: string;
labels: GitHubLabel[];
url: string;
body: string;
}
export interface ListIssuesResult {
success: boolean;
openIssues?: GitHubIssue[];
closedIssues?: GitHubIssue[];
error?: string;
}
export function createListIssuesHandler() {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -3,12 +3,39 @@
*/
import type { Request, Response } from 'express';
import type { GitHubPR, ListPRsResult } from '@automaker/types';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
// Re-export types for convenience
export type { GitHubLabel, GitHubAuthor, GitHubPR, ListPRsResult } from '@automaker/types';
export interface GitHubLabel {
name: string;
color: string;
}
export interface GitHubAuthor {
login: string;
}
export interface GitHubPR {
number: number;
title: string;
state: string;
author: GitHubAuthor;
createdAt: string;
labels: GitHubLabel[];
url: string;
isDraft: boolean;
headRefName: string;
reviewDecision: string | null;
mergeable: string;
body: string;
}
export interface ListPRsResult {
success: boolean;
openPRs?: GitHubPR[];
mergedPRs?: GitHubPR[];
error?: string;
}
export function createListPRsHandler() {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -1,12 +1,11 @@
/**
* Business logic for generating suggestions
*
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
const logger = createLogger('Suggestions');
@@ -69,44 +68,62 @@ The response will be automatically formatted as structured JSON.`;
content: `Starting ${suggestionType} analysis...\n`,
});
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeStreamingQuery({
prompt,
model: 'haiku',
const options = createSuggestionsOptions({
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
outputFormat: {
type: 'json_schema',
schema: suggestionsSchema,
},
onText: (text) => {
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: text,
});
},
onToolUse: (name, input) => {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool: name,
input,
});
},
});
const stream = query({ prompt, options });
let responseText = '';
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: block.text,
});
} else if (block.type === 'tool_use') {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
// Check for structured output
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as {
suggestions: Array<Record<string, unknown>>;
};
logger.debug('Received structured output:', structuredOutput);
}
} else if (msg.type === 'result') {
const resultMsg = msg as any;
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid suggestions output');
} else if (resultMsg.subtype === 'error_max_turns') {
logger.error('Hit max turns limit before completing suggestions generation');
logger.warn(`Response text length: ${responseText.length} chars`);
// Still try to parse what we have
}
}
}
// Use structured output if available, otherwise fall back to parsing text
try {
const structuredOutput = result.structuredOutput as
| {
suggestions: Array<Record<string, unknown>>;
}
| undefined;
if (structuredOutput && structuredOutput.suggestions) {
// Use structured output directly
logger.debug('Received structured output:', structuredOutput);
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
@@ -117,7 +134,7 @@ The response will be automatically formatted as structured JSON.`;
} else {
// Fallback: try to parse from text (for backwards compatibility)
logger.warn('No structured output received, attempting to parse from text');
const jsonMatch = result.text.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
events.emit('suggestions:event', {

View File

@@ -5,7 +5,8 @@
import type { Request, Response } from 'express';
import { spawn } from 'child_process';
import path from 'path';
import { secureFs, PathNotAllowedError } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { logger, getErrorMessage, logError } from '../common.js';
export function createCloneHandler() {

View File

@@ -3,8 +3,9 @@
*/
import type { Request, Response } from 'express';
import { secureFs, getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createConfigHandler() {

View File

@@ -3,8 +3,9 @@
*/
import type { Request, Response } from 'express';
import { secureFs, getAllowedRootDirectory } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getAllowedRootDirectory } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createDirectoriesHandler() {

View File

@@ -3,16 +3,16 @@
*/
import { createLogger } from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
const featureLoader = new FeatureLoader();
// Re-export exec utilities for convenience
export { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js';
// ============================================================================
// Constants
// ============================================================================
@@ -20,6 +20,48 @@ export { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js';
/** Maximum allowed length for git branch names */
export const MAX_BRANCH_NAME_LENGTH = 250;
// ============================================================================
// Extended PATH configuration for Electron apps
// ============================================================================
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const additionalPaths: string[] = [];
if (process.platform === 'win32') {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
}
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env['ProgramFiles(x86)']) {
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
}
} else {
// Unix/Mac paths
additionalPaths.push(
'/opt/homebrew/bin', // Homebrew on Apple Silicon
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
'/home/linuxbrew/.linuxbrew/bin', // Linuxbrew
`${process.env.HOME}/.local/bin` // pipx, other user installs
);
}
const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
.filter(Boolean)
.join(pathSeparator);
/**
* Environment variables with extended PATH for executing shell commands.
* Electron apps don't inherit the user's shell PATH, so we need to add
* common tool installation locations.
*/
export const execEnv = {
...process.env,
PATH: extendedPath,
};
// ============================================================================
// Validation utilities
// ============================================================================
@@ -69,6 +111,14 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
}
}
/**
* Check if an error is ENOENT (file/path not found or spawn failed)
* These are expected in test environments with mock paths
*/
export function isENOENT(error: unknown): boolean {
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
}
/**
* Check if a path is a mock/test path that doesn't exist
*/

View File

@@ -5,12 +5,15 @@
* can switch between branches even after worktrees are removed.
*/
import { secureFs, getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
import type { TrackedBranch } from '@automaker/types';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
// Re-export type for convenience
export type { TrackedBranch } from '@automaker/types';
export interface TrackedBranch {
name: string;
createdAt: string;
lastActivatedAt?: string;
}
interface BranchTrackingData {
branches: TrackedBranch[];

View File

@@ -11,7 +11,7 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { secureFs } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import {
isGitRepo,
getErrorMessage,

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from 'express';
import path from 'path';
import { secureFs } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
import { getGitRepositoryDiffs } from '../../common.js';

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { secureFs } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
import { generateSyntheticDiffForNewFile } from '../../common.js';

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { secureFs } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError, normalizePath } from '../common.js';
const execAsync = promisify(exec);

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { secureFs } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { join } from 'path';
import { getErrorMessage, logError } from '../common.js';

View File

@@ -8,7 +8,7 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { secureFs } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';

View File

@@ -3,7 +3,6 @@
*/
import type { Request, Response } from 'express';
import type { PRComment, PRInfo } from '@automaker/types';
import {
getErrorMessage,
logError,
@@ -13,8 +12,26 @@ import {
isGhCliAvailable,
} from '../common.js';
// Re-export types for convenience
export type { PRComment, PRInfo } from '@automaker/types';
export interface PRComment {
id: number;
author: string;
body: string;
path?: string;
line?: number;
createdAt: string;
isReviewComment: boolean;
}
export interface PRInfo {
number: number;
title: string;
url: string;
state: string;
author: string;
body: string;
comments: PRComment[];
reviewComments: PRComment[];
}
export function createPRInfoHandler() {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { secureFs } from '@automaker/platform';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);

View File

@@ -4,6 +4,7 @@
*/
import path from 'path';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import type { ExecuteOptions } from '@automaker/types';
import {
@@ -11,13 +12,10 @@ import {
buildPromptWithImages,
isAbortError,
loadContextFiles,
createLogger,
} from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { PathNotAllowedError, secureFs } from '@automaker/platform';
const logger = createLogger('AgentService');
import { PathNotAllowedError } from '@automaker/platform';
interface Message {
id: string;
@@ -152,7 +150,7 @@ export class AgentService {
filename: imageData.filename,
});
} catch (error) {
logger.error(`Failed to load image ${imagePath}:`, error);
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
}
}
}
@@ -217,7 +215,9 @@ export class AgentService {
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel);
logger.info(`Using provider "${provider.getName()}" for model "${effectiveModel}"`);
console.log(
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
);
// Build options for provider
const options: ExecuteOptions = {
@@ -254,7 +254,7 @@ export class AgentService {
// Capture SDK session ID from any message and persist it
if (msg.session_id && !session.sdkSessionId) {
session.sdkSessionId = msg.session_id;
logger.info(`Captured SDK session ID: ${msg.session_id}`);
console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`);
// Persist the SDK session ID to ensure conversation continuity across server restarts
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
}
@@ -330,7 +330,7 @@ export class AgentService {
return { success: false, aborted: true };
}
logger.error('Error:', error);
console.error('[AgentService] Error:', error);
session.isRunning = false;
session.abortController = null;
@@ -424,7 +424,7 @@ export class AgentService {
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
await this.updateSessionTimestamp(sessionId);
} catch (error) {
logger.error('Failed to save session:', error);
console.error('[AgentService] Failed to save session:', error);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,162 +0,0 @@
/**
* Feature Verification Service - Handles verification and commit operations
*
* Provides functionality to verify feature implementations (lint, typecheck, test, build)
* and commit changes to git.
*/
import { createLogger } from '@automaker/utils';
import {
runVerificationChecks,
hasUncommittedChanges,
commitAll,
shortHash,
} from '@automaker/git-utils';
import { extractTitleFromDescription } from '@automaker/prompts';
import { getFeatureDir, secureFs } from '@automaker/platform';
import path from 'path';
import type { EventEmitter } from '../../lib/events.js';
import type { Feature } from '@automaker/types';
const logger = createLogger('FeatureVerification');
export interface VerificationResult {
success: boolean;
failedCheck?: string;
}
export interface CommitResult {
hash: string | null;
shortHash?: string;
}
export class FeatureVerificationService {
private events: EventEmitter;
constructor(events: EventEmitter) {
this.events = events;
}
/**
* Resolve the working directory for a feature (checks for worktree)
*/
async resolveWorkDir(projectPath: string, featureId: string): Promise<string> {
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await secureFs.access(worktreePath);
return worktreePath;
} catch {
return projectPath;
}
}
/**
* Verify a feature's implementation by running checks
*/
async verify(projectPath: string, featureId: string): Promise<VerificationResult> {
const workDir = await this.resolveWorkDir(projectPath, featureId);
const result = await runVerificationChecks(workDir);
if (result.success) {
this.emitEvent('auto_mode_feature_complete', {
featureId,
passes: true,
message: 'All verification checks passed',
});
} else {
this.emitEvent('auto_mode_feature_complete', {
featureId,
passes: false,
message: `Verification failed: ${result.failedCheck}`,
});
}
return result;
}
/**
* Commit feature changes
*/
async commit(
projectPath: string,
featureId: string,
feature: Feature | null,
providedWorktreePath?: string
): Promise<CommitResult> {
let workDir = projectPath;
if (providedWorktreePath) {
try {
await secureFs.access(providedWorktreePath);
workDir = providedWorktreePath;
} catch {
// Use project path
}
} else {
workDir = await this.resolveWorkDir(projectPath, featureId);
}
// Check for changes
const hasChanges = await hasUncommittedChanges(workDir);
if (!hasChanges) {
return { hash: null };
}
// Build commit message
const title = feature
? extractTitleFromDescription(feature.description)
: `Feature ${featureId}`;
const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`;
// Commit changes
const hash = await commitAll(workDir, commitMessage);
if (hash) {
const short = shortHash(hash);
this.emitEvent('auto_mode_feature_complete', {
featureId,
passes: true,
message: `Changes committed: ${short}`,
});
return { hash, shortHash: short };
}
logger.error(`Commit failed for ${featureId}`);
return { hash: null };
}
/**
* Check if context (agent-output.md) exists for a feature
*/
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
try {
await secureFs.access(contextPath);
return true;
} catch {
return false;
}
}
/**
* Load existing context for a feature
*/
async loadContext(projectPath: string, featureId: string): Promise<string | null> {
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
try {
return (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
return null;
}
}
private emitEvent(eventType: string, data: Record<string, unknown>): void {
this.events.emit('auto-mode:event', { type: eventType, ...data });
}
}

View File

@@ -1,28 +0,0 @@
/**
* Auto Mode Services
*
* Re-exports all auto-mode related services and types.
*/
// Services
export { PlanApprovalService } from './plan-approval-service.js';
export { TaskExecutor } from './task-executor.js';
export { WorktreeManager, worktreeManager } from './worktree-manager.js';
export { OutputWriter, createFeatureOutputWriter } from './output-writer.js';
export { ProjectAnalyzer } from './project-analyzer.js';
export { FeatureVerificationService } from './feature-verification.js';
export type { VerificationResult, CommitResult } from './feature-verification.js';
// Types
export type {
RunningFeature,
AutoLoopState,
AutoModeConfig,
PendingApproval,
ApprovalResult,
FeatureExecutionOptions,
RunAgentOptions,
FeatureWithPlanning,
TaskExecutionContext,
TaskProgress,
} from './types.js';

View File

@@ -1,154 +0,0 @@
/**
* Output Writer - Incremental file writing for agent output
*
* Handles debounced file writes to avoid excessive I/O during streaming.
* Used to persist agent output to agent-output.md in the feature directory.
*/
import { secureFs } from '@automaker/platform';
import path from 'path';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OutputWriter');
/**
* Handles incremental, debounced file writing for agent output
*/
export class OutputWriter {
private content = '';
private writeTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly debounceMs: number;
private readonly outputPath: string;
/**
* Create a new output writer
*
* @param outputPath - Full path to the output file
* @param debounceMs - Debounce interval for writes (default: 500ms)
* @param initialContent - Optional initial content to start with
*/
constructor(outputPath: string, debounceMs = 500, initialContent = '') {
this.outputPath = outputPath;
this.debounceMs = debounceMs;
this.content = initialContent;
}
/**
* Append text to the output
*
* Schedules a debounced write to the file.
*/
append(text: string): void {
this.content += text;
this.scheduleWrite();
}
/**
* Append text with automatic separator handling
*
* Ensures proper spacing between sections.
*/
appendWithSeparator(text: string): void {
if (this.content.length > 0 && !this.content.endsWith('\n\n')) {
if (this.content.endsWith('\n')) {
this.content += '\n';
} else {
this.content += '\n\n';
}
}
this.append(text);
}
/**
* Append a tool use entry
*/
appendToolUse(toolName: string, input?: unknown): void {
if (this.content.length > 0 && !this.content.endsWith('\n')) {
this.content += '\n';
}
this.content += `\n🔧 Tool: ${toolName}\n`;
if (input) {
this.content += `Input: ${JSON.stringify(input, null, 2)}\n`;
}
this.scheduleWrite();
}
/**
* Get the current accumulated content
*/
getContent(): string {
return this.content;
}
/**
* Set content directly (for follow-up sessions with previous content)
*/
setContent(content: string): void {
this.content = content;
}
/**
* Schedule a debounced write
*/
private scheduleWrite(): void {
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
}
this.writeTimeout = setTimeout(() => {
this.flush().catch((error) => {
logger.error('Failed to flush output', error);
});
}, this.debounceMs);
}
/**
* Flush content to disk immediately
*
* Call this to ensure all content is written, e.g., at the end of execution.
*/
async flush(): Promise<void> {
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
this.writeTimeout = null;
}
try {
await secureFs.mkdir(path.dirname(this.outputPath), { recursive: true });
await secureFs.writeFile(this.outputPath, this.content);
} catch (error) {
logger.error(`Failed to write to ${this.outputPath}`, error);
// Don't throw - file write errors shouldn't crash execution
}
}
/**
* Cancel any pending writes
*/
cancel(): void {
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
this.writeTimeout = null;
}
}
}
/**
* Create an output writer for a feature
*
* @param featureDir - The feature directory path
* @param previousContent - Optional content from previous session
* @returns Configured output writer
*/
export function createFeatureOutputWriter(
featureDir: string,
previousContent?: string
): OutputWriter {
const outputPath = path.join(featureDir, 'agent-output.md');
// If there's previous content, add a follow-up separator
const initialContent = previousContent
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
: '';
return new OutputWriter(outputPath, 500, initialContent);
}

View File

@@ -1,236 +0,0 @@
/**
* Plan Approval Service - Handles plan/spec approval workflow
*
* Manages the async approval flow where:
* 1. Agent generates a spec with [SPEC_GENERATED] marker
* 2. Service emits plan_approval_required event
* 3. User reviews and approves/rejects via API
* 4. Service resolves the waiting promise to continue execution
*/
import type { EventEmitter } from '../../lib/events.js';
import type { PlanSpec, PlanningMode } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import type { PendingApproval, ApprovalResult } from './types.js';
const logger = createLogger('PlanApprovalService');
/**
* Manages plan approval workflow for spec-driven development
*/
export class PlanApprovalService {
private pendingApprovals = new Map<string, PendingApproval>();
private events: EventEmitter;
constructor(events: EventEmitter) {
this.events = events;
}
/**
* Wait for plan approval from the user
*
* Returns a promise that resolves when the user approves or rejects
* the plan via the API.
*
* @param featureId - The feature awaiting approval
* @param projectPath - The project path
* @returns Promise resolving to approval result
*/
waitForApproval(featureId: string, projectPath: string): Promise<ApprovalResult> {
logger.debug(`Registering pending approval for feature ${featureId}`);
logger.debug(
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
);
return new Promise((resolve, reject) => {
this.pendingApprovals.set(featureId, {
resolve,
reject,
featureId,
projectPath,
});
logger.debug(`Pending approval registered for feature ${featureId}`);
});
}
/**
* Resolve a pending plan approval
*
* Called when the user approves or rejects the plan via API.
*
* @param featureId - The feature ID
* @param approved - Whether the plan was approved
* @param editedPlan - Optional edited plan content
* @param feedback - Optional user feedback
* @returns Result indicating success or error
*/
resolve(
featureId: string,
approved: boolean,
editedPlan?: string,
feedback?: string
): { success: boolean; error?: string; projectPath?: string } {
logger.debug(`resolvePlanApproval called for feature ${featureId}, approved=${approved}`);
logger.debug(
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
);
const pending = this.pendingApprovals.get(featureId);
if (!pending) {
logger.warn(`No pending approval found for feature ${featureId}`);
return {
success: false,
error: `No pending approval for feature ${featureId}`,
};
}
logger.debug(`Found pending approval for feature ${featureId}, resolving...`);
// Resolve the promise with all data including feedback
pending.resolve({ approved, editedPlan, feedback });
this.pendingApprovals.delete(featureId);
return { success: true, projectPath: pending.projectPath };
}
/**
* Cancel a pending plan approval
*
* Called when a feature is stopped while waiting for approval.
*
* @param featureId - The feature ID to cancel
*/
cancel(featureId: string): void {
logger.debug(`cancelPlanApproval called for feature ${featureId}`);
const pending = this.pendingApprovals.get(featureId);
if (pending) {
logger.debug(`Found and cancelling pending approval for feature ${featureId}`);
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
this.pendingApprovals.delete(featureId);
} else {
logger.debug(`No pending approval to cancel for feature ${featureId}`);
}
}
/**
* Check if a feature has a pending plan approval
*
* @param featureId - The feature ID to check
* @returns True if there's a pending approval
*/
hasPending(featureId: string): boolean {
return this.pendingApprovals.has(featureId);
}
/**
* Get the project path for a pending approval
*
* Useful for recovery scenarios where we need to know which
* project a pending approval belongs to.
*
* @param featureId - The feature ID
* @returns The project path or undefined
*/
getProjectPath(featureId: string): string | undefined {
return this.pendingApprovals.get(featureId)?.projectPath;
}
/**
* Get all pending approval feature IDs
*
* @returns Array of feature IDs with pending approvals
*/
getAllPending(): string[] {
return Array.from(this.pendingApprovals.keys());
}
/**
* Emit a plan-related event
*/
emitPlanEvent(
eventType: string,
featureId: string,
projectPath: string,
data: Record<string, unknown> = {}
): void {
this.events.emit('auto-mode:event', {
type: eventType,
featureId,
projectPath,
...data,
});
}
/**
* Emit plan approval required event
*/
emitApprovalRequired(
featureId: string,
projectPath: string,
planContent: string,
planningMode: PlanningMode,
planVersion: number
): void {
this.emitPlanEvent('plan_approval_required', featureId, projectPath, {
planContent,
planningMode,
planVersion,
});
}
/**
* Emit plan approved event
*/
emitApproved(
featureId: string,
projectPath: string,
hasEdits: boolean,
planVersion: number
): void {
this.emitPlanEvent('plan_approved', featureId, projectPath, {
hasEdits,
planVersion,
});
}
/**
* Emit plan rejected event
*/
emitRejected(featureId: string, projectPath: string, feedback?: string): void {
this.emitPlanEvent('plan_rejected', featureId, projectPath, { feedback });
}
/**
* Emit plan auto-approved event
*/
emitAutoApproved(
featureId: string,
projectPath: string,
planContent: string,
planningMode: PlanningMode
): void {
this.emitPlanEvent('plan_auto_approved', featureId, projectPath, {
planContent,
planningMode,
});
}
/**
* Emit plan revision requested event
*/
emitRevisionRequested(
featureId: string,
projectPath: string,
feedback: string | undefined,
hasEdits: boolean,
planVersion: number
): void {
this.emitPlanEvent('plan_revision_requested', featureId, projectPath, {
feedback,
hasEdits,
planVersion,
});
}
}

View File

@@ -1,109 +0,0 @@
/**
* Project Analyzer - Analyzes project structure and context
*
* Provides project analysis functionality using Claude to understand
* codebase architecture, patterns, and conventions.
*/
import type { ExecuteOptions } from '@automaker/types';
import { createLogger, classifyError, processStream } from '@automaker/utils';
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
import { getAutomakerDir, secureFs } from '@automaker/platform';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
import path from 'path';
import type { EventEmitter } from '../../lib/events.js';
const logger = createLogger('ProjectAnalyzer');
const ANALYSIS_PROMPT = `Analyze this project and provide a summary of:
1. Project structure and architecture
2. Main technologies and frameworks used
3. Key components and their responsibilities
4. Build and test commands
5. Any existing conventions or patterns
Format your response as a structured markdown document.`;
export class ProjectAnalyzer {
private events: EventEmitter;
constructor(events: EventEmitter) {
this.events = events;
}
/**
* Analyze project to gather context
*/
async analyze(projectPath: string): Promise<void> {
validateWorkingDirectory(projectPath);
const abortController = new AbortController();
const analysisFeatureId = `analysis-${Date.now()}`;
this.emitEvent('auto_mode_feature_start', {
featureId: analysisFeatureId,
projectPath,
feature: {
id: analysisFeatureId,
title: 'Project Analysis',
description: 'Analyzing project structure',
},
});
try {
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
const provider = ProviderFactory.getProviderForModel(analysisModel);
const options: ExecuteOptions = {
prompt: ANALYSIS_PROMPT,
model: analysisModel,
maxTurns: 5,
cwd: projectPath,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
};
const stream = provider.executeQuery(options);
let analysisResult = '';
const result = await processStream(stream, {
onText: (text) => {
analysisResult += text;
this.emitEvent('auto_mode_progress', {
featureId: analysisFeatureId,
content: text,
projectPath,
});
},
});
analysisResult = result.text || analysisResult;
// Save analysis
const automakerDir = getAutomakerDir(projectPath);
const analysisPath = path.join(automakerDir, 'project-analysis.md');
await secureFs.mkdir(automakerDir, { recursive: true });
await secureFs.writeFile(analysisPath, analysisResult);
this.emitEvent('auto_mode_feature_complete', {
featureId: analysisFeatureId,
passes: true,
message: 'Project analysis completed',
projectPath,
});
} catch (error) {
const errorInfo = classifyError(error);
this.emitEvent('auto_mode_error', {
featureId: analysisFeatureId,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
});
}
}
private emitEvent(eventType: string, data: Record<string, unknown>): void {
this.events.emit('auto-mode:event', { type: eventType, ...data });
}
}

View File

@@ -1,267 +0,0 @@
/**
* Task Executor - Multi-agent task execution for spec-driven development
*
* Handles the sequential execution of parsed tasks from a spec,
* where each task gets its own focused agent call.
*/
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
import type { EventEmitter } from '../../lib/events.js';
import type { BaseProvider } from '../../providers/base-provider.js';
import { buildTaskPrompt } from '@automaker/prompts';
import { createLogger, processStream } from '@automaker/utils';
import type { TaskExecutionContext, TaskProgress } from './types.js';
const logger = createLogger('TaskExecutor');
/**
* Handles multi-agent task execution for spec-driven development
*/
export class TaskExecutor {
private events: EventEmitter;
constructor(events: EventEmitter) {
this.events = events;
}
/**
* Execute all tasks sequentially
*
* Each task gets its own focused agent call with context about
* completed and remaining tasks.
*
* @param tasks - Parsed tasks from the spec
* @param context - Execution context including provider, model, etc.
* @param provider - The provider to use for execution
* @yields TaskProgress events for each task
*/
async *executeAll(
tasks: ParsedTask[],
context: TaskExecutionContext,
provider: BaseProvider
): AsyncGenerator<TaskProgress> {
logger.info(
`Starting multi-agent execution: ${tasks.length} tasks for feature ${context.featureId}`
);
for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
const task = tasks[taskIndex];
// Check for abort
if (context.abortController.signal.aborted) {
throw new Error('Feature execution aborted');
}
// Emit task started
logger.info(`Starting task ${task.id}: ${task.description}`);
this.emitTaskEvent('auto_mode_task_started', context, {
taskId: task.id,
taskDescription: task.description,
taskIndex,
tasksTotal: tasks.length,
});
yield {
taskId: task.id,
taskIndex,
tasksTotal: tasks.length,
status: 'started',
};
// Build focused prompt for this task
const taskPrompt = buildTaskPrompt(
task,
tasks,
taskIndex,
context.planContent,
context.userFeedback
);
// Execute task with dedicated agent call
const taskOptions: ExecuteOptions = {
prompt: taskPrompt,
model: context.model,
maxTurns: Math.min(context.maxTurns, 50), // Limit turns per task
cwd: context.workDir,
allowedTools: context.allowedTools,
abortController: context.abortController,
};
const taskStream = provider.executeQuery(taskOptions);
// Process task stream
let taskOutput = '';
try {
const result = await processStream(taskStream, {
onText: (text) => {
taskOutput += text;
this.emitProgressEvent(context.featureId, text);
},
onToolUse: (name, input) => {
this.emitToolEvent(context.featureId, name, input);
},
});
taskOutput = result.text;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Task ${task.id} failed: ${errorMessage}`);
yield {
taskId: task.id,
taskIndex,
tasksTotal: tasks.length,
status: 'failed',
output: errorMessage,
};
throw error;
}
// Emit task completed
logger.info(`Task ${task.id} completed for feature ${context.featureId}`);
this.emitTaskEvent('auto_mode_task_complete', context, {
taskId: task.id,
tasksCompleted: taskIndex + 1,
tasksTotal: tasks.length,
});
// Check for phase completion
const phaseComplete = this.checkPhaseComplete(task, tasks, taskIndex);
yield {
taskId: task.id,
taskIndex,
tasksTotal: tasks.length,
status: 'completed',
output: taskOutput,
phaseComplete,
};
// Emit phase complete if needed
if (phaseComplete !== undefined) {
this.emitPhaseComplete(context, phaseComplete);
}
}
logger.info(`All ${tasks.length} tasks completed for feature ${context.featureId}`);
}
/**
* Execute a single task (for cases where you don't need the full loop)
*
* @param task - The task to execute
* @param allTasks - All tasks for context
* @param taskIndex - Index of this task
* @param context - Execution context
* @param provider - The provider to use
* @returns Task output text
*/
async executeOne(
task: ParsedTask,
allTasks: ParsedTask[],
taskIndex: number,
context: TaskExecutionContext,
provider: BaseProvider
): Promise<string> {
const taskPrompt = buildTaskPrompt(
task,
allTasks,
taskIndex,
context.planContent,
context.userFeedback
);
const taskOptions: ExecuteOptions = {
prompt: taskPrompt,
model: context.model,
maxTurns: Math.min(context.maxTurns, 50),
cwd: context.workDir,
allowedTools: context.allowedTools,
abortController: context.abortController,
};
const taskStream = provider.executeQuery(taskOptions);
const result = await processStream(taskStream, {
onText: (text) => {
this.emitProgressEvent(context.featureId, text);
},
onToolUse: (name, input) => {
this.emitToolEvent(context.featureId, name, input);
},
});
return result.text;
}
/**
* Check if completing this task completes a phase
*/
private checkPhaseComplete(
task: ParsedTask,
allTasks: ParsedTask[],
taskIndex: number
): number | undefined {
if (!task.phase) {
return undefined;
}
const nextTask = allTasks[taskIndex + 1];
if (!nextTask || nextTask.phase !== task.phase) {
// Phase changed or no more tasks
const phaseMatch = task.phase.match(/Phase\s*(\d+)/i);
return phaseMatch ? parseInt(phaseMatch[1], 10) : undefined;
}
return undefined;
}
/**
* Emit a task-related event
*/
private emitTaskEvent(
eventType: string,
context: TaskExecutionContext,
data: Record<string, unknown>
): void {
this.events.emit('auto-mode:event', {
type: eventType,
featureId: context.featureId,
projectPath: context.projectPath,
...data,
});
}
/**
* Emit progress event for text output
*/
private emitProgressEvent(featureId: string, content: string): void {
this.events.emit('auto-mode:event', {
type: 'auto_mode_progress',
featureId,
content,
});
}
/**
* Emit tool use event
*/
private emitToolEvent(featureId: string, tool: string, input: unknown): void {
this.events.emit('auto-mode:event', {
type: 'auto_mode_tool',
featureId,
tool,
input,
});
}
/**
* Emit phase complete event
*/
private emitPhaseComplete(context: TaskExecutionContext, phaseNumber: number): void {
this.events.emit('auto-mode:event', {
type: 'auto_mode_phase_complete',
featureId: context.featureId,
projectPath: context.projectPath,
phaseNumber,
});
}
}

View File

@@ -1,121 +0,0 @@
/**
* Internal types for AutoModeService
*
* These types are used internally by the auto-mode services
* and are not exported to the public API.
*/
import type { PlanningMode, PlanSpec } from '@automaker/types';
/**
* Running feature state
*/
export interface RunningFeature {
featureId: string;
projectPath: string;
worktreePath: string | null;
branchName: string | null;
abortController: AbortController;
isAutoMode: boolean;
startTime: number;
}
/**
* Auto-loop configuration
*/
export interface AutoLoopState {
projectPath: string;
maxConcurrency: number;
abortController: AbortController;
isRunning: boolean;
}
/**
* Auto-mode configuration
*/
export interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
projectPath: string;
}
/**
* Pending plan approval state
*/
export interface PendingApproval {
resolve: (result: ApprovalResult) => void;
reject: (error: Error) => void;
featureId: string;
projectPath: string;
}
/**
* Result of plan approval
*/
export interface ApprovalResult {
approved: boolean;
editedPlan?: string;
feedback?: string;
}
/**
* Options for executing a feature
*/
export interface FeatureExecutionOptions {
continuationPrompt?: string;
}
/**
* Options for running the agent
*/
export interface RunAgentOptions {
projectPath: string;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
previousContent?: string;
systemPrompt?: string;
}
/**
* Feature with planning fields for internal use
*/
export interface FeatureWithPlanning {
id: string;
description: string;
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string }>;
branchName?: string;
skipTests?: boolean;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
planSpec?: PlanSpec;
[key: string]: unknown;
}
/**
* Task execution context
*/
export interface TaskExecutionContext {
workDir: string;
featureId: string;
projectPath: string;
model: string;
maxTurns: number;
allowedTools?: string[];
abortController: AbortController;
planContent: string;
userFeedback?: string;
}
/**
* Task progress event
*/
export interface TaskProgress {
taskId: string;
taskIndex: number;
tasksTotal: number;
status: 'started' | 'completed' | 'failed';
output?: string;
phaseComplete?: number;
}

View File

@@ -1,157 +0,0 @@
/**
* Worktree Manager - Git worktree operations for feature isolation
*
* Handles finding and resolving git worktrees for feature branches.
* Worktrees are created when features are added/edited, this service
* finds existing worktrees for execution.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec);
const logger = createLogger('WorktreeManager');
/**
* Result of resolving a working directory
*/
export interface WorkDirResult {
/** The resolved working directory path */
workDir: string;
/** The worktree path if using a worktree, null otherwise */
worktreePath: string | null;
}
/**
* Manages git worktree operations for feature isolation
*/
export class WorktreeManager {
/**
* Find existing worktree path for a branch
*
* Parses `git worktree list --porcelain` output to find the worktree
* associated with a specific branch.
*
* @param projectPath - The main project path
* @param branchName - The branch to find a worktree for
* @returns The absolute path to the worktree, or null if not found
*/
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
try {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
const lines = stdout.split('\n');
let currentPath: string | null = null;
let currentBranch: string | null = null;
for (const line of lines) {
if (line.startsWith('worktree ')) {
currentPath = line.slice(9);
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
} else if (line === '' && currentPath && currentBranch) {
// End of a worktree entry
if (currentBranch === branchName) {
// Resolve to absolute path - git may return relative paths
// On Windows, this is critical for cwd to work correctly
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return resolvedPath;
}
currentPath = null;
currentBranch = null;
}
}
// Check the last entry (if file doesn't end with newline)
if (currentPath && currentBranch && currentBranch === branchName) {
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return resolvedPath;
}
return null;
} catch (error) {
logger.warn(`Failed to find worktree for branch ${branchName}`, error);
return null;
}
}
/**
* Resolve the working directory for feature execution
*
* If worktrees are enabled and a branch name is provided, attempts to
* find an existing worktree. Falls back to the project path if no
* worktree is found.
*
* @param projectPath - The main project path
* @param branchName - Optional branch name to look for
* @param useWorktrees - Whether to use worktrees
* @returns The resolved work directory and worktree path
*/
async resolveWorkDir(
projectPath: string,
branchName: string | undefined,
useWorktrees: boolean
): Promise<WorkDirResult> {
let worktreePath: string | null = null;
if (useWorktrees && branchName) {
worktreePath = await this.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) {
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
} else {
logger.warn(`Worktree for branch "${branchName}" not found, using project path`);
}
}
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
return { workDir, worktreePath };
}
/**
* Check if a path is a valid worktree
*
* @param worktreePath - Path to check
* @returns True if the path is a valid git worktree
*/
async isValidWorktree(worktreePath: string): Promise<boolean> {
try {
// Check if .git file exists (worktrees have a .git file, not directory)
const { stdout } = await execAsync('git rev-parse --is-inside-work-tree', {
cwd: worktreePath,
});
return stdout.trim() === 'true';
} catch {
return false;
}
}
/**
* Get the branch name for a worktree
*
* @param worktreePath - Path to the worktree
* @returns The branch name or null if not a valid worktree
*/
async getWorktreeBranch(worktreePath: string): Promise<string | null> {
try {
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
return stdout.trim();
} catch {
return null;
}
}
}
// Export a singleton instance for convenience
export const worktreeManager = new WorktreeManager();

View File

@@ -8,13 +8,10 @@
*/
import { spawn, execSync, type ChildProcess } from 'child_process';
import { secureFs } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import path from 'path';
import net from 'net';
const logger = createLogger('DevServerService');
export interface DevServerInfo {
worktreePath: string;
port: number;
@@ -72,7 +69,7 @@ class DevServerService {
for (const pid of pids) {
try {
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
logger.info(`Killed process ${pid} on port ${port}`);
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
}
@@ -85,7 +82,7 @@ class DevServerService {
for (const pid of pids) {
try {
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
logger.info(`Killed process ${pid} on port ${port}`);
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
}
@@ -96,7 +93,7 @@ class DevServerService {
}
} catch (error) {
// Ignore errors - port might not have any process
logger.info(`No process to kill on port ${port}`);
console.log(`[DevServerService] No process to kill on port ${port}`);
}
}
@@ -254,9 +251,11 @@ class DevServerService {
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
logger.info(`Starting dev server on port ${port}`);
logger.info(`Working directory (cwd): ${worktreePath}`);
logger.info(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
console.log(`[DevServerService] Starting dev server on port ${port}`);
console.log(`[DevServerService] Working directory (cwd): ${worktreePath}`);
console.log(
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`
);
// Spawn the dev process with PORT environment variable
const env = {
@@ -277,26 +276,26 @@ class DevServerService {
// Log output for debugging
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
logger.info(`[DevServer:${port}] ${data.toString().trim()}`);
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
});
}
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
const msg = data.toString().trim();
logger.error(`[DevServer:${port}] ${msg}`);
console.error(`[DevServer:${port}] ${msg}`);
});
}
devProcess.on('error', (error) => {
logger.error(`Process error:`, error);
console.error(`[DevServerService] Process error:`, error);
status.error = error.message;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
});
devProcess.on('exit', (code) => {
logger.info(`Process for ${worktreePath} exited with code ${code}`);
console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
@@ -353,7 +352,9 @@ class DevServerService {
// If we don't have a record of this server, it may have crashed/exited on its own
// Return success so the frontend can clear its state
if (!server) {
logger.info(`No server record for ${worktreePath}, may have already stopped`);
console.log(
`[DevServerService] No server record for ${worktreePath}, may have already stopped`
);
return {
success: true,
result: {
@@ -363,7 +364,7 @@ class DevServerService {
};
}
logger.info(`Stopping dev server for ${worktreePath}`);
console.log(`[DevServerService] Stopping dev server for ${worktreePath}`);
// Kill the process
if (server.process && !server.process.killed) {
@@ -433,7 +434,7 @@ class DevServerService {
* Stop all running dev servers (for cleanup)
*/
async stopAll(): Promise<void> {
logger.info(`Stopping all ${this.runningServers.size} dev servers`);
console.log(`[DevServerService] Stopping all ${this.runningServers.size} dev servers`);
for (const [worktreePath] of this.runningServers) {
await this.stopDevServer(worktreePath);

View File

@@ -4,15 +4,14 @@
*/
import path from 'path';
import type { Feature, PlanSpec, FeatureStatus } from '@automaker/types';
import type { Feature } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
import * as secureFs from '../lib/secure-fs.js';
import {
getFeaturesDir,
getFeatureDir,
getFeatureImagesDir,
ensureAutomakerDir,
secureFs,
} from '@automaker/platform';
const logger = createLogger('FeatureLoader');
@@ -57,7 +56,7 @@ export class FeatureLoader {
try {
// Paths are now absolute
await secureFs.unlink(oldPath);
logger.info(`Deleted orphaned image: ${oldPath}`);
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
} catch (error) {
// Ignore errors when deleting (file may already be gone)
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
@@ -112,7 +111,7 @@ export class FeatureLoader {
// Copy the file
await secureFs.copyFile(fullOriginalPath, newPath);
logger.info(`Copied image: ${originalPath} -> ${newPath}`);
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
// Try to delete the original temp file
try {
@@ -333,7 +332,7 @@ export class FeatureLoader {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await secureFs.rm(featureDir, { recursive: true, force: true });
logger.info(`Deleted feature ${featureId}`);
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
@@ -382,115 +381,4 @@ export class FeatureLoader {
}
}
}
/**
* Check if agent output exists for a feature
*/
async hasAgentOutput(projectPath: string, featureId: string): Promise<boolean> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await secureFs.access(agentOutputPath);
return true;
} catch {
return false;
}
}
/**
* Update feature status with proper timestamp handling
* Used by auto-mode to update feature status during execution
*/
async updateStatus(
projectPath: string,
featureId: string,
status: FeatureStatus
): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
const feature = JSON.parse(content) as Feature;
feature.status = status;
feature.updatedAt = new Date().toISOString();
// Handle justFinishedAt for waiting_approval status
if (status === 'waiting_approval') {
feature.justFinishedAt = new Date().toISOString();
} else {
feature.justFinishedAt = undefined;
}
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2));
return feature;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`[FeatureLoader] Failed to update status for ${featureId}:`, error);
return null;
}
}
/**
* Update feature plan specification
* Handles version incrementing and timestamp management
*/
async updatePlanSpec(
projectPath: string,
featureId: string,
updates: Partial<PlanSpec>
): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
const feature = JSON.parse(content) as Feature;
// Initialize planSpec if not present
if (!feature.planSpec) {
feature.planSpec = { status: 'pending', version: 1, reviewedByUser: false };
}
// Increment version if content changed
if (updates.content && updates.content !== feature.planSpec.content) {
feature.planSpec.version = (feature.planSpec.version || 0) + 1;
}
// Merge updates
Object.assign(feature.planSpec, updates);
feature.updatedAt = new Date().toISOString();
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2));
return feature;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`[FeatureLoader] Failed to update planSpec for ${featureId}:`, error);
return null;
}
}
/**
* Get features that are pending and ready to execute
* Filters by status and resolves dependencies
*/
async getPending(projectPath: string): Promise<Feature[]> {
try {
const allFeatures = await this.getAll(projectPath);
const pendingFeatures = allFeatures.filter(
(f) => f.status && ['pending', 'ready', 'backlog'].includes(f.status)
);
// Resolve dependencies and order features
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Filter to features whose dependencies are satisfied
return orderedFeatures.filter((feature: Feature) =>
areDependenciesSatisfied(feature, allFeatures)
);
} catch (error) {
logger.error('[FeatureLoader] Failed to get pending features:', error);
return [];
}
}
}

View File

@@ -8,13 +8,14 @@
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
getGlobalSettingsPath,
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
ensureAutomakerDir,
secureFs,
} from '@automaker/platform';
import type {
GlobalSettings,

View File

@@ -10,9 +10,6 @@ import { EventEmitter } from 'events';
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '@automaker/utils';
const logger = createLogger('Terminal');
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
@@ -174,7 +171,7 @@ export class TerminalService extends EventEmitter {
// Reject paths with null bytes (could bypass path checks)
if (cwd.includes('\0')) {
logger.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
return homeDir;
}
@@ -195,10 +192,10 @@ export class TerminalService extends EventEmitter {
if (stat.isDirectory()) {
return cwd;
}
logger.warn(`Path exists but is not a directory: ${cwd}, falling back to home`);
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
return homeDir;
} catch {
logger.warn(`Working directory does not exist: ${cwd}, falling back to home`);
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
return homeDir;
}
}
@@ -223,7 +220,7 @@ export class TerminalService extends EventEmitter {
setMaxSessions(limit: number): void {
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
maxSessions = limit;
logger.info(`Max sessions limit updated to ${limit}`);
console.log(`[Terminal] Max sessions limit updated to ${limit}`);
}
}
@@ -234,7 +231,7 @@ export class TerminalService extends EventEmitter {
createSession(options: TerminalOptions = {}): TerminalSession | null {
// Check session limit
if (this.sessions.size >= maxSessions) {
logger.error(`Max sessions (${maxSessions}) reached, refusing new session`);
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
return null;
}
@@ -259,7 +256,7 @@ export class TerminalService extends EventEmitter {
...options.env,
};
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
@@ -331,13 +328,13 @@ export class TerminalService extends EventEmitter {
// Handle exit
ptyProcess.onExit(({ exitCode }) => {
logger.info(`Session ${id} exited with code ${exitCode}`);
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit('exit', id, exitCode);
});
logger.info(`Session ${id} created successfully`);
console.log(`[Terminal] Session ${id} created successfully`);
return session;
}
@@ -347,7 +344,7 @@ export class TerminalService extends EventEmitter {
write(sessionId: string, data: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
logger.warn(`Session ${sessionId} not found`);
console.warn(`[Terminal] Session ${sessionId} not found`);
return false;
}
session.pty.write(data);
@@ -362,7 +359,7 @@ export class TerminalService extends EventEmitter {
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
logger.warn(`Session ${sessionId} not found for resize`);
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
return false;
}
try {
@@ -388,7 +385,7 @@ export class TerminalService extends EventEmitter {
return true;
} catch (error) {
logger.error(`Error resizing session ${sessionId}:`, error);
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
session.resizeInProgress = false; // Clear flag on error
return false;
}
@@ -416,14 +413,14 @@ export class TerminalService extends EventEmitter {
}
// First try graceful SIGTERM to allow process cleanup
logger.info(`Session ${sessionId} sending SIGTERM`);
console.log(`[Terminal] Session ${sessionId} sending SIGTERM`);
session.pty.kill('SIGTERM');
// Schedule SIGKILL fallback if process doesn't exit gracefully
// The onExit handler will remove session from map when it actually exits
setTimeout(() => {
if (this.sessions.has(sessionId)) {
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
try {
session.pty.kill('SIGKILL');
} catch {
@@ -434,10 +431,10 @@ export class TerminalService extends EventEmitter {
}
}, 1000);
logger.info(`Session ${sessionId} kill initiated`);
console.log(`[Terminal] Session ${sessionId} kill initiated`);
return true;
} catch (error) {
logger.error(`Error killing session ${sessionId}:`, error);
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
// Still try to remove from map even if kill fails
this.sessions.delete(sessionId);
return false;
@@ -520,7 +517,7 @@ export class TerminalService extends EventEmitter {
* Clean up all sessions
*/
cleanup(): void {
logger.info(`Cleaning up ${this.sessions.size} sessions`);
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
this.sessions.forEach((session, id) => {
try {
// Clean up flush timeout

View File

@@ -15,7 +15,7 @@ import {
SIMPLIFY_EXAMPLES,
ACCEPTANCE_EXAMPLES,
type EnhancementMode,
} from '@automaker/prompts';
} from '@/lib/enhancement-prompts.js';
describe('enhancement-prompts.ts', () => {
describe('System Prompt Constants', () => {

View File

@@ -1,143 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Store original platform and env
const originalPlatform = process.platform;
const originalEnv = { ...process.env };
describe('exec-utils.ts', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
// Restore original values
Object.defineProperty(process, 'platform', { value: originalPlatform });
process.env = { ...originalEnv };
});
describe('execAsync', () => {
it('should be a promisified exec function', async () => {
const { execAsync } = await import('@/lib/exec-utils.js');
expect(typeof execAsync).toBe('function');
});
it('should execute shell commands successfully', async () => {
const { execAsync } = await import('@/lib/exec-utils.js');
const result = await execAsync('echo "hello"');
expect(result.stdout.trim()).toBe('hello');
});
it('should reject on invalid commands', async () => {
const { execAsync } = await import('@/lib/exec-utils.js');
await expect(execAsync('nonexistent-command-12345')).rejects.toThrow();
});
});
describe('extendedPath', () => {
it('should include the original PATH', async () => {
const { extendedPath } = await import('@/lib/exec-utils.js');
expect(extendedPath).toContain(process.env.PATH);
});
it('should include additional Unix paths on non-Windows', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
vi.resetModules();
const { extendedPath } = await import('@/lib/exec-utils.js');
expect(extendedPath).toContain('/opt/homebrew/bin');
expect(extendedPath).toContain('/usr/local/bin');
});
});
describe('execEnv', () => {
it('should have PATH set to extendedPath', async () => {
const { execEnv, extendedPath } = await import('@/lib/exec-utils.js');
expect(execEnv.PATH).toBe(extendedPath);
});
it('should include all original environment variables', async () => {
const { execEnv } = await import('@/lib/exec-utils.js');
// Should have common env vars
expect(execEnv.HOME || execEnv.USERPROFILE).toBeDefined();
});
});
describe('isENOENT', () => {
it('should return true for ENOENT errors', async () => {
const { isENOENT } = await import('@/lib/exec-utils.js');
const error = { code: 'ENOENT' };
expect(isENOENT(error)).toBe(true);
});
it('should return false for other error codes', async () => {
const { isENOENT } = await import('@/lib/exec-utils.js');
const error = { code: 'EACCES' };
expect(isENOENT(error)).toBe(false);
});
it('should return false for null', async () => {
const { isENOENT } = await import('@/lib/exec-utils.js');
expect(isENOENT(null)).toBe(false);
});
it('should return false for undefined', async () => {
const { isENOENT } = await import('@/lib/exec-utils.js');
expect(isENOENT(undefined)).toBe(false);
});
it('should return false for non-objects', async () => {
const { isENOENT } = await import('@/lib/exec-utils.js');
expect(isENOENT('ENOENT')).toBe(false);
expect(isENOENT(123)).toBe(false);
});
it('should return false for objects without code property', async () => {
const { isENOENT } = await import('@/lib/exec-utils.js');
expect(isENOENT({})).toBe(false);
expect(isENOENT({ message: 'error' })).toBe(false);
});
it('should handle Error objects with code', async () => {
const { isENOENT } = await import('@/lib/exec-utils.js');
const error = new Error('File not found') as Error & { code: string };
error.code = 'ENOENT';
expect(isENOENT(error)).toBe(true);
});
});
describe('Windows platform handling', () => {
it('should use semicolon as path separator on Windows', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
process.env.LOCALAPPDATA = 'C:\\Users\\Test\\AppData\\Local';
process.env.PROGRAMFILES = 'C:\\Program Files';
vi.resetModules();
const { extendedPath } = await import('@/lib/exec-utils.js');
// Windows uses semicolon separator
expect(extendedPath).toContain(';');
expect(extendedPath).toContain('\\Git\\cmd');
});
});
describe('Unix platform handling', () => {
it('should use colon as path separator on Unix', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.HOME = '/home/testuser';
vi.resetModules();
const { extendedPath } = await import('@/lib/exec-utils.js');
// Unix uses colon separator
expect(extendedPath).toContain(':');
expect(extendedPath).toContain('/home/linuxbrew/.linuxbrew/bin');
});
it('should include HOME/.local/bin path', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
process.env.HOME = '/Users/testuser';
vi.resetModules();
const { extendedPath } = await import('@/lib/exec-utils.js');
expect(extendedPath).toContain('/Users/testuser/.local/bin');
});
});
});

View File

@@ -9,15 +9,7 @@ import { collectAsyncGenerator } from '../../utils/helpers.js';
vi.mock('fs/promises');
vi.mock('@/providers/provider-factory.js');
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual('@automaker/utils');
return {
...actual,
readImageAsBase64: vi.fn(),
buildPromptWithImages: vi.fn(),
loadContextFiles: vi.fn(),
};
});
vi.mock('@automaker/utils');
describe('agent-service.ts', () => {
let service: AgentService;

View File

@@ -1,12 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AutoModeService } from '@/services/auto-mode-service.js';
import {
getPlanningPromptPrefix,
parseTasksFromSpec,
parseTaskLine,
buildFeaturePrompt,
extractTitleFromDescription,
} from '@automaker/prompts';
describe('auto-mode-service.ts - Planning Mode', () => {
let service: AutoModeService;
@@ -25,28 +18,54 @@ describe('auto-mode-service.ts - Planning Mode', () => {
await service.stopAutoLoop().catch(() => {});
});
describe('getPlanningPromptPrefix (from @automaker/prompts)', () => {
describe('getPlanningPromptPrefix', () => {
// Access private method through any cast for testing
const getPlanningPromptPrefix = (svc: any, feature: any) => {
return svc.getPlanningPromptPrefix(feature);
};
it('should return empty string for skip mode', () => {
const result = getPlanningPromptPrefix('skip');
const feature = { id: 'test', planningMode: 'skip' as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toBe('');
});
it('should return empty string when planningMode is undefined', () => {
const feature = { id: 'test' };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toBe('');
});
it('should return lite prompt for lite mode without approval', () => {
const result = getPlanningPromptPrefix('lite', false);
const feature = {
id: 'test',
planningMode: 'lite' as const,
requirePlanApproval: false,
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Planning Phase (Lite Mode)');
expect(result).toContain('[PLAN_GENERATED]');
expect(result).toContain('Feature Request');
});
it('should return lite_with_approval prompt for lite mode with approval', () => {
const result = getPlanningPromptPrefix('lite', true);
const feature = {
id: 'test',
planningMode: 'lite' as const,
requirePlanApproval: true,
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Planning Phase (Lite Mode)');
expect(result).toContain('[SPEC_GENERATED]');
expect(result).toContain('DO NOT proceed with implementation');
});
it('should return spec prompt for spec mode', () => {
const result = getPlanningPromptPrefix('spec');
const feature = {
id: 'test',
planningMode: 'spec' as const,
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Specification Phase (Spec Mode)');
expect(result).toContain('```tasks');
expect(result).toContain('T001');
@@ -55,7 +74,11 @@ describe('auto-mode-service.ts - Planning Mode', () => {
});
it('should return full prompt for full mode', () => {
const result = getPlanningPromptPrefix('full');
const feature = {
id: 'test',
planningMode: 'full' as const,
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
expect(result).toContain('Phase 1: Foundation');
expect(result).toContain('Phase 2: Core Implementation');
@@ -63,7 +86,11 @@ describe('auto-mode-service.ts - Planning Mode', () => {
});
it('should include the separator and Feature Request header', () => {
const result = getPlanningPromptPrefix('spec');
const feature = {
id: 'test',
planningMode: 'spec' as const,
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('---');
expect(result).toContain('## Feature Request');
});
@@ -71,7 +98,8 @@ describe('auto-mode-service.ts - Planning Mode', () => {
it('should instruct agent to NOT output exploration text', () => {
const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) {
const result = getPlanningPromptPrefix(mode);
const feature = { id: 'test', planningMode: mode };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Do NOT output exploration text');
expect(result).toContain('Start DIRECTLY');
}
@@ -170,14 +198,17 @@ describe('auto-mode-service.ts - Planning Mode', () => {
});
});
describe('buildFeaturePrompt (from @automaker/prompts)', () => {
describe('buildFeaturePrompt', () => {
const buildFeaturePrompt = (svc: any, feature: any) => {
return svc.buildFeaturePrompt(feature);
};
it('should include feature ID and description', () => {
const feature = {
id: 'feat-123',
category: 'Test',
description: 'Add user authentication',
};
const result = buildFeaturePrompt(feature);
const result = buildFeaturePrompt(service, feature);
expect(result).toContain('feat-123');
expect(result).toContain('Add user authentication');
});
@@ -185,11 +216,10 @@ describe('auto-mode-service.ts - Planning Mode', () => {
it('should include specification when present', () => {
const feature = {
id: 'feat-123',
category: 'Test',
description: 'Test feature',
spec: 'Detailed specification here',
};
const result = buildFeaturePrompt(feature);
const result = buildFeaturePrompt(service, feature);
expect(result).toContain('Specification:');
expect(result).toContain('Detailed specification here');
});
@@ -197,14 +227,13 @@ describe('auto-mode-service.ts - Planning Mode', () => {
it('should include image paths when present', () => {
const feature = {
id: 'feat-123',
category: 'Test',
description: 'Test feature',
imagePaths: [
{ path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' },
'/tmp/image2.jpg',
],
};
const result = buildFeaturePrompt(feature);
const result = buildFeaturePrompt(service, feature);
expect(result).toContain('Context Images Attached');
expect(result).toContain('image1.png');
expect(result).toContain('/tmp/image2.jpg');
@@ -213,46 +242,55 @@ describe('auto-mode-service.ts - Planning Mode', () => {
it('should include summary tags instruction', () => {
const feature = {
id: 'feat-123',
category: 'Test',
description: 'Test feature',
};
const result = buildFeaturePrompt(feature);
const result = buildFeaturePrompt(service, feature);
expect(result).toContain('<summary>');
expect(result).toContain('summary');
expect(result).toContain('</summary>');
});
});
describe('extractTitleFromDescription (from @automaker/prompts)', () => {
describe('extractTitleFromDescription', () => {
const extractTitle = (svc: any, description: string) => {
return svc.extractTitleFromDescription(description);
};
it("should return 'Untitled Feature' for empty description", () => {
expect(extractTitleFromDescription('')).toBe('Untitled Feature');
expect(extractTitleFromDescription(' ')).toBe('Untitled Feature');
expect(extractTitle(service, '')).toBe('Untitled Feature');
expect(extractTitle(service, ' ')).toBe('Untitled Feature');
});
it('should return first line if under 60 characters', () => {
const description = 'Add user login\nWith email validation';
expect(extractTitleFromDescription(description)).toBe('Add user login');
expect(extractTitle(service, description)).toBe('Add user login');
});
it('should truncate long first lines to 60 characters', () => {
const description =
'This is a very long feature description that exceeds the sixty character limit significantly';
const result = extractTitleFromDescription(description);
const result = extractTitle(service, description);
expect(result.length).toBe(60);
expect(result).toContain('...');
});
});
describe('PLANNING_PROMPTS structure (from @automaker/prompts)', () => {
describe('PLANNING_PROMPTS structure', () => {
const getPlanningPromptPrefix = (svc: any, feature: any) => {
return svc.getPlanningPromptPrefix(feature);
};
it('should have all required planning modes', () => {
const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) {
const result = getPlanningPromptPrefix(mode);
const feature = { id: 'test', planningMode: mode };
const result = getPlanningPromptPrefix(service, feature);
expect(result.length).toBeGreaterThan(100);
}
});
it('lite prompt should include correct structure', () => {
const result = getPlanningPromptPrefix('lite');
const feature = { id: 'test', planningMode: 'lite' as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Goal');
expect(result).toContain('Approach');
expect(result).toContain('Files to Touch');
@@ -261,7 +299,8 @@ describe('auto-mode-service.ts - Planning Mode', () => {
});
it('spec prompt should include task format instructions', () => {
const result = getPlanningPromptPrefix('spec');
const feature = { id: 'test', planningMode: 'spec' as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Problem');
expect(result).toContain('Solution');
expect(result).toContain('Acceptance Criteria');
@@ -271,7 +310,8 @@ describe('auto-mode-service.ts - Planning Mode', () => {
});
it('full prompt should include phases', () => {
const result = getPlanningPromptPrefix('full');
const feature = { id: 'test', planningMode: 'full' as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Problem Statement');
expect(result).toContain('User Story');
expect(result).toContain('Technical Context');

View File

@@ -1,5 +1,92 @@
import { describe, it, expect } from 'vitest';
import { parseTaskLine, parseTasksFromSpec } from '@automaker/prompts';
/**
* Test the task parsing logic by reimplementing the parsing functions
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
*/
interface ParsedTask {
id: string;
description: string;
filePath?: string;
phase?: string;
status: 'pending' | 'in_progress' | 'completed';
}
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
// Match pattern: - [ ] T###: Description | File: path
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
if (!taskMatch) {
// Try simpler pattern without file
const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
if (simpleMatch) {
return {
id: simpleMatch[1],
description: simpleMatch[2].trim(),
phase: currentPhase,
status: 'pending',
};
}
return null;
}
return {
id: taskMatch[1],
description: taskMatch[2].trim(),
filePath: taskMatch[3]?.trim(),
phase: currentPhase,
status: 'pending',
};
}
function parseTasksFromSpec(specContent: string): ParsedTask[] {
const tasks: ParsedTask[] = [];
// Extract content within ```tasks ... ``` block
const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
if (!tasksBlockMatch) {
// Try fallback: look for task lines anywhere in content
const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
if (!taskLines) {
return tasks;
}
// Parse fallback task lines
let currentPhase: string | undefined;
for (const line of taskLines) {
const parsed = parseTaskLine(line, currentPhase);
if (parsed) {
tasks.push(parsed);
}
}
return tasks;
}
const tasksContent = tasksBlockMatch[1];
const lines = tasksContent.split('\n');
let currentPhase: string | undefined;
for (const line of lines) {
const trimmedLine = line.trim();
// Check for phase header (e.g., "## Phase 1: Foundation")
const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
if (phaseMatch) {
currentPhase = phaseMatch[1].trim();
continue;
}
// Check for task line
if (trimmedLine.startsWith('- [ ]')) {
const parsed = parseTaskLine(trimmedLine, currentPhase);
if (parsed) {
tasks.push(parsed);
}
}
}
return tasks;
}
describe('Task Parsing', () => {
describe('parseTaskLine', () => {

View File

@@ -1,238 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FeatureVerificationService } from '@/services/auto-mode/feature-verification.js';
// Mock dependencies
vi.mock('@automaker/platform', () => ({
secureFs: {
access: vi.fn(),
readFile: vi.fn(),
},
getFeatureDir: vi.fn(
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
),
}));
vi.mock('@automaker/git-utils', () => ({
runVerificationChecks: vi.fn(),
hasUncommittedChanges: vi.fn(),
commitAll: vi.fn(),
shortHash: vi.fn((hash: string) => hash.substring(0, 7)),
}));
vi.mock('@automaker/prompts', () => ({
extractTitleFromDescription: vi.fn((desc: string) => desc.split('\n')[0]),
}));
import { secureFs, getFeatureDir } from '@automaker/platform';
import { runVerificationChecks, hasUncommittedChanges, commitAll } from '@automaker/git-utils';
describe('FeatureVerificationService', () => {
let service: FeatureVerificationService;
let mockEvents: { emit: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockEvents = { emit: vi.fn() };
service = new FeatureVerificationService(mockEvents as any);
});
describe('constructor', () => {
it('should create service instance', () => {
expect(service).toBeInstanceOf(FeatureVerificationService);
});
});
describe('resolveWorkDir', () => {
it('should return worktree path if it exists', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const result = await service.resolveWorkDir('/project', 'feature-1');
expect(result).toBe('/project/.worktrees/feature-1');
expect(secureFs.access).toHaveBeenCalledWith('/project/.worktrees/feature-1');
});
it('should return project path if worktree does not exist', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
const result = await service.resolveWorkDir('/project', 'feature-1');
expect(result).toBe('/project');
});
});
describe('verify', () => {
it('should emit success event when verification passes', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
vi.mocked(runVerificationChecks).mockResolvedValue({ success: true });
const result = await service.verify('/project', 'feature-1');
expect(result.success).toBe(true);
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_feature_complete',
featureId: 'feature-1',
passes: true,
message: 'All verification checks passed',
});
});
it('should emit failure event when verification fails', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
vi.mocked(runVerificationChecks).mockResolvedValue({
success: false,
failedCheck: 'lint',
});
const result = await service.verify('/project', 'feature-1');
expect(result.success).toBe(false);
expect(result.failedCheck).toBe('lint');
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_feature_complete',
featureId: 'feature-1',
passes: false,
message: 'Verification failed: lint',
});
});
it('should use worktree path if available', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(runVerificationChecks).mockResolvedValue({ success: true });
await service.verify('/project', 'feature-1');
expect(runVerificationChecks).toHaveBeenCalledWith('/project/.worktrees/feature-1');
});
});
describe('commit', () => {
it('should return null hash when no changes', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
vi.mocked(hasUncommittedChanges).mockResolvedValue(false);
const result = await service.commit('/project', 'feature-1', null);
expect(result.hash).toBeNull();
expect(commitAll).not.toHaveBeenCalled();
});
it('should commit changes and return hash', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
vi.mocked(commitAll).mockResolvedValue('abc123def456');
const result = await service.commit('/project', 'feature-1', {
id: 'feature-1',
description: 'Add login button\nWith authentication',
} as any);
expect(result.hash).toBe('abc123def456');
expect(result.shortHash).toBe('abc123d');
expect(commitAll).toHaveBeenCalledWith(
'/project',
expect.stringContaining('feat: Add login button')
);
});
it('should use provided worktree path', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
vi.mocked(commitAll).mockResolvedValue('abc123');
await service.commit('/project', 'feature-1', null, '/custom/worktree');
expect(hasUncommittedChanges).toHaveBeenCalledWith('/custom/worktree');
});
it('should fall back to project path if provided worktree does not exist', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
vi.mocked(hasUncommittedChanges).mockResolvedValue(false);
await service.commit('/project', 'feature-1', null, '/nonexistent/worktree');
expect(hasUncommittedChanges).toHaveBeenCalledWith('/project');
});
it('should use feature ID in commit message when no feature provided', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
vi.mocked(commitAll).mockResolvedValue('abc123');
await service.commit('/project', 'feature-123', null);
expect(commitAll).toHaveBeenCalledWith(
'/project',
expect.stringContaining('feat: Feature feature-123')
);
});
it('should emit event on successful commit', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
vi.mocked(commitAll).mockResolvedValue('abc123def');
await service.commit('/project', 'feature-1', null);
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_feature_complete',
featureId: 'feature-1',
passes: true,
message: expect.stringContaining('Changes committed:'),
});
});
it('should return null hash when commit fails', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
vi.mocked(commitAll).mockResolvedValue(null);
const result = await service.commit('/project', 'feature-1', null);
expect(result.hash).toBeNull();
});
});
describe('contextExists', () => {
it('should return true if context file exists', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const result = await service.contextExists('/project', 'feature-1');
expect(result).toBe(true);
expect(secureFs.access).toHaveBeenCalledWith(
'/project/.automaker/features/feature-1/agent-output.md'
);
});
it('should return false if context file does not exist', async () => {
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
const result = await service.contextExists('/project', 'feature-1');
expect(result).toBe(false);
});
});
describe('loadContext', () => {
it('should return context content if file exists', async () => {
vi.mocked(secureFs.readFile).mockResolvedValue('# Agent Output\nSome content');
const result = await service.loadContext('/project', 'feature-1');
expect(result).toBe('# Agent Output\nSome content');
expect(secureFs.readFile).toHaveBeenCalledWith(
'/project/.automaker/features/feature-1/agent-output.md',
'utf-8'
);
});
it('should return null if file does not exist', async () => {
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const result = await service.loadContext('/project', 'feature-1');
expect(result).toBeNull();
});
});
});

View File

@@ -1,161 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Use vi.hoisted for mock functions that need to be used in vi.mock factories
const {
mockExecuteQuery,
mockProcessStream,
mockMkdir,
mockWriteFile,
mockValidateWorkingDirectory,
} = vi.hoisted(() => ({
mockExecuteQuery: vi.fn(),
mockProcessStream: vi.fn(),
mockMkdir: vi.fn(),
mockWriteFile: vi.fn(),
mockValidateWorkingDirectory: vi.fn(),
}));
// Mock dependencies
vi.mock('@automaker/platform', () => ({
secureFs: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
},
getAutomakerDir: (projectPath: string) => `${projectPath}/.automaker`,
}));
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual('@automaker/utils');
return {
...actual,
processStream: mockProcessStream,
};
});
vi.mock('@automaker/model-resolver', () => ({
resolveModelString: () => 'claude-sonnet-4-20250514',
DEFAULT_MODELS: { claude: 'claude-sonnet-4-20250514' },
}));
vi.mock('@/providers/provider-factory.js', () => ({
ProviderFactory: {
getProviderForModel: () => ({
executeQuery: mockExecuteQuery,
}),
},
}));
vi.mock('@/lib/sdk-options.js', () => ({
validateWorkingDirectory: mockValidateWorkingDirectory,
}));
import { ProjectAnalyzer } from '@/services/auto-mode/project-analyzer.js';
describe('ProjectAnalyzer', () => {
let analyzer: ProjectAnalyzer;
let mockEvents: { emit: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockEvents = { emit: vi.fn() };
mockExecuteQuery.mockReturnValue(
(async function* () {
yield { type: 'text', text: 'Analysis result' };
})()
);
mockProcessStream.mockResolvedValue({
text: '# Project Analysis\nThis is a test project.',
toolUses: [],
});
analyzer = new ProjectAnalyzer(mockEvents as any);
});
describe('constructor', () => {
it('should create analyzer instance', () => {
expect(analyzer).toBeInstanceOf(ProjectAnalyzer);
});
});
describe('analyze', () => {
it('should validate working directory', async () => {
await analyzer.analyze('/project');
expect(mockValidateWorkingDirectory).toHaveBeenCalledWith('/project');
});
it('should emit start event', async () => {
await analyzer.analyze('/project');
expect(mockEvents.emit).toHaveBeenCalledWith(
'auto-mode:event',
expect.objectContaining({
type: 'auto_mode_feature_start',
projectPath: '/project',
})
);
});
it('should call provider executeQuery with correct options', async () => {
await analyzer.analyze('/project');
expect(mockExecuteQuery).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/project',
maxTurns: 5,
allowedTools: ['Read', 'Glob', 'Grep'],
})
);
});
it('should save analysis to file', async () => {
await analyzer.analyze('/project');
expect(mockMkdir).toHaveBeenCalledWith('/project/.automaker', { recursive: true });
expect(mockWriteFile).toHaveBeenCalledWith(
'/project/.automaker/project-analysis.md',
'# Project Analysis\nThis is a test project.'
);
});
it('should emit complete event on success', async () => {
await analyzer.analyze('/project');
expect(mockEvents.emit).toHaveBeenCalledWith(
'auto-mode:event',
expect.objectContaining({
type: 'auto_mode_feature_complete',
passes: true,
message: 'Project analysis completed',
})
);
});
it('should emit error event on failure', async () => {
mockProcessStream.mockRejectedValue(new Error('Analysis failed'));
await analyzer.analyze('/project');
expect(mockEvents.emit).toHaveBeenCalledWith(
'auto-mode:event',
expect.objectContaining({
type: 'auto_mode_error',
error: expect.stringContaining('Analysis failed'),
})
);
});
it('should handle stream with onText callback', async () => {
await analyzer.analyze('/project');
expect(mockProcessStream).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
onText: expect.any(Function),
})
);
});
});
});

View File

@@ -1,332 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TaskExecutor } from '@/services/auto-mode/task-executor.js';
import type { ParsedTask } from '@automaker/types';
import type { TaskExecutionContext } from '@/services/auto-mode/types.js';
// Use vi.hoisted for mock functions
const { mockBuildTaskPrompt, mockProcessStream } = vi.hoisted(() => ({
mockBuildTaskPrompt: vi.fn(),
mockProcessStream: vi.fn(),
}));
// Mock dependencies
vi.mock('@automaker/prompts', () => ({
buildTaskPrompt: mockBuildTaskPrompt,
}));
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual('@automaker/utils');
return {
...actual,
processStream: mockProcessStream,
};
});
describe('TaskExecutor', () => {
let executor: TaskExecutor;
let mockEvents: { emit: ReturnType<typeof vi.fn> };
let mockProvider: { executeQuery: ReturnType<typeof vi.fn> };
let mockContext: TaskExecutionContext;
let mockTasks: ParsedTask[];
beforeEach(() => {
vi.clearAllMocks();
mockEvents = { emit: vi.fn() };
mockProvider = {
executeQuery: vi.fn().mockReturnValue(
(async function* () {
yield { type: 'text', text: 'Task output' };
})()
),
};
mockContext = {
featureId: 'feature-1',
projectPath: '/project',
workDir: '/project/worktree',
model: 'claude-sonnet-4-20250514',
maxTurns: 100,
allowedTools: ['Read', 'Write'],
abortController: new AbortController(),
planContent: '# Plan\nTask list',
userFeedback: undefined,
};
mockTasks = [
{ id: '1', description: 'Task 1', phase: 'Phase 1' },
{ id: '2', description: 'Task 2', phase: 'Phase 1' },
{ id: '3', description: 'Task 3', phase: 'Phase 2' },
];
mockBuildTaskPrompt.mockReturnValue('Generated task prompt');
mockProcessStream.mockResolvedValue({ text: 'Processed output', toolUses: [] });
executor = new TaskExecutor(mockEvents as any);
});
describe('constructor', () => {
it('should create executor instance', () => {
expect(executor).toBeInstanceOf(TaskExecutor);
});
});
describe('executeAll', () => {
it('should yield started and completed events for each task', async () => {
const results: any[] = [];
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
// Should have 2 events per task (started + completed)
expect(results).toHaveLength(6);
expect(results[0]).toEqual({
taskId: '1',
taskIndex: 0,
tasksTotal: 3,
status: 'started',
});
expect(results[1]).toEqual({
taskId: '1',
taskIndex: 0,
tasksTotal: 3,
status: 'completed',
output: 'Processed output',
phaseComplete: undefined,
});
});
it('should emit task started events', async () => {
const results: any[] = [];
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_task_started',
featureId: 'feature-1',
projectPath: '/project',
taskId: '1',
taskDescription: 'Task 1',
taskIndex: 0,
tasksTotal: 3,
});
});
it('should emit task complete events', async () => {
const results: any[] = [];
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_task_complete',
featureId: 'feature-1',
projectPath: '/project',
taskId: '1',
tasksCompleted: 1,
tasksTotal: 3,
});
});
it('should throw on abort', async () => {
mockContext.abortController.abort();
const results: any[] = [];
await expect(async () => {
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
}).rejects.toThrow('Feature execution aborted');
});
it('should call provider executeQuery with correct options', async () => {
const results: any[] = [];
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
expect(mockProvider.executeQuery).toHaveBeenCalledWith({
prompt: 'Generated task prompt',
model: 'claude-sonnet-4-20250514',
maxTurns: 50, // Limited to 50 per task
cwd: '/project/worktree',
allowedTools: ['Read', 'Write'],
abortController: mockContext.abortController,
});
});
it('should detect phase completion', async () => {
const results: any[] = [];
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
// Task 2 completes Phase 1 (next task is Phase 2)
const task2Completed = results.find((r) => r.taskId === '2' && r.status === 'completed');
expect(task2Completed?.phaseComplete).toBe(1);
// Task 3 completes Phase 2 (no more tasks)
const task3Completed = results.find((r) => r.taskId === '3' && r.status === 'completed');
expect(task3Completed?.phaseComplete).toBe(2);
});
it('should emit phase complete events', async () => {
const results: any[] = [];
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_phase_complete',
featureId: 'feature-1',
projectPath: '/project',
phaseNumber: 1,
});
});
it('should yield failed status on error', async () => {
mockProcessStream.mockRejectedValueOnce(new Error('Task failed'));
const results: any[] = [];
await expect(async () => {
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
}).rejects.toThrow('Task failed');
expect(results).toContainEqual({
taskId: '1',
taskIndex: 0,
tasksTotal: 3,
status: 'failed',
output: 'Task failed',
});
});
});
describe('executeOne', () => {
it('should execute a single task and return output', async () => {
const result = await executor.executeOne(
mockTasks[0],
mockTasks,
0,
mockContext,
mockProvider as any
);
expect(result).toBe('Processed output');
});
it('should build prompt with correct parameters', async () => {
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
expect(mockBuildTaskPrompt).toHaveBeenCalledWith(
mockTasks[0],
mockTasks,
0,
mockContext.planContent,
mockContext.userFeedback
);
});
it('should emit progress events for text output', async () => {
mockProcessStream.mockImplementation(async (_stream, options) => {
options.onText?.('Some output');
return { text: 'Some output', toolUses: [] };
});
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_progress',
featureId: 'feature-1',
content: 'Some output',
});
});
it('should emit tool events for tool use', async () => {
mockProcessStream.mockImplementation(async (_stream, options) => {
options.onToolUse?.('Read', { path: '/file.txt' });
return { text: 'Output', toolUses: [] };
});
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_tool',
featureId: 'feature-1',
tool: 'Read',
input: { path: '/file.txt' },
});
});
});
describe('phase detection', () => {
it('should not detect phase completion for tasks without phase', async () => {
const tasksNoPhase = [
{ id: '1', description: 'Task 1' },
{ id: '2', description: 'Task 2' },
];
const results: any[] = [];
for await (const progress of executor.executeAll(
tasksNoPhase,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
const completedResults = results.filter((r) => r.status === 'completed');
expect(completedResults.every((r) => r.phaseComplete === undefined)).toBe(true);
});
it('should detect phase change when next task has different phase', async () => {
const results: any[] = [];
for await (const progress of executor.executeAll(
mockTasks,
mockContext,
mockProvider as any
)) {
results.push(progress);
}
// Task 2 (Phase 1) -> Task 3 (Phase 2) = phase complete
const task2Completed = results.find((r) => r.taskId === '2' && r.status === 'completed');
expect(task2Completed?.phaseComplete).toBe(1);
});
});
});

View File

@@ -10,16 +10,10 @@ vi.mock('child_process', () => ({
execSync: vi.fn(),
}));
// Mock secure-fs from @automaker/platform
vi.mock('@automaker/platform', async () => {
const actual = await vi.importActual('@automaker/platform');
return {
...actual,
secureFs: {
access: vi.fn(),
},
};
});
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn(),
}));
// Mock net
vi.mock('net', () => ({
@@ -30,7 +24,7 @@ vi.mock('net', () => ({
}));
import { spawn, execSync } from 'child_process';
import { secureFs } from '@automaker/platform';
import * as secureFs from '@/lib/secure-fs.js';
import net from 'net';
describe('dev-server-service.ts', () => {

View File

@@ -63,17 +63,21 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"dotenv": "^17.2.3",
"framer-motion": "^12.23.26",
"geist": "^1.5.1",
"lucide-react": "^0.562.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"react-resizable-panels": "^3.0.6",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
@@ -95,6 +99,7 @@
"@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.7",
"@types/dagre": "^0.7.53",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

View File

@@ -6,6 +6,8 @@ import { useSettingsMigration } from './hooks/use-settings-migration';
import './styles/global.css';
import './styles/theme-imports';
import { Shell } from './components/layout/shell';
export default function App() {
const [showSplash, setShowSplash] = useState(() => {
// Only show splash once per session
@@ -27,9 +29,9 @@ export default function App() {
}, []);
return (
<>
<Shell>
<RouterProvider router={router} />
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
</>
</Shell>
);
}

View File

@@ -0,0 +1,118 @@
import { useRef } from 'react';
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
import { useNavigate, useLocation } from '@tanstack/react-router';
import {
LayoutDashboard,
Bot,
FileText,
Database,
Terminal,
Settings,
Users,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
export function FloatingDock() {
const mouseX = useMotionValue(Infinity);
const navigate = useNavigate();
const location = useLocation();
const { currentProject } = useAppStore();
const navItems = [
{ id: 'board', icon: LayoutDashboard, label: 'Board', path: '/board' },
{ id: 'agent', icon: Bot, label: 'Agent', path: '/agent' },
{ id: 'spec', icon: FileText, label: 'Spec', path: '/spec' },
{ id: 'context', icon: Database, label: 'Context', path: '/context' },
{ id: 'profiles', icon: Users, label: 'Profiles', path: '/profiles' },
{ id: 'terminal', icon: Terminal, label: 'Terminal', path: '/terminal' },
{ id: 'settings', icon: Settings, label: 'Settings', path: '/settings' },
];
if (!currentProject) return null;
return (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
<motion.div
onMouseMove={(e) => mouseX.set(e.pageX)}
onMouseLeave={() => mouseX.set(Infinity)}
className={cn(
'flex h-16 items-end gap-4 rounded-2xl px-4 pb-3',
'bg-white/5 backdrop-blur-2xl border border-white/10 shadow-2xl'
)}
>
{navItems.map((item) => (
<DockIcon
key={item.id}
mouseX={mouseX}
icon={item.icon}
path={item.path}
label={item.label}
isActive={location.pathname.startsWith(item.path)}
onClick={() => navigate({ to: item.path })}
/>
))}
</motion.div>
</div>
);
}
function DockIcon({
mouseX,
icon: Icon,
path,
label,
isActive,
onClick,
}: {
mouseX: any;
icon: LucideIcon;
path: string;
label: string;
isActive: boolean;
onClick: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const distance = useTransform(mouseX, (val: number) => {
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
return val - bounds.x - bounds.width / 2;
});
const widthSync = useTransform(distance, [-150, 0, 150], [40, 80, 40]);
const width = useSpring(widthSync, { mass: 0.1, stiffness: 150, damping: 12 });
return (
<motion.div
ref={ref}
style={{ width }}
className="aspect-square cursor-pointer group relative"
onClick={onClick}
>
{/* Tooltip */}
<div className="absolute -top-10 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity text-xs font-mono bg-black/80 text-white px-2 py-1 rounded backdrop-blur-md border border-white/10 pointer-events-none whitespace-nowrap">
{label}
</div>
<div
className={cn(
'flex h-full w-full items-center justify-center rounded-full transition-colors',
isActive
? 'bg-primary text-primary-foreground shadow-[0_0_20px_rgba(34,211,238,0.3)]'
: 'bg-white/5 text-muted-foreground hover:bg-white/10'
)}
>
<Icon className="h-[40%] w-[40%]" />
</div>
{/* Active Dot */}
{isActive && (
<motion.div
layoutId="activeDockDot"
className="absolute -bottom-2 left-1/2 w-1 h-1 bg-primary rounded-full -translate-x-1/2"
/>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,70 @@
import { ChevronDown, Command, Folder } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface HudProps {
onOpenProjectPicker: () => void;
onOpenFolder: () => void;
}
export function Hud({ onOpenProjectPicker, onOpenFolder }: HudProps) {
const { currentProject, projects, setCurrentProject } = useAppStore();
if (!currentProject) return null;
return (
<div className="fixed top-4 left-4 z-50 flex items-center gap-3">
{/* Project Pill */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className={cn(
'group flex items-center gap-3 px-4 py-2 rounded-full cursor-pointer',
'bg-white/5 backdrop-blur-md border border-white/10',
'hover:bg-white/10 transition-colors'
)}
>
<div className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.4)] animate-pulse" />
<span className="font-mono text-sm font-medium tracking-tight">
{currentProject.name}
</span>
<ChevronDown className="w-3 h-3 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 glass border-white/10" align="start">
<DropdownMenuLabel>Switch Project</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects.slice(0, 5).map((p) => (
<DropdownMenuItem
key={p.id}
onClick={() => setCurrentProject(p)}
className="font-mono text-xs"
>
{p.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onOpenProjectPicker}>
<Command className="mr-2 w-3 h-3" />
All Projects...
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenFolder}>
<Folder className="mr-2 w-3 h-3" />
Open Local Folder...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Dynamic Status / Breadcrumbs could go here */}
</div>
);
}

View File

@@ -0,0 +1,17 @@
export function NoiseOverlay() {
return (
<div className="fixed inset-0 z-50 pointer-events-none opacity-[0.015] mix-blend-overlay">
<svg className="w-full h-full">
<filter id="noiseFilter">
<feTurbulence
type="fractalNoise"
baseFrequency="0.80"
numOctaves="3"
stitchTiles="stitch"
/>
</filter>
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface PageShellProps {
children: ReactNode;
className?: string;
fullWidth?: boolean;
}
export function PageShell({ children, className, fullWidth = false }: PageShellProps) {
return (
<div className="relative w-full h-full pt-16 pb-24 px-6 overflow-hidden">
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.2, 0, 0, 1] }}
className={cn(
'w-full h-full rounded-3xl overflow-hidden',
'bg-black/20 backdrop-blur-2xl border border-white/5 shadow-2xl',
'flex flex-col',
!fullWidth && 'max-w-7xl mx-auto',
className
)}
>
{children}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
export function PrismField() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({
x: e.clientX,
y: e.clientY,
});
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return (
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[#0b101a]">
{/* Deep Space Base */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(17,24,39,1)_0%,rgba(11,16,26,1)_100%)]" />
{/* Animated Orbs */}
<motion.div
animate={{
x: mousePosition.x * 0.02,
y: mousePosition.y * 0.02,
}}
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
className="absolute top-[-20%] left-[-10%] w-[70vw] h-[70vw] rounded-full bg-cyan-500/5 blur-[120px] mix-blend-screen"
/>
<motion.div
animate={{
x: mousePosition.x * -0.03,
y: mousePosition.y * -0.03,
}}
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
className="absolute bottom-[-20%] right-[-10%] w-[60vw] h-[60vw] rounded-full bg-violet-600/5 blur-[120px] mix-blend-screen"
/>
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: 'easeInOut',
}}
className="absolute top-[30%] left-[50%] transform -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] rounded-full bg-blue-500/5 blur-[100px] mix-blend-screen"
/>
{/* Grid Overlay */}
<div
className="absolute inset-0 z-10 opacity-[0.03]"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '50px 50px',
}}
/>
{/* Vignette */}
<div className="absolute inset-0 z-20 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(11,16,26,0.8)_100%)]" />
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { ReactNode } from 'react';
import { cn } from '../../lib/utils';
import { PrismField } from './prism-field';
import { NoiseOverlay } from './noise-overlay';
interface ShellProps {
children: ReactNode;
className?: string;
showBackgroundElements?: boolean;
}
export function Shell({ children, className, showBackgroundElements = true }: ShellProps) {
return (
<div
className={cn(
'relative min-h-screen w-full overflow-hidden bg-background text-foreground transition-colors duration-500',
className
)}
>
{/* Animated Background Layers */}
{showBackgroundElements && (
<>
<PrismField />
<NoiseOverlay />
</>
)}
{/* Content wrapper */}
<div className="relative z-10 flex h-screen flex-col">{children}</div>
</div>
);
}

View File

@@ -17,8 +17,9 @@ import {
ProjectActions,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { Hud } from './hud';
import { FloatingDock } from './floating-dock';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import {
@@ -247,64 +248,27 @@ export function Sidebar() {
};
return (
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
)}
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
<>
{/* Heads-Up Display (Top Bar) */}
<Hud
onOpenProjectPicker={() => setIsProjectPickerOpen(true)}
onOpenFolder={handleOpenFolder}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
<ProjectActions
setShowNewProjectModal={setShowNewProjectModal}
handleOpenFolder={handleOpenFolder}
setShowTrashDialog={setShowTrashDialog}
trashedProjects={trashedProjects}
shortcuts={{ openProject: shortcuts.openProject }}
/>
)}
{/* Floating Navigation Dock */}
<FloatingDock />
{/* Project Selector Dialog (Hidden logic, controlled by state) */}
<div className="hidden">
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
sidebarOpen={true}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
</div>
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
{/* Dialogs & Modals - Preservation of Logic */}
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
@@ -317,7 +281,6 @@ export function Sidebar() {
isEmptyingTrash={isEmptyingTrash}
/>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
@@ -345,7 +308,6 @@ export function Sidebar() {
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
@@ -353,7 +315,6 @@ export function Sidebar() {
onConfirm={moveProjectToTrash}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
@@ -362,6 +323,6 @@ export function Sidebar() {
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
</>
);
}

View File

@@ -24,6 +24,13 @@ const badgeVariants = cva(
// Muted variants for subtle indication
muted: 'border-border/50 bg-muted/50 text-muted-foreground',
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
// Prism variants
prism:
'border-cyan-500/30 bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 font-mono tracking-wide rounded-md',
'prism-orange':
'border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 font-mono tracking-wide rounded-md',
'prism-green':
'border-emerald-500/30 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 font-mono tracking-wide rounded-md',
},
size: {
default: 'px-2.5 py-0.5 text-xs',

View File

@@ -6,25 +6,32 @@ import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-300 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25',
'bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90 hover:shadow-primary/40 hover:-translate-y-0.5',
destructive:
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'border border-border/50 bg-background/50 backdrop-blur-sm shadow-sm hover:bg-accent hover:text-accent-foreground dark:bg-white/5 dark:hover:bg-white/10 hover:border-accent',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 hover:shadow-md',
ghost: 'hover:bg-accent/50 hover:text-accent-foreground hover:backdrop-blur-sm',
link: 'text-primary underline-offset-4 hover:underline active:scale-100',
glass:
'border border-white/10 bg-white/5 text-foreground shadow-sm drop-shadow-sm backdrop-blur-md hover:bg-white/10 hover:border-white/20 hover:shadow-md transition-all duration-300',
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
'prism-primary':
'bg-cyan-400 text-slate-950 font-extrabold shadow-lg shadow-cyan-400/20 hover:brightness-110 hover:shadow-cyan-400/40 transition-all duration-200 tracking-wide',
'prism-glass':
'glass hover:bg-white/10 text-xs font-bold rounded-xl transition-all duration-200',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-xs',
lg: 'h-11 rounded-md px-8 has-[>svg]:px-5 text-base',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',

View File

@@ -11,9 +11,9 @@ function Card({ className, gradient = false, ...props }: CardProps) {
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6',
// Premium layered shadow
'shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]',
'bg-white/5 text-card-foreground flex flex-col gap-1 rounded-[1.5rem] border border-white/10 backdrop-blur-xl py-6 transition-all duration-300',
// Prism hover effect
'hover:-translate-y-1 hover:bg-white/[0.06] hover:border-white/15',
// Gradient border option
gradient &&
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',

View File

@@ -66,10 +66,10 @@ function DialogOverlay({
<DialogOverlayPrimitive
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'fixed inset-0 z-50 bg-black/40 backdrop-blur-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-200',
'duration-300',
className
)}
{...props}
@@ -99,15 +99,15 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
className={cn(
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
'bg-card border border-border rounded-xl shadow-2xl',
'bg-card/90 border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl',
// Premium shadow
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
'shadow-[0_40px_80px_-12px_rgba(0,0,0,0.5)]',
// Animations - smoother with scale
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
'duration-200',
'duration-300 ease-out',
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
className
)}

View File

@@ -157,7 +157,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/10 bg-popover/80 p-1 text-popover-foreground shadow-xl backdrop-blur-xl',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}

View File

@@ -15,17 +15,21 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
// Inner shadow for depth
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
// Animated focus ring
'transition-[color,box-shadow,border-color] duration-200 ease-out',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'file:text-foreground placeholder:text-muted-foreground/50 selection:bg-cyan-500/30 selection:text-cyan-100',
'bg-white/5 border-white/10 h-9 w-full min-w-0 rounded-xl border px-3 py-1 text-sm shadow-sm outline-none transition-all duration-200',
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
'backdrop-blur-sm',
// Hover state
'hover:bg-white/10 hover:border-white/20',
// Focus state with ring
'focus:bg-white/10 focus:border-cyan-500/50',
'focus-visible:border-cyan-500/50 focus-visible:ring-cyan-500/20 focus-visible:ring-[4px]',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
// Adjust padding for addons
startAddon && 'pl-0',
endAddon && 'pr-0',
hasAddons && 'border-0 shadow-none focus-visible:ring-0',
hasAddons && 'border-0 shadow-none focus-visible:ring-0 bg-transparent',
className
)}
{...props}
@@ -39,10 +43,10 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
return (
<div
className={cn(
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs',
'flex items-center h-9 w-full rounded-lg border border-input/50 bg-input/50 shadow-xs backdrop-blur-sm transition-all duration-300',
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
'transition-[box-shadow,border-color] duration-200 ease-out',
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',
'focus-within:bg-input/80 focus-within:border-ring/50',
'focus-within:border-ring focus-within:ring-ring/20 focus-within:ring-[4px]',
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
)}

View File

@@ -50,10 +50,10 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(({ className, ...p
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10 cursor-pointer">
<SliderRangePrimitive className="slider-range absolute h-full bg-cyan-400" />
</SliderTrackPrimitive>
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-cyan-400/50 bg-background shadow-none transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400 disabled:pointer-events-none disabled:opacity-50 hover:bg-cyan-950/30 hover:border-cyan-400" />
</SliderRootPrimitive>
));
Slider.displayName = SliderPrimitive.Root.displayName;

View File

@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-cyan-500 data-[state=unchecked]:bg-white/10',
className
)}
{...props}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>

View File

@@ -16,11 +16,13 @@ import { RefreshCw } from 'lucide-react';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
import { PageShell } from '@/components/layout/page-shell';
// Board-view specific imports
import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import {
AddFeatureDialog,
AgentOutputModal,
@@ -69,6 +71,8 @@ export function BoardView() {
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardViewMode,
setBoardViewMode,
specCreatingForProject,
setSpecCreatingForProject,
pendingPlanApproval,
@@ -989,40 +993,54 @@ export function BoardView() {
completedCount={completedFeatures.length}
kanbanCardDetailLevel={kanbanCardDetailLevel}
onDetailLevelChange={setKanbanCardDetailLevel}
boardViewMode={boardViewMode}
onBoardViewModeChange={setBoardViewMode}
/>
</div>
{/* Kanban Columns */}
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
{/* View Content - Kanban or Graph */}
{boardViewMode === 'kanban' ? (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
) : (
<GraphView
features={hookFeatures}
runningAutoTasks={runningAutoTasks}
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
/>
)}
</div>
{/* Board Background Modal */}

View File

@@ -1,7 +1,8 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react';
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BoardViewMode } from '@/store/app-store';
interface BoardControlsProps {
isMounted: boolean;
@@ -10,6 +11,8 @@ interface BoardControlsProps {
completedCount: number;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
boardViewMode: BoardViewMode;
onBoardViewModeChange: (mode: BoardViewMode) => void;
}
export function BoardControls({
@@ -19,12 +22,59 @@ export function BoardControls({
completedCount,
kanbanCardDetailLevel,
onDetailLevelChange,
boardViewMode,
onBoardViewModeChange,
}: BoardControlsProps) {
if (!isMounted) return null;
return (
<TooltipProvider>
<div className="flex items-center gap-2 ml-4">
{/* View Mode Toggle - Kanban / Graph */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="view-mode-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('kanban')}
className={cn(
'p-2 rounded-l-lg transition-colors',
boardViewMode === 'kanban'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-kanban"
>
<Columns3 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Kanban Board View</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('graph')}
className={cn(
'p-2 rounded-r-lg transition-colors',
boardViewMode === 'graph'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-graph"
>
<Network className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Dependency Graph View</p>
</TooltipContent>
</Tooltip>
</div>
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -1,4 +1,5 @@
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { cn } from '@/lib/utils';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
@@ -39,23 +40,20 @@ export function BoardHeader({
const showUsageTracking = !apiKeys.anthropic && !isWindows;
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<header className="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0">
<div>
<h1 className="text-xl font-bold">Kanban Board</h1>
<p className="text-sm text-muted-foreground">{projectName}</p>
<h2 className="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
<p className="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
{projectName}
</p>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - only show for CLI users (not API key users) */}
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
<div className="flex items-center gap-5">
{/* Concurrency/Agent Control - Styled as Toggle for visual matching, but keeps slider logic if needed or simplified */}
{isMounted && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
data-testid="concurrency-slider-container"
>
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<div className="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3">
<Bot className="w-4 h-4 text-slate-500" />
{/* We keep the slider for functionality, but could style it to look like the toggle or just use the slider cleanly */}
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
@@ -63,43 +61,43 @@ export function BoardHeader({
max={10}
step={1}
className="w-20"
data-testid="concurrency-slider"
/>
<span
className="text-sm text-muted-foreground min-w-[5ch] text-center"
data-testid="concurrency-value"
>
<span className="mono text-xs font-bold text-slate-400">
{runningAgentsCount} / {maxConcurrency}
</span>
</div>
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{/* Auto Mode Button */}
{isMounted && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>
<Switch
id="auto-mode-toggle"
checked={isAutoModeRunning}
onCheckedChange={onAutoModeToggle}
data-testid="auto-mode-toggle"
<button
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
className={cn(
'flex items-center gap-2 px-5 py-2 rounded-xl text-xs font-bold transition',
isAutoModeRunning
? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20'
: 'glass hover:bg-white/10'
)}
>
<div
className={cn(
'w-2 h-2 rounded-full',
isAutoModeRunning ? 'bg-cyan-400 animate-pulse' : 'bg-slate-500'
)}
/>
</div>
Auto Mode
</button>
)}
<HotkeyButton
size="sm"
{/* Add Feature Button */}
<button
onClick={onAddFeature}
hotkey={addFeatureShortcut}
hotkeyActive={false}
data-testid="add-feature-button"
className="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</HotkeyButton>
<Plus className="w-4 h-4 stroke-[3.5px]" />
ADD FEATURE
</button>
</div>
</div>
</header>
);
}

View File

@@ -7,6 +7,7 @@ interface KanbanColumnProps {
id: string;
title: string;
colorClass: string;
columnClass?: string;
count: number;
children: ReactNode;
headerAction?: ReactNode;
@@ -21,6 +22,7 @@ export const KanbanColumn = memo(function KanbanColumn({
id,
title,
colorClass,
columnClass,
count,
children,
headerAction,
@@ -43,7 +45,8 @@ export const KanbanColumn = memo(function KanbanColumn({
'transition-[box-shadow,ring] duration-200',
!width && 'w-72', // Only apply w-72 if no custom width
showBorder && 'border border-border/60',
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background',
columnClass
)}
style={widthStyle}
data-testid={`kanban-column-${id}`}

View File

@@ -2,21 +2,25 @@ import { Feature } from '@/store/app-store';
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
{
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
},
{
id: 'waiting_approval',
title: 'Waiting Approval',
colorClass: 'bg-[var(--status-waiting)]',
},
{
id: 'verified',
title: 'Verified',
colorClass: 'bg-[var(--status-success)]',
},
];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string; columnClass?: string }[] =
[
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-white/20', columnClass: '' },
{
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-cyan-400',
columnClass: 'col-in-progress',
},
{
id: 'waiting_approval',
title: 'Waiting Approval',
colorClass: 'bg-amber-500',
columnClass: 'col-waiting',
},
{
id: 'verified',
title: 'Verified',
colorClass: 'bg-emerald-500',
columnClass: 'col-verified',
},
];

View File

@@ -102,6 +102,7 @@ export function KanbanBoard({
id={column.id}
title={column.title}
colorClass={column.colorClass}
columnClass={column.columnClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}

View File

@@ -16,10 +16,12 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { PlanningMode, PlanSpec, ParsedTask } from '@automaker/types';
import type { PlanSpec } from '@/store/app-store';
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
// Re-export for backwards compatibility
export type { PlanningMode, ParsedTask, PlanSpec };
export type { ParsedTask, PlanSpec } from '@/store/app-store';
interface PlanningModeSelectorProps {
mode: PlanningMode;

View File

@@ -0,0 +1,115 @@
import { memo } from 'react';
import { BaseEdge, getBezierPath, EdgeLabelRenderer } from '@xyflow/react';
import type { EdgeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import { Feature } from '@/store/app-store';
export interface DependencyEdgeData {
sourceStatus: Feature['status'];
targetStatus: Feature['status'];
}
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
// If source is completed/verified, the dependency is satisfied
if (sourceStatus === 'completed' || sourceStatus === 'verified') {
return 'var(--status-success)';
}
// If target is in progress, show active color
if (targetStatus === 'in_progress') {
return 'var(--status-in-progress)';
}
// If target is blocked (in backlog with incomplete deps)
if (targetStatus === 'backlog') {
return 'var(--border)';
}
// Default
return 'var(--border)';
};
export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
const {
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
animated,
} = props;
const edgeData = data as DependencyEdgeData | undefined;
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.25,
});
const edgeColor = edgeData
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
: 'var(--border)';
const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
const isInProgress = edgeData?.targetStatus === 'in_progress';
return (
<>
{/* Background edge for better visibility */}
<BaseEdge
id={`${id}-bg`}
path={edgePath}
style={{
strokeWidth: 4,
stroke: 'var(--background)',
}}
/>
{/* Main edge */}
<BaseEdge
id={id}
path={edgePath}
className={cn(
'transition-all duration-300',
animated && 'animated-edge',
isInProgress && 'edge-flowing'
)}
style={{
strokeWidth: selected ? 3 : 2,
stroke: edgeColor,
strokeDasharray: isCompleted ? 'none' : '5 5',
filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none',
}}
/>
{/* Animated particles for in-progress edges */}
{animated && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'none',
}}
className="edge-particle"
>
<div
className={cn(
'w-2 h-2 rounded-full',
isInProgress
? 'bg-[var(--status-in-progress)] animate-ping'
: 'bg-brand-500 animate-pulse'
)}
/>
</div>
</EdgeLabelRenderer>
)}
</>
);
});

View File

@@ -0,0 +1,144 @@
import { useReactFlow, Panel } from '@xyflow/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
ZoomIn,
ZoomOut,
Maximize2,
Lock,
Unlock,
GitBranch,
ArrowRight,
ArrowDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface GraphControlsProps {
isLocked: boolean;
onToggleLock: () => void;
onRunLayout: (direction: 'LR' | 'TB') => void;
layoutDirection: 'LR' | 'TB';
}
export function GraphControls({
isLocked,
onToggleLock,
onRunLayout,
layoutDirection,
}: GraphControlsProps) {
const { zoomIn, zoomOut, fitView } = useReactFlow();
return (
<Panel position="bottom-left" className="flex flex-col gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg">
{/* Zoom controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomIn({ duration: 200 })}
>
<ZoomIn className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom In</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomOut({ duration: 200 })}
>
<ZoomOut className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom Out</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fitView({ padding: 0.2, duration: 300 })}
>
<Maximize2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Fit View</TooltipContent>
</Tooltip>
<div className="h-px bg-border my-1" />
{/* Layout controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'LR' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('LR')}
>
<ArrowRight className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Horizontal Layout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'TB' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('TB')}
>
<ArrowDown className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Vertical Layout</TooltipContent>
</Tooltip>
<div className="h-px bg-border my-1" />
{/* Lock toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
isLocked && 'bg-brand-500/20 text-brand-500'
)}
onClick={onToggleLock}
>
{isLocked ? (
<Lock className="w-4 h-4" />
) : (
<Unlock className="w-4 h-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
);
}

View File

@@ -0,0 +1,69 @@
import { Panel } from '@xyflow/react';
import {
Clock,
Play,
Pause,
CheckCircle2,
Lock,
AlertCircle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const legendItems = [
{
icon: Clock,
label: 'Backlog',
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
},
{
icon: Play,
label: 'In Progress',
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/20',
},
{
icon: Pause,
label: 'Waiting',
colorClass: 'text-[var(--status-waiting)]',
bgClass: 'bg-[var(--status-warning)]/20',
},
{
icon: CheckCircle2,
label: 'Verified',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/20',
},
{
icon: Lock,
label: 'Blocked',
colorClass: 'text-orange-500',
bgClass: 'bg-orange-500/20',
},
{
icon: AlertCircle,
label: 'Error',
colorClass: 'text-[var(--status-error)]',
bgClass: 'bg-[var(--status-error)]/20',
},
];
export function GraphLegend() {
return (
<Panel position="bottom-right" className="pointer-events-none">
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto">
{legendItems.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="flex items-center gap-1.5">
<div className={cn('p-1 rounded', item.bgClass)}>
<Icon className={cn('w-3 h-3', item.colorClass)} />
</div>
<span className="text-xs text-muted-foreground">{item.label}</span>
</div>
);
})}
</div>
</Panel>
);
}

Some files were not shown because too many files have changed in this diff Show More