mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
7 Commits
v0.13.0
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51641bad9c | ||
|
|
a1c5d0cb0d | ||
|
|
0bb9db5f80 | ||
|
|
5ec19f11cd | ||
|
|
85dc631250 | ||
|
|
460afa82b8 | ||
|
|
79ef8c8510 |
99
CLAUDE.md
Normal file
99
CLAUDE.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
69
apps/server/src/lib/exec-utils.ts
Normal file
69
apps/server/src/lib/exec-utils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* 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,26 +3,16 @@
|
||||
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
|
||||
*/
|
||||
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
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,6 +9,10 @@ import type {
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
SimpleQueryOptions,
|
||||
SimpleQueryResult,
|
||||
StreamingQueryOptions,
|
||||
StreamingQueryResult,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -35,6 +39,22 @@ 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,15 +3,26 @@
|
||||
*
|
||||
* 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 {
|
||||
@@ -175,4 +186,225 @@ 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,3 +102,92 @@ 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,15 +1,14 @@
|
||||
/**
|
||||
* 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 { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { logAuthStatus } from './common.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import { getAppSpecPath, secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -91,72 +90,37 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
||||
projectPath: projectPath,
|
||||
});
|
||||
|
||||
const options = createFeatureGenerationOptions({
|
||||
logger.info('Calling provider.executeStreamingQuery() for features...');
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeStreamingQuery({
|
||||
prompt,
|
||||
model: 'haiku',
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
if (!result.success) {
|
||||
logger.error('❌ Feature generation failed:', result.error);
|
||||
throw new Error(result.error || 'Feature generation failed');
|
||||
}
|
||||
|
||||
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(`Feature response length: ${result.text.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(responseText);
|
||||
logger.info(result.text);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
await parseAndCreateFeatures(projectPath, result.text, events);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* 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,
|
||||
@@ -13,10 +12,9 @@ import {
|
||||
type SpecOutput,
|
||||
} from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { logAuthStatus } from './common.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||
import { ensureAutomakerDir, getAppSpecPath, secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -83,105 +81,53 @@ ${getStructuredSpecPromptInstruction()}`;
|
||||
content: 'Starting spec generation...\n',
|
||||
});
|
||||
|
||||
const options = createSpecGenerationOptions({
|
||||
logger.info('Calling provider.executeStreamingQuery()...');
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeStreamingQuery({
|
||||
prompt,
|
||||
model: 'haiku',
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
if (!result.success) {
|
||||
logger.error('❌ Spec generation failed:', result.error);
|
||||
throw new Error(result.error || 'Spec generation failed');
|
||||
}
|
||||
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
const responseText = result.text;
|
||||
const structuredOutput = result.structuredOutput as SpecOutput | undefined;
|
||||
|
||||
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,10 +3,9 @@
|
||||
*/
|
||||
|
||||
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 } from '@automaker/platform';
|
||||
import { getFeaturesDir, secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
/**
|
||||
* Claude Usage types for CLI-based usage tracking
|
||||
* Re-exported from @automaker/types for convenience
|
||||
*/
|
||||
|
||||
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;
|
||||
};
|
||||
export type { ClaudeUsage, ClaudeStatus } from '@automaker/types';
|
||||
|
||||
@@ -18,14 +18,13 @@ export {
|
||||
getGitRepositoryDiffs,
|
||||
} from '@automaker/git-utils';
|
||||
|
||||
type Logger = ReturnType<typeof createLogger>;
|
||||
// Re-export error utilities from shared package
|
||||
export { getErrorMessage } from '@automaker/utils';
|
||||
|
||||
/**
|
||||
* Get error message from error object
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
// Re-export exec utilities
|
||||
export { execAsync, execEnv, isENOENT } from '../lib/exec-utils.js';
|
||||
|
||||
type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
/**
|
||||
* Create a logError function for a specific logger
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* POST /context/describe-file endpoint - Generate description for a text file
|
||||
*
|
||||
* Uses Claude Haiku to analyze a text file and generate a concise description
|
||||
* suitable for context file metadata.
|
||||
* Uses Claude Haiku via ClaudeProvider 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,12 +10,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
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 { PathNotAllowedError, secureFs } from '@automaker/platform';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import * as path from 'path';
|
||||
|
||||
const logger = createLogger('DescribeFile');
|
||||
@@ -44,31 +41,6 @@ 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
|
||||
*
|
||||
@@ -150,60 +122,39 @@ export function createDescribeFileHandler(): (req: Request, res: Response) => Pr
|
||||
const fileName = path.basename(resolvedPath);
|
||||
|
||||
// Build prompt with file content passed as structured data
|
||||
// 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").
|
||||
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").
|
||||
|
||||
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
|
||||
|
||||
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
||||
|
||||
const promptContent = [
|
||||
{ type: 'text' as const, text: instructionText },
|
||||
File: ${fileName}${truncated ? ' (truncated)' : ''}`,
|
||||
},
|
||||
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
|
||||
];
|
||||
|
||||
// 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 },
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeSimpleQuery({
|
||||
prompt: promptContent,
|
||||
model: 'haiku',
|
||||
});
|
||||
|
||||
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');
|
||||
if (!result.success) {
|
||||
logger.warn('Failed to generate description:', result.error);
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate description - empty response',
|
||||
error: result.error || 'Failed to generate description',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Description generated, length: ${description.length} chars`);
|
||||
logger.info(`Description generated, length: ${result.text.length} chars`);
|
||||
|
||||
const response: DescribeFileSuccessResponse = {
|
||||
success: true,
|
||||
description: description.trim(),
|
||||
description: result.text,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* POST /context/describe-image endpoint - Generate description for an image
|
||||
*
|
||||
* Uses Claude Haiku to analyze an image and generate a concise description
|
||||
* suitable for context file metadata.
|
||||
* Uses Claude Haiku via ClaudeProvider 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,10 +11,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import type { PromptContentBlock } from '../../../providers/types.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -173,53 +172,6 @@ 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
|
||||
*
|
||||
@@ -308,13 +260,17 @@ 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 = [
|
||||
{ type: 'text' as const, text: instructionText },
|
||||
const promptContent: PromptContentBlock[] = [
|
||||
{ type: 'text', text: instructionText },
|
||||
{
|
||||
type: 'image' as const,
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: imageData.mimeType,
|
||||
type: 'base64',
|
||||
media_type: imageData.mimeType as
|
||||
| 'image/jpeg'
|
||||
| 'image/png'
|
||||
| 'image/gif'
|
||||
| 'image/webp',
|
||||
data: imageData.base64,
|
||||
},
|
||||
},
|
||||
@@ -322,48 +278,26 @@ export function createDescribeImageHandler(): (req: Request, res: Response) => P
|
||||
|
||||
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
|
||||
|
||||
const cwd = path.dirname(actualPath);
|
||||
logger.info(`[${requestId}] Using cwd=${cwd}`);
|
||||
logger.info(`[${requestId}] Calling provider.executeSimpleQuery()...`);
|
||||
const queryStart = Date.now();
|
||||
|
||||
// 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 },
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeSimpleQuery({
|
||||
prompt: promptContent,
|
||||
model: 'haiku',
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||
sdkOptions.allowedTools
|
||||
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
||||
);
|
||||
logger.info(`[${requestId}] Query completed in ${Date.now() - queryStart}ms`);
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
const description = result.success ? result.text : '';
|
||||
|
||||
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`);
|
||||
if (!result.success || !description || description.trim().length === 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to generate description: ${result.error || 'empty response'}`
|
||||
);
|
||||
const response: DescribeImageErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate description - empty response',
|
||||
error: result.error || 'Failed to generate description - empty response',
|
||||
requestId,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
/**
|
||||
* POST /enhance-prompt endpoint - Enhance user input text
|
||||
*
|
||||
* Uses Claude AI to enhance text based on the specified enhancement mode.
|
||||
* Supports modes: improve, technical, simplify, acceptance
|
||||
* Uses Claude AI via ClaudeProvider 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 { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import {
|
||||
getSystemPrompt,
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
} from '../../../lib/enhancement-prompts.js';
|
||||
} from '@automaker/prompts';
|
||||
|
||||
const logger = createLogger('EnhancePrompt');
|
||||
|
||||
@@ -47,39 +45,6 @@ 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
|
||||
*
|
||||
@@ -132,45 +97,30 @@ 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);
|
||||
|
||||
// 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({
|
||||
const provider = ProviderFactory.getProviderForModel(model || 'sonnet');
|
||||
const result = await provider.executeSimpleQuery({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model: resolvedModel,
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
},
|
||||
model: model || 'sonnet',
|
||||
systemPrompt,
|
||||
});
|
||||
|
||||
// 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');
|
||||
if (!result.success) {
|
||||
logger.warn('Failed to enhance text:', result.error);
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate enhanced text - empty response',
|
||||
error: result.error || 'Failed to generate enhanced text',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`);
|
||||
logger.info(`Enhancement complete, output length: ${result.text.length} chars`);
|
||||
|
||||
const response: EnhanceSuccessResponse = {
|
||||
success: true,
|
||||
enhancedText: enhancedText.trim(),
|
||||
enhancedText: result.text,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* POST /features/generate-title endpoint - Generate a concise title from description
|
||||
*
|
||||
* Uses Claude Haiku to generate a short, descriptive title from feature description.
|
||||
* Uses Claude Haiku via ClaudeProvider to generate a short, descriptive title.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
|
||||
const logger = createLogger('GenerateTitle');
|
||||
|
||||
@@ -34,33 +33,6 @@ 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 {
|
||||
@@ -89,34 +61,28 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
const stream = query({
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeSimpleQuery({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
},
|
||||
model: 'haiku',
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
});
|
||||
|
||||
const title = await extractTextFromStream(stream);
|
||||
|
||||
if (!title || title.trim().length === 0) {
|
||||
logger.warn('Received empty response from Claude');
|
||||
if (!result.success) {
|
||||
logger.warn('Failed to generate title:', result.error);
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate title - empty response',
|
||||
error: result.error || 'Failed to generate title',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generated title: ${title.trim()}`);
|
||||
logger.info(`Generated title: ${result.text}`);
|
||||
|
||||
const response: GenerateTitleSuccessResponse = {
|
||||
success: true,
|
||||
title: title.trim(),
|
||||
title: result.text,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError, isENOENT } from '../common.js';
|
||||
|
||||
const logger = createLogger('FS');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export { isENOENT };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createBrowseHandler() {
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getBoardDir } from '@automaker/platform';
|
||||
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,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteHandler() {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createExistsHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createImageHandler() {
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createMkdirHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError, isENOENT } from '../common.js';
|
||||
|
||||
// Optional files that are expected to not exist in new projects
|
||||
// Don't log ENOENT errors for these to reduce noise
|
||||
@@ -15,10 +14,6 @@ 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,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createReaddirHandler() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getBoardDir } from '@automaker/platform';
|
||||
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,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getImagesDir } from '@automaker/platform';
|
||||
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,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, isPathAllowed } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { isPathAllowed } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createValidatePathHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { mkdirSafe } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { GitHubRemoteStatus } from '@automaker/types';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
|
||||
export interface GitHubRemoteStatus {
|
||||
hasGitHubRemote: boolean;
|
||||
remoteUrl: string | null;
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
}
|
||||
// Re-export type for convenience
|
||||
export type { GitHubRemoteStatus } from '@automaker/types';
|
||||
|
||||
export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemoteStatus> {
|
||||
const status: GitHubRemoteStatus = {
|
||||
|
||||
@@ -2,34 +2,16 @@
|
||||
* Common utilities for GitHub routes
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createLogError, getErrorMessage } from '../../common.js';
|
||||
import { execAsync, execEnv } from '../../../lib/exec-utils.js';
|
||||
|
||||
export const execAsync = promisify(exec);
|
||||
const logger = createLogger('GitHub');
|
||||
|
||||
// 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 exec utilities for convenience
|
||||
export { execAsync, execEnv } from '../../../lib/exec-utils.js';
|
||||
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
// Re-export error utilities
|
||||
export { getErrorMessage } from '../../common.js';
|
||||
|
||||
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);
|
||||
}
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
@@ -3,35 +3,12 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { GitHubLabel, GitHubAuthor, GitHubIssue, ListIssuesResult } from '@automaker/types';
|
||||
|
||||
export function createListIssuesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -3,39 +3,12 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { GitHubLabel, GitHubAuthor, GitHubPR, ListPRsResult } from '@automaker/types';
|
||||
|
||||
export function createListPRsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* 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 { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
@@ -68,62 +69,44 @@ The response will be automatically formatted as structured JSON.`;
|
||||
content: `Starting ${suggestionType} analysis...\n`,
|
||||
});
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeStreamingQuery({
|
||||
prompt,
|
||||
model: 'haiku',
|
||||
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) => ({
|
||||
@@ -134,7 +117,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 = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||
const jsonMatch = result.text.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
events.emit('suggestions:event', {
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { logger, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCloneHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createConfigHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getAllowedRootDirectory } from '@automaker/platform';
|
||||
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,48 +20,6 @@ const featureLoader = new FeatureLoader();
|
||||
/** 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
|
||||
// ============================================================================
|
||||
@@ -111,14 +69,6 @@ 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,15 +5,12 @@
|
||||
* can switch between branches even after worktrees are removed.
|
||||
*/
|
||||
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
|
||||
import type { TrackedBranch } from '@automaker/types';
|
||||
import path from 'path';
|
||||
import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
|
||||
|
||||
export interface TrackedBranch {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastActivatedAt?: string;
|
||||
}
|
||||
// Re-export type for convenience
|
||||
export type { TrackedBranch } from '@automaker/types';
|
||||
|
||||
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 * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import {
|
||||
isGitRepo,
|
||||
getErrorMessage,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
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 * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
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 * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
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 * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
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 * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PRComment, PRInfo } from '@automaker/types';
|
||||
import {
|
||||
getErrorMessage,
|
||||
logError,
|
||||
@@ -12,26 +13,8 @@ import {
|
||||
isGhCliAvailable,
|
||||
} from '../common.js';
|
||||
|
||||
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[];
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { PRComment, PRInfo } from '@automaker/types';
|
||||
|
||||
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 * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -12,10 +11,13 @@ 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 } from '@automaker/platform';
|
||||
import { PathNotAllowedError, secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('AgentService');
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -150,7 +152,7 @@ export class AgentService {
|
||||
filename: imageData.filename,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
logger.error(`Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,9 +217,7 @@ export class AgentService {
|
||||
// Get provider for this model
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
|
||||
console.log(
|
||||
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
|
||||
);
|
||||
logger.info(`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;
|
||||
console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`);
|
||||
logger.info(`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 };
|
||||
}
|
||||
|
||||
console.error('[AgentService] Error:', error);
|
||||
logger.error('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) {
|
||||
console.error('[AgentService] Failed to save session:', error);
|
||||
logger.error('Failed to save session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
162
apps/server/src/services/auto-mode/feature-verification.ts
Normal file
162
apps/server/src/services/auto-mode/feature-verification.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
28
apps/server/src/services/auto-mode/index.ts
Normal file
28
apps/server/src/services/auto-mode/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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';
|
||||
154
apps/server/src/services/auto-mode/output-writer.ts
Normal file
154
apps/server/src/services/auto-mode/output-writer.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
236
apps/server/src/services/auto-mode/plan-approval-service.ts
Normal file
236
apps/server/src/services/auto-mode/plan-approval-service.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
109
apps/server/src/services/auto-mode/project-analyzer.ts
Normal file
109
apps/server/src/services/auto-mode/project-analyzer.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
267
apps/server/src/services/auto-mode/task-executor.ts
Normal file
267
apps/server/src/services/auto-mode/task-executor.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
121
apps/server/src/services/auto-mode/types.ts
Normal file
121
apps/server/src/services/auto-mode/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
157
apps/server/src/services/auto-mode/worktree-manager.ts
Normal file
157
apps/server/src/services/auto-mode/worktree-manager.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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,10 +8,13 @@
|
||||
*/
|
||||
|
||||
import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import path from 'path';
|
||||
import net from 'net';
|
||||
|
||||
const logger = createLogger('DevServerService');
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
@@ -69,7 +72,7 @@ class DevServerService {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
||||
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
|
||||
logger.info(`Killed process ${pid} on port ${port}`);
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
@@ -82,7 +85,7 @@ class DevServerService {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
|
||||
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
|
||||
logger.info(`Killed process ${pid} on port ${port}`);
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
@@ -93,7 +96,7 @@ class DevServerService {
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors - port might not have any process
|
||||
console.log(`[DevServerService] No process to kill on port ${port}`);
|
||||
logger.info(`No process to kill on port ${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,11 +254,9 @@ class DevServerService {
|
||||
// Small delay to ensure related ports are freed
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
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}`
|
||||
);
|
||||
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}`);
|
||||
|
||||
// Spawn the dev process with PORT environment variable
|
||||
const env = {
|
||||
@@ -276,26 +277,26 @@ class DevServerService {
|
||||
// Log output for debugging
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
|
||||
logger.info(`[DevServer:${port}] ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
const msg = data.toString().trim();
|
||||
console.error(`[DevServer:${port}] ${msg}`);
|
||||
logger.error(`[DevServer:${port}] ${msg}`);
|
||||
});
|
||||
}
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
console.error(`[DevServerService] Process error:`, error);
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`);
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
@@ -352,9 +353,7 @@ 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) {
|
||||
console.log(
|
||||
`[DevServerService] No server record for ${worktreePath}, may have already stopped`
|
||||
);
|
||||
logger.info(`No server record for ${worktreePath}, may have already stopped`);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
@@ -364,7 +363,7 @@ class DevServerService {
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[DevServerService] Stopping dev server for ${worktreePath}`);
|
||||
logger.info(`Stopping dev server for ${worktreePath}`);
|
||||
|
||||
// Kill the process
|
||||
if (server.process && !server.process.killed) {
|
||||
@@ -434,7 +433,7 @@ class DevServerService {
|
||||
* Stop all running dev servers (for cleanup)
|
||||
*/
|
||||
async stopAll(): Promise<void> {
|
||||
console.log(`[DevServerService] Stopping all ${this.runningServers.size} dev servers`);
|
||||
logger.info(`Stopping all ${this.runningServers.size} dev servers`);
|
||||
|
||||
for (const [worktreePath] of this.runningServers) {
|
||||
await this.stopDevServer(worktreePath);
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { Feature, PlanSpec, FeatureStatus } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
||||
import {
|
||||
getFeaturesDir,
|
||||
getFeatureDir,
|
||||
getFeatureImagesDir,
|
||||
ensureAutomakerDir,
|
||||
secureFs,
|
||||
} from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('FeatureLoader');
|
||||
@@ -56,7 +57,7 @@ export class FeatureLoader {
|
||||
try {
|
||||
// Paths are now absolute
|
||||
await secureFs.unlink(oldPath);
|
||||
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
|
||||
logger.info(`Deleted orphaned image: ${oldPath}`);
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting (file may already be gone)
|
||||
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
|
||||
@@ -111,7 +112,7 @@ export class FeatureLoader {
|
||||
|
||||
// Copy the file
|
||||
await secureFs.copyFile(fullOriginalPath, newPath);
|
||||
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
|
||||
logger.info(`Copied image: ${originalPath} -> ${newPath}`);
|
||||
|
||||
// Try to delete the original temp file
|
||||
try {
|
||||
@@ -332,7 +333,7 @@ export class FeatureLoader {
|
||||
try {
|
||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
||||
await secureFs.rm(featureDir, { recursive: true, force: true });
|
||||
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
||||
logger.info(`Deleted feature ${featureId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
|
||||
@@ -381,4 +382,115 @@ 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,14 +8,13 @@
|
||||
*/
|
||||
|
||||
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,6 +10,9 @@ 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
|
||||
@@ -171,7 +174,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Reject paths with null bytes (could bypass path checks)
|
||||
if (cwd.includes('\0')) {
|
||||
console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
||||
logger.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
||||
return homeDir;
|
||||
}
|
||||
|
||||
@@ -192,10 +195,10 @@ export class TerminalService extends EventEmitter {
|
||||
if (stat.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
logger.warn(`Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
} catch {
|
||||
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
|
||||
logger.warn(`Working directory does not exist: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
}
|
||||
}
|
||||
@@ -220,7 +223,7 @@ export class TerminalService extends EventEmitter {
|
||||
setMaxSessions(limit: number): void {
|
||||
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
|
||||
maxSessions = limit;
|
||||
console.log(`[Terminal] Max sessions limit updated to ${limit}`);
|
||||
logger.info(`Max sessions limit updated to ${limit}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +234,7 @@ export class TerminalService extends EventEmitter {
|
||||
createSession(options: TerminalOptions = {}): TerminalSession | null {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
logger.error(`Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -256,7 +259,7 @@ export class TerminalService extends EventEmitter {
|
||||
...options.env,
|
||||
};
|
||||
|
||||
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
@@ -328,13 +331,13 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
|
||||
logger.info(`Session ${id} exited with code ${exitCode}`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||
this.emit('exit', id, exitCode);
|
||||
});
|
||||
|
||||
console.log(`[Terminal] Session ${id} created successfully`);
|
||||
logger.info(`Session ${id} created successfully`);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -344,7 +347,7 @@ export class TerminalService extends EventEmitter {
|
||||
write(sessionId: string, data: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found`);
|
||||
logger.warn(`Session ${sessionId} not found`);
|
||||
return false;
|
||||
}
|
||||
session.pty.write(data);
|
||||
@@ -359,7 +362,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) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
|
||||
logger.warn(`Session ${sessionId} not found for resize`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -385,7 +388,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
|
||||
logger.error(`Error resizing session ${sessionId}:`, error);
|
||||
session.resizeInProgress = false; // Clear flag on error
|
||||
return false;
|
||||
}
|
||||
@@ -413,14 +416,14 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
// First try graceful SIGTERM to allow process cleanup
|
||||
console.log(`[Terminal] Session ${sessionId} sending SIGTERM`);
|
||||
logger.info(`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)) {
|
||||
console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
try {
|
||||
session.pty.kill('SIGKILL');
|
||||
} catch {
|
||||
@@ -431,10 +434,10 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
console.log(`[Terminal] Session ${sessionId} kill initiated`);
|
||||
logger.info(`Session ${sessionId} kill initiated`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
|
||||
logger.error(`Error killing session ${sessionId}:`, error);
|
||||
// Still try to remove from map even if kill fails
|
||||
this.sessions.delete(sessionId);
|
||||
return false;
|
||||
@@ -517,7 +520,7 @@ export class TerminalService extends EventEmitter {
|
||||
* Clean up all sessions
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
|
||||
logger.info(`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 '@/lib/enhancement-prompts.js';
|
||||
} from '@automaker/prompts';
|
||||
|
||||
describe('enhancement-prompts.ts', () => {
|
||||
describe('System Prompt Constants', () => {
|
||||
|
||||
143
apps/server/tests/unit/lib/exec-utils.test.ts
Normal file
143
apps/server/tests/unit/lib/exec-utils.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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,7 +9,15 @@ import { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('@/providers/provider-factory.js');
|
||||
vi.mock('@automaker/utils');
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
readImageAsBase64: vi.fn(),
|
||||
buildPromptWithImages: vi.fn(),
|
||||
loadContextFiles: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('agent-service.ts', () => {
|
||||
let service: AgentService;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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;
|
||||
@@ -18,54 +25,28 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
await service.stopAutoLoop().catch(() => {});
|
||||
});
|
||||
|
||||
describe('getPlanningPromptPrefix', () => {
|
||||
// Access private method through any cast for testing
|
||||
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
describe('getPlanningPromptPrefix (from @automaker/prompts)', () => {
|
||||
it('should return empty string for skip mode', () => {
|
||||
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);
|
||||
const result = getPlanningPromptPrefix('skip');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return lite prompt for lite mode without approval', () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('lite', false);
|
||||
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 feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: true,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('lite', true);
|
||||
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 feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('spec');
|
||||
expect(result).toContain('Specification Phase (Spec Mode)');
|
||||
expect(result).toContain('```tasks');
|
||||
expect(result).toContain('T001');
|
||||
@@ -74,11 +55,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
|
||||
it('should return full prompt for full mode', () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'full' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('full');
|
||||
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
|
||||
expect(result).toContain('Phase 1: Foundation');
|
||||
expect(result).toContain('Phase 2: Core Implementation');
|
||||
@@ -86,11 +63,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
|
||||
it('should include the separator and Feature Request header', () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('spec');
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('## Feature Request');
|
||||
});
|
||||
@@ -98,8 +71,7 @@ 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 feature = { id: 'test', planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix(mode);
|
||||
expect(result).toContain('Do NOT output exploration text');
|
||||
expect(result).toContain('Start DIRECTLY');
|
||||
}
|
||||
@@ -198,17 +170,14 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFeaturePrompt', () => {
|
||||
const buildFeaturePrompt = (svc: any, feature: any) => {
|
||||
return svc.buildFeaturePrompt(feature);
|
||||
};
|
||||
|
||||
describe('buildFeaturePrompt (from @automaker/prompts)', () => {
|
||||
it('should include feature ID and description', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
category: 'Test',
|
||||
description: 'Add user authentication',
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
const result = buildFeaturePrompt(feature);
|
||||
expect(result).toContain('feat-123');
|
||||
expect(result).toContain('Add user authentication');
|
||||
});
|
||||
@@ -216,10 +185,11 @@ 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(service, feature);
|
||||
const result = buildFeaturePrompt(feature);
|
||||
expect(result).toContain('Specification:');
|
||||
expect(result).toContain('Detailed specification here');
|
||||
});
|
||||
@@ -227,13 +197,14 @@ 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(service, feature);
|
||||
const result = buildFeaturePrompt(feature);
|
||||
expect(result).toContain('Context Images Attached');
|
||||
expect(result).toContain('image1.png');
|
||||
expect(result).toContain('/tmp/image2.jpg');
|
||||
@@ -242,55 +213,46 @@ 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(service, feature);
|
||||
const result = buildFeaturePrompt(feature);
|
||||
expect(result).toContain('<summary>');
|
||||
expect(result).toContain('</summary>');
|
||||
expect(result).toContain('summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTitleFromDescription', () => {
|
||||
const extractTitle = (svc: any, description: string) => {
|
||||
return svc.extractTitleFromDescription(description);
|
||||
};
|
||||
|
||||
describe('extractTitleFromDescription (from @automaker/prompts)', () => {
|
||||
it("should return 'Untitled Feature' for empty description", () => {
|
||||
expect(extractTitle(service, '')).toBe('Untitled Feature');
|
||||
expect(extractTitle(service, ' ')).toBe('Untitled Feature');
|
||||
expect(extractTitleFromDescription('')).toBe('Untitled Feature');
|
||||
expect(extractTitleFromDescription(' ')).toBe('Untitled Feature');
|
||||
});
|
||||
|
||||
it('should return first line if under 60 characters', () => {
|
||||
const description = 'Add user login\nWith email validation';
|
||||
expect(extractTitle(service, description)).toBe('Add user login');
|
||||
expect(extractTitleFromDescription(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 = extractTitle(service, description);
|
||||
const result = extractTitleFromDescription(description);
|
||||
expect(result.length).toBe(60);
|
||||
expect(result).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLANNING_PROMPTS structure', () => {
|
||||
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
describe('PLANNING_PROMPTS structure (from @automaker/prompts)', () => {
|
||||
it('should have all required planning modes', () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix(mode);
|
||||
expect(result.length).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('lite prompt should include correct structure', () => {
|
||||
const feature = { id: 'test', planningMode: 'lite' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('lite');
|
||||
expect(result).toContain('Goal');
|
||||
expect(result).toContain('Approach');
|
||||
expect(result).toContain('Files to Touch');
|
||||
@@ -299,8 +261,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
|
||||
it('spec prompt should include task format instructions', () => {
|
||||
const feature = { id: 'test', planningMode: 'spec' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('spec');
|
||||
expect(result).toContain('Problem');
|
||||
expect(result).toContain('Solution');
|
||||
expect(result).toContain('Acceptance Criteria');
|
||||
@@ -310,8 +271,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
|
||||
it('full prompt should include phases', () => {
|
||||
const feature = { id: 'test', planningMode: 'full' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('full');
|
||||
expect(result).toContain('Problem Statement');
|
||||
expect(result).toContain('User Story');
|
||||
expect(result).toContain('Technical Context');
|
||||
|
||||
@@ -1,92 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
import { parseTaskLine, parseTasksFromSpec } from '@automaker/prompts';
|
||||
|
||||
describe('Task Parsing', () => {
|
||||
describe('parseTaskLine', () => {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
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),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
332
apps/server/tests/unit/services/auto-mode/task-executor.test.ts
Normal file
332
apps/server/tests/unit/services/auto-mode/task-executor.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
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,10 +10,16 @@ vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock secure-fs
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
access: 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 net
|
||||
vi.mock('net', () => ({
|
||||
@@ -24,7 +30,7 @@ vi.mock('net', () => ({
|
||||
}));
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import net from 'net';
|
||||
|
||||
describe('dev-server-service.ts', () => {
|
||||
|
||||
@@ -16,12 +16,10 @@ 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 { PlanSpec } from '@/store/app-store';
|
||||
|
||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
import type { PlanningMode, PlanSpec, ParsedTask } from '@automaker/types';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ParsedTask, PlanSpec } from '@/store/app-store';
|
||||
export type { PlanningMode, ParsedTask, PlanSpec };
|
||||
|
||||
interface PlanningModeSelectorProps {
|
||||
mode: PlanningMode;
|
||||
|
||||
@@ -4,11 +4,25 @@ import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import type {
|
||||
Feature as BaseFeature,
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath,
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
ParsedTask,
|
||||
PlanSpec,
|
||||
ThinkingLevel,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Re-export types from @automaker/types for backwards compatibility
|
||||
export type {
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
ThinkingLevel,
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath,
|
||||
};
|
||||
|
||||
// Re-export ThemeMode for convenience
|
||||
export type { ThemeMode };
|
||||
|
||||
@@ -269,28 +283,8 @@ export interface Feature extends Omit<
|
||||
prUrl?: string; // UI-specific: Pull request URL
|
||||
}
|
||||
|
||||
// Parsed task from spec (for spec and full planning modes)
|
||||
export interface ParsedTask {
|
||||
id: string; // e.g., "T001"
|
||||
description: string; // e.g., "Create user model"
|
||||
filePath?: string; // e.g., "src/models/user.ts"
|
||||
phase?: string; // e.g., "Phase 1: Foundation" (for full mode)
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
// PlanSpec status for feature planning/specification
|
||||
export interface PlanSpec {
|
||||
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
||||
content?: string; // The actual spec/plan markdown content
|
||||
version: number;
|
||||
generatedAt?: string; // ISO timestamp
|
||||
approvedAt?: string; // ISO timestamp
|
||||
reviewedByUser: boolean; // True if user has seen the spec
|
||||
tasksCompleted?: number;
|
||||
tasksTotal?: number;
|
||||
currentTaskId?: string; // ID of the task currently being worked on
|
||||
tasks?: ParsedTask[]; // Parsed tasks from the spec
|
||||
}
|
||||
// Re-export planning types for backwards compatibility with existing imports
|
||||
export type { ParsedTask, PlanSpec };
|
||||
|
||||
// File tree node for project analysis
|
||||
export interface FileTreeNode {
|
||||
|
||||
671
docs/auto-mode-refactoring-plan.md
Normal file
671
docs/auto-mode-refactoring-plan.md
Normal file
@@ -0,0 +1,671 @@
|
||||
# Auto Mode Service Refactoring Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document proposes a refactoring of `apps/server/src/services/auto-mode-service.ts` (2,497 lines) into smaller, focused modules while leveraging shared packages per `docs/llm-shared-packages.md`.
|
||||
|
||||
## Current Problems
|
||||
|
||||
1. **Monolithic Service** - 2,497 lines with 16+ public methods mixing concerns
|
||||
2. **Giant Method** - `runAgent()` is 658 lines handling planning, approval, task execution, file I/O
|
||||
3. **Code Duplication** - Stream processing repeated 4x, types duplicated in 3 files
|
||||
4. **Missing Shared Package Usage** - Local types that exist in `@automaker/types`, prompts not in `@automaker/prompts`
|
||||
5. **No Structured Logging** - Uses `console.log` instead of `createLogger`
|
||||
6. **Untyped Events** - Event strings scattered, no type safety
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```
|
||||
apps/server/src/services/
|
||||
├── auto-mode/
|
||||
│ ├── index.ts # Re-exports AutoModeService (facade)
|
||||
│ ├── auto-mode-service.ts # Orchestrator (~300 lines)
|
||||
│ ├── feature-executor.ts # Feature execution logic
|
||||
│ ├── plan-approval-service.ts # Plan approval flow
|
||||
│ ├── task-executor.ts # Multi-task execution
|
||||
│ ├── worktree-manager.ts # Git worktree operations
|
||||
│ ├── output-writer.ts # Incremental file writing
|
||||
│ └── types.ts # Internal types (RunningFeature, etc.)
|
||||
├── feature-loader.ts # Existing - extend with status updates
|
||||
└── ...
|
||||
|
||||
libs/types/src/
|
||||
├── planning.ts # NEW: ParsedTask, PlanSpec, AutoModeEventType
|
||||
├── feature.ts # Update: use PlanSpec from planning.ts
|
||||
└── index.ts # Export planning types
|
||||
|
||||
libs/prompts/src/
|
||||
├── planning.ts # NEW: PLANNING_PROMPTS, task parsing, buildTaskPrompt
|
||||
├── enhancement.ts # Existing
|
||||
└── index.ts # Export planning functions
|
||||
|
||||
apps/server/src/lib/
|
||||
├── stream-processor.ts # NEW: Reusable stream processing utility
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Phase 1: Shared Package Updates (No Breaking Changes)
|
||||
|
||||
### 1.1 Add Planning Types to `@automaker/types`
|
||||
|
||||
Create `libs/types/src/planning.ts`:
|
||||
|
||||
```typescript
|
||||
// Task and plan status types
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
export type PlanSpecStatus = 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
||||
|
||||
export interface ParsedTask {
|
||||
id: string; // e.g., "T001"
|
||||
description: string; // e.g., "Create user model"
|
||||
filePath?: string; // e.g., "src/models/user.ts"
|
||||
phase?: string; // e.g., "Phase 1: Foundation"
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
export interface PlanSpec {
|
||||
status: PlanSpecStatus;
|
||||
content?: string;
|
||||
version: number;
|
||||
generatedAt?: string;
|
||||
approvedAt?: string;
|
||||
reviewedByUser: boolean;
|
||||
tasksCompleted?: number;
|
||||
tasksTotal?: number;
|
||||
currentTaskId?: string;
|
||||
tasks?: ParsedTask[];
|
||||
}
|
||||
|
||||
// Auto-mode event types for type safety
|
||||
export type AutoModeEventType =
|
||||
| 'auto_mode_started'
|
||||
| 'auto_mode_stopped'
|
||||
| 'auto_mode_idle'
|
||||
| 'auto_mode_feature_start'
|
||||
| 'auto_mode_feature_complete'
|
||||
| 'auto_mode_progress'
|
||||
| 'auto_mode_tool'
|
||||
| 'auto_mode_error'
|
||||
| 'auto_mode_task_started'
|
||||
| 'auto_mode_task_complete'
|
||||
| 'auto_mode_phase_complete'
|
||||
| 'planning_started'
|
||||
| 'plan_approval_required'
|
||||
| 'plan_approved'
|
||||
| 'plan_rejected'
|
||||
| 'plan_auto_approved'
|
||||
| 'plan_revision_requested';
|
||||
```
|
||||
|
||||
Update `libs/types/src/feature.ts` to import `PlanSpec`:
|
||||
|
||||
```typescript
|
||||
import type { PlanSpec } from './planning.js';
|
||||
|
||||
export interface Feature {
|
||||
// ... existing fields ...
|
||||
planSpec?: PlanSpec; // Now references shared type
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 Add Planning Prompts to `@automaker/prompts`
|
||||
|
||||
Create `libs/prompts/src/planning.ts`:
|
||||
|
||||
````typescript
|
||||
import type { PlanningMode, ParsedTask } from '@automaker/types';
|
||||
|
||||
// Planning mode prompts (moved from auto-mode-service.ts lines 57-211)
|
||||
export const PLANNING_PROMPTS = {
|
||||
lite: `## Planning Phase (Lite Mode)...`,
|
||||
lite_with_approval: `## Planning Phase (Lite Mode)...`,
|
||||
spec: `## Specification Phase (Spec Mode)...`,
|
||||
full: `## Full Specification Phase (Full SDD Mode)...`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get planning prompt for a mode
|
||||
*/
|
||||
export function getPlanningPrompt(mode: PlanningMode, requireApproval?: boolean): string {
|
||||
if (mode === 'skip') return '';
|
||||
if (mode === 'lite' && requireApproval) return PLANNING_PROMPTS.lite_with_approval;
|
||||
return PLANNING_PROMPTS[mode] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tasks from generated spec content
|
||||
* Looks for ```tasks code block and extracts task lines
|
||||
*/
|
||||
export function parseTasksFromSpec(specContent: string): ParsedTask[] {
|
||||
// ... moved from auto-mode-service.ts lines 218-265
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single task line
|
||||
* Format: - [ ] T###: Description | File: path/to/file
|
||||
*/
|
||||
export function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||
// ... moved from auto-mode-service.ts lines 271-295
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a focused prompt for executing a single task
|
||||
*/
|
||||
export function buildTaskPrompt(
|
||||
task: ParsedTask,
|
||||
allTasks: ParsedTask[],
|
||||
taskIndex: number,
|
||||
planContent: string,
|
||||
userFeedback?: string
|
||||
): string {
|
||||
// ... moved from auto-mode-service.ts lines 2389-2458
|
||||
}
|
||||
````
|
||||
|
||||
## Phase 2: Extract Utility Classes
|
||||
|
||||
### 2.1 Create Stream Processor
|
||||
|
||||
Create `apps/server/src/lib/stream-processor.ts`:
|
||||
|
||||
```typescript
|
||||
import type { ProviderMessage } from '../providers/types.js';
|
||||
|
||||
export interface StreamHandlers {
|
||||
onText?: (text: string) => void;
|
||||
onToolUse?: (name: string, input: unknown) => void;
|
||||
onError?: (error: string) => void;
|
||||
onComplete?: (result: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process provider message stream with unified handling
|
||||
* Eliminates the 4x duplicated stream processing pattern
|
||||
*/
|
||||
export async function* processStream(
|
||||
stream: AsyncGenerator<ProviderMessage>,
|
||||
handlers: StreamHandlers
|
||||
): AsyncGenerator<{ text: string; isComplete: boolean }> {
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
handlers.onText?.(block.text || '');
|
||||
yield { text: block.text || '', isComplete: false };
|
||||
} else if (block.type === 'tool_use') {
|
||||
handlers.onToolUse?.(block.name, block.input);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
handlers.onError?.(msg.error || 'Unknown error');
|
||||
throw new Error(msg.error || 'Unknown error');
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
handlers.onComplete?.(msg.result || '');
|
||||
yield { text: msg.result || '', isComplete: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Create Output Writer
|
||||
|
||||
Create `apps/server/src/services/auto-mode/output-writer.ts`:
|
||||
|
||||
```typescript
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Handles incremental, debounced file writing for agent output
|
||||
*/
|
||||
export class OutputWriter {
|
||||
private content = '';
|
||||
private writeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly debounceMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly outputPath: string,
|
||||
debounceMs = 500
|
||||
) {
|
||||
this.debounceMs = debounceMs;
|
||||
}
|
||||
|
||||
append(text: string): void {
|
||||
this.content += text;
|
||||
this.scheduleWrite();
|
||||
}
|
||||
|
||||
getContent(): string {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
private scheduleWrite(): void {
|
||||
if (this.writeTimeout) {
|
||||
clearTimeout(this.writeTimeout);
|
||||
}
|
||||
this.writeTimeout = setTimeout(() => this.flush(), this.debounceMs);
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error(`[OutputWriter] Failed to write: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 3: Extract Service Classes
|
||||
|
||||
### 3.1 Plan Approval Service
|
||||
|
||||
Create `apps/server/src/services/auto-mode/plan-approval-service.ts`:
|
||||
|
||||
```typescript
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { PlanSpec, AutoModeEventType } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
interface PendingApproval {
|
||||
resolve: (result: ApprovalResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
interface ApprovalResult {
|
||||
approved: boolean;
|
||||
editedPlan?: string;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
const logger = createLogger('PlanApprovalService');
|
||||
|
||||
export class PlanApprovalService {
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
|
||||
constructor(private events: EventEmitter) {}
|
||||
|
||||
waitForApproval(featureId: string, projectPath: string): Promise<ApprovalResult> {
|
||||
logger.debug(`Registering pending approval for feature ${featureId}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingApprovals.set(featureId, { resolve, reject, featureId, projectPath });
|
||||
});
|
||||
}
|
||||
|
||||
async resolve(
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const pending = this.pendingApprovals.get(featureId);
|
||||
if (!pending) {
|
||||
return { success: false, error: `No pending approval for ${featureId}` };
|
||||
}
|
||||
|
||||
pending.resolve({ approved, editedPlan, feedback });
|
||||
this.pendingApprovals.delete(featureId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
cancel(featureId: string): void {
|
||||
const pending = this.pendingApprovals.get(featureId);
|
||||
if (pending) {
|
||||
pending.reject(new Error('Plan approval cancelled'));
|
||||
this.pendingApprovals.delete(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
hasPending(featureId: string): boolean {
|
||||
return this.pendingApprovals.has(featureId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Task Executor
|
||||
|
||||
Create `apps/server/src/services/auto-mode/task-executor.ts`:
|
||||
|
||||
```typescript
|
||||
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 } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('TaskExecutor');
|
||||
|
||||
interface TaskExecutionContext {
|
||||
workDir: string;
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
provider: BaseProvider;
|
||||
model: string;
|
||||
maxTurns: number;
|
||||
allowedTools?: string[];
|
||||
abortController: AbortController;
|
||||
planContent: string;
|
||||
userFeedback?: string;
|
||||
}
|
||||
|
||||
interface TaskProgress {
|
||||
taskId: string;
|
||||
taskIndex: number;
|
||||
tasksTotal: number;
|
||||
status: 'started' | 'completed' | 'failed';
|
||||
output?: string;
|
||||
phaseComplete?: number;
|
||||
}
|
||||
|
||||
export class TaskExecutor {
|
||||
constructor(private events: EventEmitter) {}
|
||||
|
||||
async *executeAll(
|
||||
tasks: ParsedTask[],
|
||||
context: TaskExecutionContext
|
||||
): AsyncGenerator<TaskProgress> {
|
||||
for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
|
||||
const task = tasks[taskIndex];
|
||||
|
||||
if (context.abortController.signal.aborted) {
|
||||
throw new Error('Feature execution aborted');
|
||||
}
|
||||
|
||||
logger.info(`Starting task ${task.id}: ${task.description}`);
|
||||
yield {
|
||||
taskId: task.id,
|
||||
taskIndex,
|
||||
tasksTotal: tasks.length,
|
||||
status: 'started',
|
||||
};
|
||||
|
||||
const taskPrompt = buildTaskPrompt(
|
||||
task,
|
||||
tasks,
|
||||
taskIndex,
|
||||
context.planContent,
|
||||
context.userFeedback
|
||||
);
|
||||
|
||||
const taskStream = context.provider.executeQuery({
|
||||
prompt: taskPrompt,
|
||||
model: context.model,
|
||||
maxTurns: Math.min(context.maxTurns, 50),
|
||||
cwd: context.workDir,
|
||||
allowedTools: context.allowedTools,
|
||||
abortController: context.abortController,
|
||||
});
|
||||
|
||||
let taskOutput = '';
|
||||
for await (const msg of taskStream) {
|
||||
// Process stream messages...
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
taskOutput += block.text || '';
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: 'auto_mode_progress',
|
||||
featureId: context.featureId,
|
||||
content: block.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Task ${task.id} completed`);
|
||||
yield {
|
||||
taskId: task.id,
|
||||
taskIndex,
|
||||
tasksTotal: tasks.length,
|
||||
status: 'completed',
|
||||
output: taskOutput,
|
||||
phaseComplete: this.checkPhaseComplete(task, tasks, taskIndex),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const phaseMatch = task.phase.match(/Phase\s*(\d+)/i);
|
||||
return phaseMatch ? parseInt(phaseMatch[1], 10) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Worktree Manager
|
||||
|
||||
Create `apps/server/src/services/auto-mode/worktree-manager.ts`:
|
||||
|
||||
```typescript
|
||||
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');
|
||||
|
||||
export class WorktreeManager {
|
||||
/**
|
||||
* Find existing worktree path for a branch
|
||||
*/
|
||||
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) {
|
||||
if (currentBranch === branchName) {
|
||||
return path.isAbsolute(currentPath)
|
||||
? path.resolve(currentPath)
|
||||
: path.resolve(projectPath, currentPath);
|
||||
}
|
||||
currentPath = null;
|
||||
currentBranch = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check last entry
|
||||
if (currentPath && currentBranch === branchName) {
|
||||
return path.isAbsolute(currentPath)
|
||||
? path.resolve(currentPath)
|
||||
: path.resolve(projectPath, currentPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to find worktree for branch ${branchName}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve working directory for feature execution
|
||||
*/
|
||||
async resolveWorkDir(
|
||||
projectPath: string,
|
||||
branchName: string | undefined,
|
||||
useWorktrees: boolean
|
||||
): Promise<{ workDir: string; worktreePath: string | null }> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 4: Refactor AutoModeService
|
||||
|
||||
### 4.1 Simplified AutoModeService
|
||||
|
||||
The refactored `auto-mode-service.ts` (~300-400 lines) becomes an orchestrator:
|
||||
|
||||
```typescript
|
||||
import type { Feature, ExecuteOptions, PlanSpec, ParsedTask } from '@automaker/types';
|
||||
import { createLogger, classifyError, buildPromptWithImages, loadContextFiles } from '@automaker/utils';
|
||||
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||
import { getPlanningPrompt, parseTasksFromSpec } from '@automaker/prompts';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import { validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
|
||||
import { PlanApprovalService } from './auto-mode/plan-approval-service.js';
|
||||
import { TaskExecutor } from './auto-mode/task-executor.js';
|
||||
import { WorktreeManager } from './auto-mode/worktree-manager.js';
|
||||
import { OutputWriter } from './auto-mode/output-writer.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
|
||||
const logger = createLogger('AutoModeService');
|
||||
|
||||
export class AutoModeService {
|
||||
private events: EventEmitter;
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
private featureLoader = new FeatureLoader();
|
||||
private planApproval: PlanApprovalService;
|
||||
private taskExecutor: TaskExecutor;
|
||||
private worktreeManager: WorktreeManager;
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
this.planApproval = new PlanApprovalService(events);
|
||||
this.taskExecutor = new TaskExecutor(events);
|
||||
this.worktreeManager = new WorktreeManager();
|
||||
}
|
||||
|
||||
// Public methods remain the same API, but delegate to sub-services
|
||||
async executeFeature(...): Promise<void> {
|
||||
// Validation
|
||||
validateWorkingDirectory(projectPath);
|
||||
|
||||
// Resolve work directory via WorktreeManager
|
||||
const { workDir, worktreePath } = await this.worktreeManager.resolveWorkDir(
|
||||
projectPath, feature.branchName, useWorktrees
|
||||
);
|
||||
|
||||
// Build prompt (simplified)
|
||||
const prompt = getPlanningPrompt(feature.planningMode, feature.requirePlanApproval)
|
||||
+ this.buildFeaturePrompt(feature);
|
||||
|
||||
// Execute agent (delegated to runAgent which uses TaskExecutor)
|
||||
await this.runAgent(workDir, featureId, prompt, ...);
|
||||
}
|
||||
|
||||
// Plan approval delegated to PlanApprovalService
|
||||
waitForPlanApproval = this.planApproval.waitForApproval.bind(this.planApproval);
|
||||
resolvePlanApproval = this.planApproval.resolve.bind(this.planApproval);
|
||||
cancelPlanApproval = this.planApproval.cancel.bind(this.planApproval);
|
||||
hasPendingApproval = this.planApproval.hasPending.bind(this.planApproval);
|
||||
}
|
||||
```
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
### New Files to Create
|
||||
|
||||
| File | Purpose | Est. Lines |
|
||||
| ------------------------------------------------------------- | -------------------------- | ---------- |
|
||||
| `libs/types/src/planning.ts` | Planning types | ~50 |
|
||||
| `libs/prompts/src/planning.ts` | Planning prompts & parsing | ~200 |
|
||||
| `apps/server/src/lib/stream-processor.ts` | Stream utility | ~50 |
|
||||
| `apps/server/src/services/auto-mode/index.ts` | Re-exports | ~10 |
|
||||
| `apps/server/src/services/auto-mode/plan-approval-service.ts` | Approval logic | ~100 |
|
||||
| `apps/server/src/services/auto-mode/task-executor.ts` | Task execution | ~150 |
|
||||
| `apps/server/src/services/auto-mode/worktree-manager.ts` | Git worktrees | ~80 |
|
||||
| `apps/server/src/services/auto-mode/output-writer.ts` | File I/O | ~60 |
|
||||
| `apps/server/src/services/auto-mode/types.ts` | Internal types | ~40 |
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------------------------- | ---------------------------------- |
|
||||
| `libs/types/src/index.ts` | Export planning types |
|
||||
| `libs/types/src/feature.ts` | Import PlanSpec |
|
||||
| `libs/prompts/src/index.ts` | Export planning functions |
|
||||
| `apps/server/src/services/auto-mode-service.ts` | Refactor to orchestrator |
|
||||
| `apps/ui/src/store/app-store.ts` | Import types from @automaker/types |
|
||||
| `apps/ui/src/components/.../planning-mode-selector.tsx` | Import PlanningMode |
|
||||
| `apps/server/tests/.../auto-mode-task-parsing.test.ts` | Import from @automaker/prompts |
|
||||
|
||||
### Files to Delete (after refactoring)
|
||||
|
||||
None - old file becomes the slim orchestrator.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After refactoring, verify:
|
||||
|
||||
- [ ] `npm run build:packages` succeeds
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] `npm run test:packages` passes
|
||||
- [ ] `npm run test:server` passes
|
||||
- [ ] `npm run test` (E2E) passes
|
||||
- [ ] Feature execution works in UI
|
||||
- [ ] Plan approval flow works (spec/full modes)
|
||||
- [ ] Task progress events appear correctly
|
||||
- [ ] Resume feature works
|
||||
- [ ] Follow-up feature works
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. **Phase 1**: Add shared package updates (non-breaking)
|
||||
2. **Phase 2**: Extract utilities (stream-processor, output-writer)
|
||||
3. **Phase 3**: Extract services one at a time, keeping old code as fallback
|
||||
4. **Phase 4**: Wire up orchestrator, remove old code
|
||||
5. **Phase 5**: Update tests to use new imports
|
||||
|
||||
Each phase should be a separate PR for easier review.
|
||||
|
||||
## Benefits
|
||||
|
||||
| Metric | Before | After |
|
||||
| ---------------- | -------------------- | -------------------- |
|
||||
| Main file lines | 2,497 | ~400 |
|
||||
| Largest method | 658 lines | ~100 lines |
|
||||
| Code duplication | 4x stream processing | 1 utility |
|
||||
| Type safety | None for events | Full |
|
||||
| Testability | Hard (monolith) | Easy (focused units) |
|
||||
| Logging | console.log | createLogger |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should `startAutoLoop`/`stopAutoLoop` remain in AutoModeService or become a separate `AutoLoopService`?
|
||||
2. Should we add a `FeatureRepository` class to consolidate all feature file operations?
|
||||
3. Is the recovery mechanism in `resolvePlanApproval` still needed with the refactored architecture?
|
||||
112
libs/git-utils/src/commit.ts
Normal file
112
libs/git-utils/src/commit.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Git Commit Utilities - Commit operations for git repositories
|
||||
*
|
||||
* Provides utilities for staging and committing changes.
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Check if there are uncommitted changes in the working directory
|
||||
*
|
||||
* @param workDir - The working directory to check
|
||||
* @returns True if there are uncommitted changes
|
||||
*/
|
||||
export async function hasUncommittedChanges(workDir: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd: workDir });
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage all changes and commit with a message
|
||||
*
|
||||
* @param workDir - The working directory
|
||||
* @param message - The commit message
|
||||
* @returns The commit hash if successful, null otherwise
|
||||
*/
|
||||
export async function commitAll(workDir: string, message: string): Promise<string | null> {
|
||||
try {
|
||||
// Check for changes first
|
||||
const hasChanges = await hasUncommittedChanges(workDir);
|
||||
if (!hasChanges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
await execAsync('git add -A', { cwd: workDir });
|
||||
|
||||
// Commit with message (escape double quotes)
|
||||
const escapedMessage = message.replace(/"/g, '\\"');
|
||||
await execAsync(`git commit -m "${escapedMessage}"`, { cwd: workDir });
|
||||
|
||||
// Get the commit hash
|
||||
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: workDir });
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current HEAD commit hash
|
||||
*
|
||||
* @param workDir - The working directory
|
||||
* @returns The commit hash or null if not a git repo
|
||||
*/
|
||||
export async function getHeadHash(workDir: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: workDir });
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the short version of a commit hash
|
||||
*
|
||||
* @param hash - The full commit hash
|
||||
* @param length - Length of short hash (default 8)
|
||||
* @returns The shortened hash
|
||||
*/
|
||||
export function shortHash(hash: string, length = 8): string {
|
||||
return hash.substring(0, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run verification commands (lint, typecheck, test, build)
|
||||
*
|
||||
* @param workDir - The working directory
|
||||
* @param checks - Optional custom checks (defaults to npm scripts)
|
||||
* @returns Object with success status and failed check name if any
|
||||
*/
|
||||
export async function runVerificationChecks(
|
||||
workDir: string,
|
||||
checks?: Array<{ cmd: string; name: string }>
|
||||
): Promise<{ success: boolean; failedCheck?: string }> {
|
||||
const defaultChecks = [
|
||||
{ cmd: 'npm run lint', name: 'Lint' },
|
||||
{ cmd: 'npm run typecheck', name: 'Type check' },
|
||||
{ cmd: 'npm test', name: 'Tests' },
|
||||
{ cmd: 'npm run build', name: 'Build' },
|
||||
];
|
||||
|
||||
const checksToRun = checks || defaultChecks;
|
||||
|
||||
for (const check of checksToRun) {
|
||||
try {
|
||||
await execAsync(check.cmd, { cwd: workDir, timeout: 120000 });
|
||||
} catch {
|
||||
return { success: false, failedCheck: check.name };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -17,3 +17,12 @@ export {
|
||||
generateDiffsForNonGitDirectory,
|
||||
getGitRepositoryDiffs,
|
||||
} from './diff.js';
|
||||
|
||||
// Export commit utilities
|
||||
export {
|
||||
hasUncommittedChanges,
|
||||
commitAll,
|
||||
getHeadHash,
|
||||
shortHash,
|
||||
runVerificationChecks,
|
||||
} from './commit.js';
|
||||
|
||||
138
libs/prompts/src/feature-prompt.ts
Normal file
138
libs/prompts/src/feature-prompt.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Feature Prompt - Prompt building for feature implementation
|
||||
*
|
||||
* Contains utilities for building prompts from Feature objects.
|
||||
*/
|
||||
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Extract a title from feature description
|
||||
*
|
||||
* Takes the first line of the description and truncates if needed.
|
||||
*
|
||||
* @param description - The feature description
|
||||
* @returns A title string (max 60 chars)
|
||||
*/
|
||||
export function extractTitleFromDescription(description: string): string {
|
||||
if (!description?.trim()) {
|
||||
return 'Untitled Feature';
|
||||
}
|
||||
|
||||
const firstLine = description.split('\n')[0].trim();
|
||||
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a feature implementation prompt
|
||||
*
|
||||
* Creates a structured prompt for the AI agent to implement a feature.
|
||||
*
|
||||
* @param feature - The feature to build a prompt for
|
||||
* @returns The formatted prompt string
|
||||
*/
|
||||
export function buildFeaturePrompt(feature: Feature): string {
|
||||
const title = extractTitleFromDescription(feature.description);
|
||||
|
||||
let prompt = `## Feature Implementation Task
|
||||
|
||||
**Feature ID:** ${feature.id}
|
||||
**Title:** ${title}
|
||||
**Description:** ${feature.description}
|
||||
`;
|
||||
|
||||
if (feature.spec) {
|
||||
prompt += `\n**Specification:**\n${feature.spec}\n`;
|
||||
}
|
||||
|
||||
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
||||
const imagesList = feature.imagePaths
|
||||
.map((img, idx) => {
|
||||
const imgPath = typeof img === 'string' ? img : img.path;
|
||||
const filename =
|
||||
typeof img === 'string'
|
||||
? imgPath.split('/').pop()
|
||||
: (img as { filename?: string }).filename || imgPath.split('/').pop();
|
||||
return ` ${idx + 1}. ${filename}\n Path: ${imgPath}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
prompt += `\n**Context Images Attached:**\n${imagesList}\n`;
|
||||
}
|
||||
|
||||
if (feature.skipTests) {
|
||||
prompt += `
|
||||
## Instructions
|
||||
|
||||
Implement this feature by:
|
||||
1. Explore the codebase to understand the existing structure
|
||||
2. Plan your implementation approach
|
||||
3. Write the necessary code changes
|
||||
4. Ensure the code follows existing patterns
|
||||
|
||||
When done, wrap your final summary in <summary> tags.`;
|
||||
} else {
|
||||
prompt += `
|
||||
## Instructions
|
||||
|
||||
Implement and verify this feature:
|
||||
1. Explore the codebase
|
||||
2. Plan your approach
|
||||
3. Write the code changes
|
||||
4. Verify with Playwright tests
|
||||
|
||||
When done, wrap your final summary in <summary> tags.`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a follow-up prompt for continuing work on a feature
|
||||
*
|
||||
* @param feature - The feature being followed up on
|
||||
* @param previousContext - Previous agent work context
|
||||
* @param followUpInstructions - New instructions from user
|
||||
* @returns The formatted follow-up prompt
|
||||
*/
|
||||
export function buildFollowUpPrompt(
|
||||
feature: Feature | null,
|
||||
featureId: string,
|
||||
previousContext: string,
|
||||
followUpInstructions: string
|
||||
): string {
|
||||
let prompt = `## Follow-up on Feature Implementation\n\n`;
|
||||
|
||||
if (feature) {
|
||||
prompt += buildFeaturePrompt(feature) + '\n';
|
||||
} else {
|
||||
prompt += `**Feature ID:** ${featureId}\n`;
|
||||
}
|
||||
|
||||
if (previousContext) {
|
||||
prompt += `\n## Previous Agent Work\n${previousContext}\n`;
|
||||
}
|
||||
|
||||
prompt += `\n## Follow-up Instructions\n${followUpInstructions}\n\n## Task\nAddress the follow-up instructions above.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a continuation prompt for resuming work
|
||||
*
|
||||
* @param feature - The feature to continue
|
||||
* @param context - Previous work context
|
||||
* @returns The continuation prompt
|
||||
*/
|
||||
export function buildContinuationPrompt(feature: Feature, context: string): string {
|
||||
return `## Continuing Feature Implementation
|
||||
|
||||
${buildFeaturePrompt(feature)}
|
||||
|
||||
## Previous Context
|
||||
${context}
|
||||
|
||||
## Instructions
|
||||
Review the previous work and continue the implementation.`;
|
||||
}
|
||||
@@ -21,5 +21,31 @@ export {
|
||||
getAvailableEnhancementModes,
|
||||
} from './enhancement.js';
|
||||
|
||||
// Planning prompts (spec-driven development)
|
||||
export {
|
||||
PLANNING_PROMPTS,
|
||||
getPlanningPrompt,
|
||||
getPlanningPromptPrefix,
|
||||
parseTasksFromSpec,
|
||||
parseTaskLine,
|
||||
buildTaskPrompt,
|
||||
isSpecGeneratingMode,
|
||||
canRequireApproval,
|
||||
getPlanningModeDisplayName,
|
||||
} from './planning.js';
|
||||
|
||||
// Feature prompts (implementation)
|
||||
export {
|
||||
buildFeaturePrompt,
|
||||
buildFollowUpPrompt,
|
||||
buildContinuationPrompt,
|
||||
extractTitleFromDescription,
|
||||
} from './feature-prompt.js';
|
||||
|
||||
// Re-export types from @automaker/types
|
||||
export type { EnhancementMode, EnhancementExample } from '@automaker/types';
|
||||
export type {
|
||||
EnhancementMode,
|
||||
EnhancementExample,
|
||||
PlanningMode,
|
||||
ParsedTask,
|
||||
} from '@automaker/types';
|
||||
|
||||
411
libs/prompts/src/planning.ts
Normal file
411
libs/prompts/src/planning.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Planning Prompts - AI prompt templates for spec-driven development
|
||||
*
|
||||
* Contains planning mode prompts, task parsing utilities, and prompt builders
|
||||
* for the multi-agent task execution workflow.
|
||||
*/
|
||||
|
||||
import type { PlanningMode, ParsedTask } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Planning mode prompt templates
|
||||
*
|
||||
* Each mode has a specific prompt format that instructs the AI to generate
|
||||
* a planning document with task breakdowns in a parseable format.
|
||||
*/
|
||||
export const PLANNING_PROMPTS = {
|
||||
lite: `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[PLAN_GENERATED] Planning outline complete."
|
||||
|
||||
Then proceed with implementation.`,
|
||||
|
||||
lite_with_approval: `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.`,
|
||||
|
||||
spec: `## Specification Phase (Spec Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a specification with an actionable task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem**: What problem are we solving? (user perspective)
|
||||
|
||||
2. **Solution**: Brief approach (1-2 sentences)
|
||||
|
||||
3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format
|
||||
- GIVEN [context], WHEN [action], THEN [outcome]
|
||||
|
||||
4. **Files to Modify**:
|
||||
| File | Purpose | Action |
|
||||
|------|---------|--------|
|
||||
| path/to/file | description | create/modify/delete |
|
||||
|
||||
5. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential: T001, T002, T003, etc.
|
||||
- Description: Clear action (e.g., "Create user model", "Add API endpoint")
|
||||
- File: Primary file affected (helps with context)
|
||||
- Order by dependencies (foundational tasks first)
|
||||
|
||||
6. **Verification**: How to confirm feature works
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY in order. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
This allows real-time progress tracking during implementation.`,
|
||||
|
||||
full: `## Full Specification Phase (Full SDD Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem Statement**: 2-3 sentences from user perspective
|
||||
|
||||
2. **User Story**: As a [user], I want [goal], so that [benefit]
|
||||
|
||||
3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN
|
||||
- **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome]
|
||||
- **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling]
|
||||
- **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response]
|
||||
|
||||
4. **Technical Context**:
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Affected Files | list of files |
|
||||
| Dependencies | external libs if any |
|
||||
| Constraints | technical limitations |
|
||||
| Patterns to Follow | existing patterns in codebase |
|
||||
|
||||
5. **Non-Goals**: What this feature explicitly does NOT include
|
||||
|
||||
6. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
## Phase 1: Foundation
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 2: Core Implementation
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
- [ ] T004: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 3: Integration & Testing
|
||||
- [ ] T005: [Description] | File: [path/to/file]
|
||||
- [ ] T006: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential across all phases: T001, T002, T003, etc.
|
||||
- Description: Clear action verb + target
|
||||
- File: Primary file affected
|
||||
- Order by dependencies within each phase
|
||||
- Phase structure helps organize complex work
|
||||
|
||||
7. **Success Metrics**: How we know it's done (measurable criteria)
|
||||
|
||||
8. **Risks & Mitigations**:
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| description | approach |
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY by phase. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
After completing all tasks in a phase, output:
|
||||
"[PHASE_COMPLETE] Phase N complete"
|
||||
|
||||
This allows real-time progress tracking during implementation.`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get the planning prompt for a given mode
|
||||
*
|
||||
* @param mode - The planning mode (skip, lite, spec, full)
|
||||
* @param requireApproval - Whether to use approval variant for lite mode
|
||||
* @returns The prompt string, or empty string for 'skip' mode
|
||||
*/
|
||||
export function getPlanningPrompt(mode: PlanningMode, requireApproval?: boolean): string {
|
||||
if (mode === 'skip') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// For lite mode, use approval variant if required
|
||||
if (mode === 'lite' && requireApproval) {
|
||||
return PLANNING_PROMPTS.lite_with_approval;
|
||||
}
|
||||
|
||||
return PLANNING_PROMPTS[mode] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the planning prompt prefix for a feature prompt
|
||||
*
|
||||
* Used to prepend planning instructions before the feature description.
|
||||
*
|
||||
* @param mode - The planning mode
|
||||
* @param requireApproval - Whether approval is required
|
||||
* @returns Formatted prompt prefix with separator, or empty string
|
||||
*/
|
||||
export function getPlanningPromptPrefix(mode: PlanningMode, requireApproval?: boolean): string {
|
||||
const prompt = getPlanningPrompt(mode, requireApproval);
|
||||
if (!prompt) {
|
||||
return '';
|
||||
}
|
||||
return prompt + '\n\n---\n\n## Feature Request\n\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tasks from generated spec content
|
||||
*
|
||||
* Looks for the ```tasks code block and extracts task lines.
|
||||
* Falls back to finding task lines anywhere in content if no block found.
|
||||
*
|
||||
* @param specContent - The full spec content string
|
||||
* @returns Array of parsed tasks
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single task line
|
||||
*
|
||||
* Format: - [ ] T###: Description | File: path/to/file
|
||||
*
|
||||
* @param line - The task line to parse
|
||||
* @param currentPhase - Optional phase context
|
||||
* @returns Parsed task or null if line doesn't match format
|
||||
*/
|
||||
export 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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a focused prompt for executing a single task
|
||||
*
|
||||
* Creates a prompt that shows the current task, completed tasks,
|
||||
* and remaining tasks to give the agent context while keeping focus.
|
||||
*
|
||||
* @param task - The current task to execute
|
||||
* @param allTasks - All tasks in the spec
|
||||
* @param taskIndex - Index of current task in allTasks
|
||||
* @param planContent - The full approved plan content
|
||||
* @param userFeedback - Optional user feedback to incorporate
|
||||
* @returns Formatted prompt for task execution
|
||||
*/
|
||||
export function buildTaskPrompt(
|
||||
task: ParsedTask,
|
||||
allTasks: ParsedTask[],
|
||||
taskIndex: number,
|
||||
planContent: string,
|
||||
userFeedback?: string
|
||||
): string {
|
||||
const completedTasks = allTasks.slice(0, taskIndex);
|
||||
const remainingTasks = allTasks.slice(taskIndex + 1);
|
||||
|
||||
let prompt = `# Task Execution: ${task.id}
|
||||
|
||||
You are executing a specific task as part of a larger feature implementation.
|
||||
|
||||
## Your Current Task
|
||||
|
||||
**Task ID:** ${task.id}
|
||||
**Description:** ${task.description}
|
||||
${task.filePath ? `**Primary File:** ${task.filePath}` : ''}
|
||||
${task.phase ? `**Phase:** ${task.phase}` : ''}
|
||||
|
||||
## Context
|
||||
|
||||
`;
|
||||
|
||||
// Show what's already done
|
||||
if (completedTasks.length > 0) {
|
||||
prompt += `### Already Completed (${completedTasks.length} tasks)
|
||||
${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Show remaining tasks
|
||||
if (remainingTasks.length > 0) {
|
||||
prompt += `### Coming Up Next (${remainingTasks.length} tasks remaining)
|
||||
${remainingTasks
|
||||
.slice(0, 3)
|
||||
.map((t) => `- [ ] ${t.id}: ${t.description}`)
|
||||
.join('\n')}
|
||||
${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ''}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Add user feedback if any
|
||||
if (userFeedback) {
|
||||
prompt += `### User Feedback
|
||||
${userFeedback}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Add relevant excerpt from plan (just the task-related part to save context)
|
||||
prompt += `### Reference: Full Plan
|
||||
<details>
|
||||
${planContent}
|
||||
</details>
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Focus ONLY on completing task ${task.id}: "${task.description}"
|
||||
2. Do not work on other tasks
|
||||
3. Use the existing codebase patterns
|
||||
4. When done, summarize what you implemented
|
||||
|
||||
Begin implementing task ${task.id} now.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a planning mode requires spec generation
|
||||
*/
|
||||
export function isSpecGeneratingMode(mode: PlanningMode): boolean {
|
||||
return mode === 'spec' || mode === 'full' || mode === 'lite';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a planning mode can require approval
|
||||
*/
|
||||
export function canRequireApproval(mode: PlanningMode): boolean {
|
||||
return mode !== 'skip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a planning mode
|
||||
*/
|
||||
export function getPlanningModeDisplayName(mode: PlanningMode): string {
|
||||
const names: Record<PlanningMode, string> = {
|
||||
skip: 'Skip Planning',
|
||||
lite: 'Lite Planning',
|
||||
spec: 'Specification',
|
||||
full: 'Full SDD',
|
||||
};
|
||||
return names[mode] || mode;
|
||||
}
|
||||
35
libs/types/src/claude.ts
Normal file
35
libs/types/src/claude.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Claude Usage types for CLI-based usage tracking
|
||||
*/
|
||||
|
||||
export interface 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 interface ClaudeStatus {
|
||||
indicator: {
|
||||
color: 'green' | 'yellow' | 'orange' | 'red' | 'gray';
|
||||
};
|
||||
description: string;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { PlanningMode } from './settings.js';
|
||||
import type { PlanSpec } from './planning.js';
|
||||
|
||||
export interface FeatureImagePath {
|
||||
id: string;
|
||||
@@ -41,20 +42,21 @@ export interface Feature {
|
||||
thinkingLevel?: string;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
planSpec?: {
|
||||
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
||||
content?: string;
|
||||
version: number;
|
||||
generatedAt?: string;
|
||||
approvedAt?: string;
|
||||
reviewedByUser: boolean;
|
||||
tasksCompleted?: number;
|
||||
tasksTotal?: number;
|
||||
};
|
||||
/** Specification state for spec-driven development modes */
|
||||
planSpec?: PlanSpec;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
startedAt?: string;
|
||||
[key: string]: unknown; // Keep catch-all for extensibility
|
||||
}
|
||||
|
||||
export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified';
|
||||
export type FeatureStatus =
|
||||
| 'pending'
|
||||
| 'ready'
|
||||
| 'backlog'
|
||||
| 'in_progress'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'verified'
|
||||
| 'waiting_approval';
|
||||
|
||||
59
libs/types/src/github.ts
Normal file
59
libs/types/src/github.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* GitHub-related types shared across server and UI
|
||||
*/
|
||||
|
||||
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 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 GitHubRemoteStatus {
|
||||
hasGitHubRemote: boolean;
|
||||
remoteUrl: string | null;
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
}
|
||||
|
||||
export interface ListPRsResult {
|
||||
success: boolean;
|
||||
openPRs?: GitHubPR[];
|
||||
mergedPRs?: GitHubPR[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ListIssuesResult {
|
||||
success: boolean;
|
||||
openIssues?: GitHubIssue[];
|
||||
closedIssues?: GitHubIssue[];
|
||||
error?: string;
|
||||
}
|
||||
@@ -81,3 +81,40 @@ export {
|
||||
THINKING_LEVEL_LABELS,
|
||||
getModelDisplayName,
|
||||
} from './model-display.js';
|
||||
|
||||
// Planning types (spec-driven development)
|
||||
export type {
|
||||
TaskStatus,
|
||||
PlanSpecStatus,
|
||||
ParsedTask,
|
||||
PlanSpec,
|
||||
AutoModeEventType,
|
||||
AutoModeEventPayload,
|
||||
TaskProgressPayload,
|
||||
PlanApprovalPayload,
|
||||
} from './planning.js';
|
||||
|
||||
// GitHub types
|
||||
export type {
|
||||
GitHubLabel,
|
||||
GitHubAuthor,
|
||||
GitHubIssue,
|
||||
GitHubPR,
|
||||
GitHubRemoteStatus,
|
||||
ListPRsResult,
|
||||
ListIssuesResult,
|
||||
} from './github.js';
|
||||
|
||||
// Worktree types
|
||||
export type {
|
||||
WorktreePRInfo,
|
||||
WorktreeMetadata,
|
||||
WorktreeListItem,
|
||||
PRComment,
|
||||
PRInfo,
|
||||
DevServerInfo,
|
||||
TrackedBranch,
|
||||
} from './worktree.js';
|
||||
|
||||
// Claude usage types
|
||||
export type { ClaudeUsage, ClaudeStatus } from './claude.js';
|
||||
|
||||
141
libs/types/src/planning.ts
Normal file
141
libs/types/src/planning.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Planning Types - Types for spec-driven development and task execution
|
||||
*
|
||||
* These types support the planning/specification workflow in auto-mode:
|
||||
* - PlanningMode: skip, lite, spec, full
|
||||
* - ParsedTask: Individual tasks extracted from specs
|
||||
* - PlanSpec: Specification state and content
|
||||
* - AutoModeEventType: Type-safe event names for auto-mode
|
||||
*/
|
||||
|
||||
import type { PlanningMode } from './settings.js';
|
||||
|
||||
// Re-export PlanningMode for convenience
|
||||
export type { PlanningMode };
|
||||
|
||||
/**
|
||||
* TaskStatus - Status of an individual task within a spec
|
||||
*/
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
|
||||
/**
|
||||
* PlanSpecStatus - Status of a plan/specification document
|
||||
*/
|
||||
export type PlanSpecStatus = 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
||||
|
||||
/**
|
||||
* ParsedTask - A single task extracted from a generated specification
|
||||
*
|
||||
* Tasks are identified by ID (e.g., "T001") and may belong to a phase.
|
||||
* Format in spec: `- [ ] T###: Description | File: path/to/file`
|
||||
*/
|
||||
export interface ParsedTask {
|
||||
/** Task identifier, e.g., "T001", "T002" */
|
||||
id: string;
|
||||
/** Human-readable description of what the task accomplishes */
|
||||
description: string;
|
||||
/** Primary file affected by this task (optional) */
|
||||
filePath?: string;
|
||||
/** Phase this task belongs to, e.g., "Phase 1: Foundation" (for full mode) */
|
||||
phase?: string;
|
||||
/** Current execution status of the task */
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanSpec - Specification/plan state for a feature
|
||||
*
|
||||
* Tracks the generated spec content, approval status, and task progress.
|
||||
* Stored in feature.json as `planSpec` property.
|
||||
*/
|
||||
export interface PlanSpec {
|
||||
/** Current status of the spec */
|
||||
status: PlanSpecStatus;
|
||||
/** The spec/plan content (markdown) */
|
||||
content?: string;
|
||||
/** Version number, incremented on each revision */
|
||||
version: number;
|
||||
/** ISO timestamp when spec was first generated */
|
||||
generatedAt?: string;
|
||||
/** ISO timestamp when spec was approved */
|
||||
approvedAt?: string;
|
||||
/** Whether user has reviewed (approved/rejected) the spec */
|
||||
reviewedByUser: boolean;
|
||||
/** Number of tasks completed during execution */
|
||||
tasksCompleted?: number;
|
||||
/** Total number of tasks parsed from spec */
|
||||
tasksTotal?: number;
|
||||
/** ID of the task currently being executed */
|
||||
currentTaskId?: string;
|
||||
/** All parsed tasks from the spec */
|
||||
tasks?: ParsedTask[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AutoModeEventType - Type-safe event names emitted by auto-mode service
|
||||
*
|
||||
* All events are wrapped as `auto-mode:event` with `type` field containing
|
||||
* one of these values.
|
||||
*/
|
||||
export type AutoModeEventType =
|
||||
// Auto-loop lifecycle events
|
||||
| 'auto_mode_started'
|
||||
| 'auto_mode_stopped'
|
||||
| 'auto_mode_idle'
|
||||
// Feature execution events
|
||||
| 'auto_mode_feature_start'
|
||||
| 'auto_mode_feature_complete'
|
||||
| 'auto_mode_progress'
|
||||
| 'auto_mode_tool'
|
||||
| 'auto_mode_error'
|
||||
// Task execution events (multi-agent)
|
||||
| 'auto_mode_task_started'
|
||||
| 'auto_mode_task_complete'
|
||||
| 'auto_mode_phase_complete'
|
||||
// Planning/spec events
|
||||
| 'planning_started'
|
||||
| 'plan_approval_required'
|
||||
| 'plan_approved'
|
||||
| 'plan_rejected'
|
||||
| 'plan_auto_approved'
|
||||
| 'plan_revision_requested';
|
||||
|
||||
/**
|
||||
* AutoModeEvent - Base event payload structure
|
||||
*/
|
||||
export interface AutoModeEventPayload {
|
||||
/** The specific event type */
|
||||
type: AutoModeEventType;
|
||||
/** Feature ID this event relates to */
|
||||
featureId?: string;
|
||||
/** Project path */
|
||||
projectPath?: string;
|
||||
/** Additional event-specific data */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskProgressPayload - Event payload for task progress events
|
||||
*/
|
||||
export interface TaskProgressPayload {
|
||||
type: 'auto_mode_task_started' | 'auto_mode_task_complete';
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
taskId: string;
|
||||
taskDescription?: string;
|
||||
taskIndex: number;
|
||||
tasksTotal: number;
|
||||
tasksCompleted?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanApprovalPayload - Event payload for plan approval events
|
||||
*/
|
||||
export interface PlanApprovalPayload {
|
||||
type: 'plan_approval_required';
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
planContent: string;
|
||||
planningMode: PlanningMode;
|
||||
planVersion: number;
|
||||
}
|
||||
61
libs/types/src/worktree.ts
Normal file
61
libs/types/src/worktree.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Worktree-related types shared across server and UI
|
||||
*/
|
||||
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorktreeMetadata {
|
||||
branch: string;
|
||||
createdAt: string;
|
||||
pr?: WorktreePRInfo;
|
||||
}
|
||||
|
||||
export interface WorktreeListItem {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean;
|
||||
hasWorktree: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
pr?: WorktreePRInfo;
|
||||
}
|
||||
|
||||
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 interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TrackedBranch {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastActivatedAt?: string;
|
||||
}
|
||||
@@ -54,3 +54,15 @@ export {
|
||||
type ContextFilesResult,
|
||||
type LoadContextFilesOptions,
|
||||
} from './context-loader.js';
|
||||
|
||||
// Stream processing
|
||||
export {
|
||||
processStream,
|
||||
collectStreamText,
|
||||
processStreamWithProgress,
|
||||
hasMarker,
|
||||
extractBeforeMarker,
|
||||
sleep,
|
||||
type StreamHandlers,
|
||||
type StreamResult,
|
||||
} from './stream-processor.js';
|
||||
|
||||
173
libs/utils/src/stream-processor.ts
Normal file
173
libs/utils/src/stream-processor.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Stream Processor - Unified stream handling for provider messages
|
||||
*
|
||||
* Eliminates duplication of the stream processing pattern for handling
|
||||
* async generators from AI providers.
|
||||
*/
|
||||
|
||||
import type { ProviderMessage, ContentBlock } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Callbacks for handling different stream events
|
||||
*/
|
||||
export interface StreamHandlers {
|
||||
/** Called for each text block in the stream */
|
||||
onText?: (text: string) => void | Promise<void>;
|
||||
/** Called for each tool use in the stream */
|
||||
onToolUse?: (name: string, input: unknown) => void | Promise<void>;
|
||||
/** Called when an error occurs in the stream */
|
||||
onError?: (error: string) => void | Promise<void>;
|
||||
/** Called when the stream completes successfully */
|
||||
onComplete?: (result: string) => void | Promise<void>;
|
||||
/** Called for thinking blocks (if present) */
|
||||
onThinking?: (thinking: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from processing a stream
|
||||
*/
|
||||
export interface StreamResult {
|
||||
/** All accumulated text from the stream */
|
||||
text: string;
|
||||
/** Whether the stream completed successfully */
|
||||
success: boolean;
|
||||
/** Error message if stream failed */
|
||||
error?: string;
|
||||
/** Final result message if stream completed */
|
||||
result?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a provider message stream with unified handling
|
||||
*
|
||||
* @param stream - The async generator from provider.executeQuery()
|
||||
* @param handlers - Callbacks for different event types
|
||||
* @returns Accumulated result with text and status
|
||||
*/
|
||||
export async function processStream(
|
||||
stream: AsyncGenerator<ProviderMessage>,
|
||||
handlers: StreamHandlers
|
||||
): Promise<StreamResult> {
|
||||
let accumulatedText = '';
|
||||
let success = true;
|
||||
let errorMessage: string | undefined;
|
||||
let resultMessage: string | undefined;
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
await processContentBlock(block, handlers, (text) => {
|
||||
accumulatedText += text;
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
success = false;
|
||||
errorMessage = msg.error || 'Unknown error';
|
||||
if (handlers.onError) {
|
||||
await handlers.onError(errorMessage);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
resultMessage = msg.result || '';
|
||||
if (handlers.onComplete) {
|
||||
await handlers.onComplete(resultMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!errorMessage) {
|
||||
success = false;
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
text: accumulatedText,
|
||||
success,
|
||||
error: errorMessage,
|
||||
result: resultMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single content block
|
||||
*/
|
||||
async function processContentBlock(
|
||||
block: ContentBlock,
|
||||
handlers: StreamHandlers,
|
||||
appendText: (text: string) => void
|
||||
): Promise<void> {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (block.text) {
|
||||
appendText(block.text);
|
||||
if (handlers.onText) {
|
||||
await handlers.onText(block.text);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_use':
|
||||
if (block.name && handlers.onToolUse) {
|
||||
await handlers.onToolUse(block.name, block.input);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thinking':
|
||||
if (block.thinking && handlers.onThinking) {
|
||||
await handlers.onThinking(block.thinking);
|
||||
}
|
||||
break;
|
||||
|
||||
// tool_result blocks are handled internally by the SDK
|
||||
case 'tool_result':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple stream processor that just collects text
|
||||
*/
|
||||
export async function collectStreamText(stream: AsyncGenerator<ProviderMessage>): Promise<string> {
|
||||
const result = await processStream(stream, {});
|
||||
return result.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process stream with progress callback
|
||||
*/
|
||||
export async function processStreamWithProgress(
|
||||
stream: AsyncGenerator<ProviderMessage>,
|
||||
onProgress: (text: string) => void
|
||||
): Promise<StreamResult> {
|
||||
return processStream(stream, {
|
||||
onText: onProgress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stream result contains a specific marker
|
||||
*/
|
||||
export function hasMarker(result: StreamResult, marker: string): boolean {
|
||||
return result.text.includes(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content before a marker
|
||||
*/
|
||||
export function extractBeforeMarker(text: string, marker: string): string | null {
|
||||
const index = text.indexOf(marker);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
return text.substring(0, index).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility - delay execution for specified milliseconds
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user