mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
8 Commits
refactor/a
...
weird-side
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb92a0402 | ||
|
|
524a9736b4 | ||
|
|
036a7d9d26 | ||
|
|
c4df2c141a | ||
|
|
7c75c24b5c | ||
|
|
2588ecaafa | ||
|
|
a071097c0d | ||
|
|
b930091c42 |
99
CLAUDE.md
99
CLAUDE.md
@@ -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
|
||||
25
apps/server/src/lib/enhancement-prompts.ts
Normal file
25
apps/server/src/lib/enhancement-prompts.ts
Normal 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';
|
||||
@@ -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';
|
||||
}
|
||||
23
apps/server/src/lib/secure-fs.ts
Normal file
23
apps/server/src/lib/secure-fs.ts
Normal 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;
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ==========');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
118
apps/ui/src/components/layout/floating-dock.tsx
Normal file
118
apps/ui/src/components/layout/floating-dock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/ui/src/components/layout/hud.tsx
Normal file
70
apps/ui/src/components/layout/hud.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/ui/src/components/layout/noise-overlay.tsx
Normal file
17
apps/ui/src/components/layout/noise-overlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/ui/src/components/layout/page-shell.tsx
Normal file
30
apps/ui/src/components/layout/page-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/ui/src/components/layout/prism-field.tsx
Normal file
69
apps/ui/src/components/layout/prism-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/ui/src/components/layout/shell.tsx
Normal file
32
apps/ui/src/components/layout/shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user