refactor: extract auto-mode-service into modular services

Reduce auto-mode-service.ts from 1308 to 516 lines (60% reduction) by
extracting reusable functionality into shared packages and services:

- Add feature prompt builders to @automaker/prompts (buildFeaturePrompt,
  buildFollowUpPrompt, buildContinuationPrompt, extractTitleFromDescription)
- Add planning prompts and task parsing to @automaker/prompts
- Add stream processor utilities to @automaker/utils (sleep, processStream)
- Add git commit utilities to @automaker/git-utils (commitAll, hasUncommittedChanges)
- Create ProjectAnalyzer service for project analysis
- Create FeatureVerificationService for verify/commit operations
- Extend FeatureLoader with updateStatus, updatePlanSpec, getPending methods
- Expand FeatureStatus type to include all used statuses
- Add PlanSpec and ParsedTask types to @automaker/types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-22 23:25:22 +01:00
parent c4a2f2c2a8
commit 79ef8c8510
25 changed files with 3048 additions and 2306 deletions

View File

@@ -0,0 +1,190 @@
/**
* Stream Processor - Unified stream handling for provider messages
*
* Eliminates duplication of the stream processing pattern that was
* repeated 4x in auto-mode-service.ts (main execution, revision,
* task execution, continuation).
*/
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
*
* This eliminates the repeated pattern of:
* ```
* for await (const msg of stream) {
* if (msg.type === 'assistant' && msg.message?.content) {
* for (const block of msg.message.content) {
* if (block.type === 'text') { ... }
* else if (block.type === 'tool_use') { ... }
* }
* } else if (msg.type === 'error') { ... }
* else if (msg.type === 'result') { ... }
* }
* ```
*
* @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
*
* Useful for cases where you just need the final text output
* without any side effects during streaming.
*/
export async function collectStreamText(stream: AsyncGenerator<ProviderMessage>): Promise<string> {
const result = await processStream(stream, {});
return result.text;
}
/**
* Process stream with progress callback
*
* Simplified interface for the common case of just wanting
* text updates during streaming.
*/
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
*
* Useful for detecting spec generation markers like [SPEC_GENERATED]
*/
export function hasMarker(result: StreamResult, marker: string): boolean {
return result.text.includes(marker);
}
/**
* Extract content before a marker
*
* Useful for extracting spec content before [SPEC_GENERATED] 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();
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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';

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

View File

@@ -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,
});
}
}

View File

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

View File

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

View File

@@ -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;
}

View 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();

View File

@@ -4,8 +4,9 @@
*/
import path from 'path';
import type { Feature } from '@automaker/types';
import type { Feature, PlanSpec, FeatureStatus } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
import * as secureFs from '../lib/secure-fs.js';
import {
getFeaturesDir,
@@ -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) =>
['pending', 'ready', 'backlog'].includes(f.status)
);
// Resolve dependencies and order features
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Filter to features whose dependencies are satisfied
return orderedFeatures.filter((feature: Feature) =>
areDependenciesSatisfied(feature, allFeatures)
);
} catch (error) {
logger.error('[FeatureLoader] Failed to get pending features:', error);
return [];
}
}
}

View File

@@ -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');

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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 {

View 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 };
}

View File

@@ -17,3 +17,12 @@ export {
generateDiffsForNonGitDirectory,
getGitRepositoryDiffs,
} from './diff.js';
// Export commit utilities
export {
hasUncommittedChanges,
commitAll,
getHeadHash,
shortHash,
runVerificationChecks,
} from './commit.js';

View 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.`;
}

View File

@@ -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';

View 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;
}

View File

@@ -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';

View File

@@ -81,3 +81,15 @@ 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';

141
libs/types/src/planning.ts Normal file
View 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;
}

View File

@@ -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';

View 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));
}