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