mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
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:
190
apps/server/src/lib/stream-processor.ts
Normal file
190
apps/server/src/lib/stream-processor.ts
Normal 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
163
apps/server/src/services/auto-mode/feature-verification.ts
Normal file
163
apps/server/src/services/auto-mode/feature-verification.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
28
apps/server/src/services/auto-mode/index.ts
Normal file
28
apps/server/src/services/auto-mode/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Auto Mode Services
|
||||
*
|
||||
* Re-exports all auto-mode related services and types.
|
||||
*/
|
||||
|
||||
// Services
|
||||
export { PlanApprovalService } from './plan-approval-service.js';
|
||||
export { TaskExecutor } from './task-executor.js';
|
||||
export { WorktreeManager, worktreeManager } from './worktree-manager.js';
|
||||
export { OutputWriter, createFeatureOutputWriter } from './output-writer.js';
|
||||
export { ProjectAnalyzer } from './project-analyzer.js';
|
||||
export { FeatureVerificationService } from './feature-verification.js';
|
||||
export type { VerificationResult, CommitResult } from './feature-verification.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
RunningFeature,
|
||||
AutoLoopState,
|
||||
AutoModeConfig,
|
||||
PendingApproval,
|
||||
ApprovalResult,
|
||||
FeatureExecutionOptions,
|
||||
RunAgentOptions,
|
||||
FeatureWithPlanning,
|
||||
TaskExecutionContext,
|
||||
TaskProgress,
|
||||
} from './types.js';
|
||||
154
apps/server/src/services/auto-mode/output-writer.ts
Normal file
154
apps/server/src/services/auto-mode/output-writer.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Output Writer - Incremental file writing for agent output
|
||||
*
|
||||
* Handles debounced file writes to avoid excessive I/O during streaming.
|
||||
* Used to persist agent output to agent-output.md in the feature directory.
|
||||
*/
|
||||
|
||||
import * 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);
|
||||
}
|
||||
236
apps/server/src/services/auto-mode/plan-approval-service.ts
Normal file
236
apps/server/src/services/auto-mode/plan-approval-service.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Plan Approval Service - Handles plan/spec approval workflow
|
||||
*
|
||||
* Manages the async approval flow where:
|
||||
* 1. Agent generates a spec with [SPEC_GENERATED] marker
|
||||
* 2. Service emits plan_approval_required event
|
||||
* 3. User reviews and approves/rejects via API
|
||||
* 4. Service resolves the waiting promise to continue execution
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { PlanSpec, PlanningMode } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { PendingApproval, ApprovalResult } from './types.js';
|
||||
|
||||
const logger = createLogger('PlanApprovalService');
|
||||
|
||||
/**
|
||||
* Manages plan approval workflow for spec-driven development
|
||||
*/
|
||||
export class PlanApprovalService {
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
private events: EventEmitter;
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for plan approval from the user
|
||||
*
|
||||
* Returns a promise that resolves when the user approves or rejects
|
||||
* the plan via the API.
|
||||
*
|
||||
* @param featureId - The feature awaiting approval
|
||||
* @param projectPath - The project path
|
||||
* @returns Promise resolving to approval result
|
||||
*/
|
||||
waitForApproval(featureId: string, projectPath: string): Promise<ApprovalResult> {
|
||||
logger.debug(`Registering pending approval for feature ${featureId}`);
|
||||
logger.debug(
|
||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingApprovals.set(featureId, {
|
||||
resolve,
|
||||
reject,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
logger.debug(`Pending approval registered for feature ${featureId}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending plan approval
|
||||
*
|
||||
* Called when the user approves or rejects the plan via API.
|
||||
*
|
||||
* @param featureId - The feature ID
|
||||
* @param approved - Whether the plan was approved
|
||||
* @param editedPlan - Optional edited plan content
|
||||
* @param feedback - Optional user feedback
|
||||
* @returns Result indicating success or error
|
||||
*/
|
||||
resolve(
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
): { success: boolean; error?: string; projectPath?: string } {
|
||||
logger.debug(`resolvePlanApproval called for feature ${featureId}, approved=${approved}`);
|
||||
logger.debug(
|
||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
|
||||
const pending = this.pendingApprovals.get(featureId);
|
||||
|
||||
if (!pending) {
|
||||
logger.warn(`No pending approval found for feature ${featureId}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `No pending approval for feature ${featureId}`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(`Found pending approval for feature ${featureId}, resolving...`);
|
||||
|
||||
// Resolve the promise with all data including feedback
|
||||
pending.resolve({ approved, editedPlan, feedback });
|
||||
this.pendingApprovals.delete(featureId);
|
||||
|
||||
return { success: true, projectPath: pending.projectPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending plan approval
|
||||
*
|
||||
* Called when a feature is stopped while waiting for approval.
|
||||
*
|
||||
* @param featureId - The feature ID to cancel
|
||||
*/
|
||||
cancel(featureId: string): void {
|
||||
logger.debug(`cancelPlanApproval called for feature ${featureId}`);
|
||||
const pending = this.pendingApprovals.get(featureId);
|
||||
|
||||
if (pending) {
|
||||
logger.debug(`Found and cancelling pending approval for feature ${featureId}`);
|
||||
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
|
||||
this.pendingApprovals.delete(featureId);
|
||||
} else {
|
||||
logger.debug(`No pending approval to cancel for feature ${featureId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature has a pending plan approval
|
||||
*
|
||||
* @param featureId - The feature ID to check
|
||||
* @returns True if there's a pending approval
|
||||
*/
|
||||
hasPending(featureId: string): boolean {
|
||||
return this.pendingApprovals.has(featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project path for a pending approval
|
||||
*
|
||||
* Useful for recovery scenarios where we need to know which
|
||||
* project a pending approval belongs to.
|
||||
*
|
||||
* @param featureId - The feature ID
|
||||
* @returns The project path or undefined
|
||||
*/
|
||||
getProjectPath(featureId: string): string | undefined {
|
||||
return this.pendingApprovals.get(featureId)?.projectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending approval feature IDs
|
||||
*
|
||||
* @returns Array of feature IDs with pending approvals
|
||||
*/
|
||||
getAllPending(): string[] {
|
||||
return Array.from(this.pendingApprovals.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a plan-related event
|
||||
*/
|
||||
emitPlanEvent(
|
||||
eventType: string,
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
data: Record<string, unknown> = {}
|
||||
): void {
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: eventType,
|
||||
featureId,
|
||||
projectPath,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan approval required event
|
||||
*/
|
||||
emitApprovalRequired(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
planContent: string,
|
||||
planningMode: PlanningMode,
|
||||
planVersion: number
|
||||
): void {
|
||||
this.emitPlanEvent('plan_approval_required', featureId, projectPath, {
|
||||
planContent,
|
||||
planningMode,
|
||||
planVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan approved event
|
||||
*/
|
||||
emitApproved(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
hasEdits: boolean,
|
||||
planVersion: number
|
||||
): void {
|
||||
this.emitPlanEvent('plan_approved', featureId, projectPath, {
|
||||
hasEdits,
|
||||
planVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan rejected event
|
||||
*/
|
||||
emitRejected(featureId: string, projectPath: string, feedback?: string): void {
|
||||
this.emitPlanEvent('plan_rejected', featureId, projectPath, { feedback });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan auto-approved event
|
||||
*/
|
||||
emitAutoApproved(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
planContent: string,
|
||||
planningMode: PlanningMode
|
||||
): void {
|
||||
this.emitPlanEvent('plan_auto_approved', featureId, projectPath, {
|
||||
planContent,
|
||||
planningMode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan revision requested event
|
||||
*/
|
||||
emitRevisionRequested(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
feedback: string | undefined,
|
||||
hasEdits: boolean,
|
||||
planVersion: number
|
||||
): void {
|
||||
this.emitPlanEvent('plan_revision_requested', featureId, projectPath, {
|
||||
feedback,
|
||||
hasEdits,
|
||||
planVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
111
apps/server/src/services/auto-mode/project-analyzer.ts
Normal file
111
apps/server/src/services/auto-mode/project-analyzer.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
268
apps/server/src/services/auto-mode/task-executor.ts
Normal file
268
apps/server/src/services/auto-mode/task-executor.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
121
apps/server/src/services/auto-mode/types.ts
Normal file
121
apps/server/src/services/auto-mode/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Internal types for AutoModeService
|
||||
*
|
||||
* These types are used internally by the auto-mode services
|
||||
* and are not exported to the public API.
|
||||
*/
|
||||
|
||||
import type { PlanningMode, PlanSpec } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Running feature state
|
||||
*/
|
||||
export interface RunningFeature {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
worktreePath: string | null;
|
||||
branchName: string | null;
|
||||
abortController: AbortController;
|
||||
isAutoMode: boolean;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-loop configuration
|
||||
*/
|
||||
export interface AutoLoopState {
|
||||
projectPath: string;
|
||||
maxConcurrency: number;
|
||||
abortController: AbortController;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-mode configuration
|
||||
*/
|
||||
export interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending plan approval state
|
||||
*/
|
||||
export interface PendingApproval {
|
||||
resolve: (result: ApprovalResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of plan approval
|
||||
*/
|
||||
export interface ApprovalResult {
|
||||
approved: boolean;
|
||||
editedPlan?: string;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a feature
|
||||
*/
|
||||
export interface FeatureExecutionOptions {
|
||||
continuationPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for running the agent
|
||||
*/
|
||||
export interface RunAgentOptions {
|
||||
projectPath: string;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
previousContent?: string;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature with planning fields for internal use
|
||||
*/
|
||||
export interface FeatureWithPlanning {
|
||||
id: string;
|
||||
description: string;
|
||||
spec?: string;
|
||||
model?: string;
|
||||
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string }>;
|
||||
branchName?: string;
|
||||
skipTests?: boolean;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
planSpec?: PlanSpec;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task execution context
|
||||
*/
|
||||
export interface TaskExecutionContext {
|
||||
workDir: string;
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
model: string;
|
||||
maxTurns: number;
|
||||
allowedTools?: string[];
|
||||
abortController: AbortController;
|
||||
planContent: string;
|
||||
userFeedback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task progress event
|
||||
*/
|
||||
export interface TaskProgress {
|
||||
taskId: string;
|
||||
taskIndex: number;
|
||||
tasksTotal: number;
|
||||
status: 'started' | 'completed' | 'failed';
|
||||
output?: string;
|
||||
phaseComplete?: number;
|
||||
}
|
||||
157
apps/server/src/services/auto-mode/worktree-manager.ts
Normal file
157
apps/server/src/services/auto-mode/worktree-manager.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Worktree Manager - Git worktree operations for feature isolation
|
||||
*
|
||||
* Handles finding and resolving git worktrees for feature branches.
|
||||
* Worktrees are created when features are added/edited, this service
|
||||
* finds existing worktrees for execution.
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('WorktreeManager');
|
||||
|
||||
/**
|
||||
* Result of resolving a working directory
|
||||
*/
|
||||
export interface WorkDirResult {
|
||||
/** The resolved working directory path */
|
||||
workDir: string;
|
||||
/** The worktree path if using a worktree, null otherwise */
|
||||
worktreePath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages git worktree operations for feature isolation
|
||||
*/
|
||||
export class WorktreeManager {
|
||||
/**
|
||||
* Find existing worktree path for a branch
|
||||
*
|
||||
* Parses `git worktree list --porcelain` output to find the worktree
|
||||
* associated with a specific branch.
|
||||
*
|
||||
* @param projectPath - The main project path
|
||||
* @param branchName - The branch to find a worktree for
|
||||
* @returns The absolute path to the worktree, or null if not found
|
||||
*/
|
||||
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
const lines = stdout.split('\n');
|
||||
let currentPath: string | null = null;
|
||||
let currentBranch: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||
} else if (line === '' && currentPath && currentBranch) {
|
||||
// End of a worktree entry
|
||||
if (currentBranch === branchName) {
|
||||
// Resolve to absolute path - git may return relative paths
|
||||
// On Windows, this is critical for cwd to work correctly
|
||||
const resolvedPath = path.isAbsolute(currentPath)
|
||||
? path.resolve(currentPath)
|
||||
: path.resolve(projectPath, currentPath);
|
||||
return resolvedPath;
|
||||
}
|
||||
currentPath = null;
|
||||
currentBranch = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the last entry (if file doesn't end with newline)
|
||||
if (currentPath && currentBranch && currentBranch === branchName) {
|
||||
const resolvedPath = path.isAbsolute(currentPath)
|
||||
? path.resolve(currentPath)
|
||||
: path.resolve(projectPath, currentPath);
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to find worktree for branch ${branchName}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the working directory for feature execution
|
||||
*
|
||||
* If worktrees are enabled and a branch name is provided, attempts to
|
||||
* find an existing worktree. Falls back to the project path if no
|
||||
* worktree is found.
|
||||
*
|
||||
* @param projectPath - The main project path
|
||||
* @param branchName - Optional branch name to look for
|
||||
* @param useWorktrees - Whether to use worktrees
|
||||
* @returns The resolved work directory and worktree path
|
||||
*/
|
||||
async resolveWorkDir(
|
||||
projectPath: string,
|
||||
branchName: string | undefined,
|
||||
useWorktrees: boolean
|
||||
): Promise<WorkDirResult> {
|
||||
let worktreePath: string | null = null;
|
||||
|
||||
if (useWorktrees && branchName) {
|
||||
worktreePath = await this.findWorktreeForBranch(projectPath, branchName);
|
||||
|
||||
if (worktreePath) {
|
||||
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||
} else {
|
||||
logger.warn(`Worktree for branch "${branchName}" not found, using project path`);
|
||||
}
|
||||
}
|
||||
|
||||
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
||||
|
||||
return { workDir, worktreePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a valid worktree
|
||||
*
|
||||
* @param worktreePath - Path to check
|
||||
* @returns True if the path is a valid git worktree
|
||||
*/
|
||||
async isValidWorktree(worktreePath: string): Promise<boolean> {
|
||||
try {
|
||||
// Check if .git file exists (worktrees have a .git file, not directory)
|
||||
const { stdout } = await execAsync('git rev-parse --is-inside-work-tree', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the branch name for a worktree
|
||||
*
|
||||
* @param worktreePath - Path to the worktree
|
||||
* @returns The branch name or null if not a valid worktree
|
||||
*/
|
||||
async getWorktreeBranch(worktreePath: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance for convenience
|
||||
export const worktreeManager = new WorktreeManager();
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import {
|
||||
getPlanningPromptPrefix,
|
||||
parseTasksFromSpec,
|
||||
parseTaskLine,
|
||||
buildFeaturePrompt,
|
||||
extractTitleFromDescription,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
let service: AutoModeService;
|
||||
@@ -18,54 +25,28 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
await service.stopAutoLoop().catch(() => {});
|
||||
});
|
||||
|
||||
describe('getPlanningPromptPrefix', () => {
|
||||
// Access private method through any cast for testing
|
||||
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
describe('getPlanningPromptPrefix (from @automaker/prompts)', () => {
|
||||
it('should return empty string for skip mode', () => {
|
||||
const feature = { id: 'test', planningMode: 'skip' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when planningMode is undefined', () => {
|
||||
const feature = { id: 'test' };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('skip');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return lite prompt for lite mode without approval', () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('lite', false);
|
||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||
expect(result).toContain('[PLAN_GENERATED]');
|
||||
expect(result).toContain('Feature Request');
|
||||
});
|
||||
|
||||
it('should return lite_with_approval prompt for lite mode with approval', () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: true,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('lite', true);
|
||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||
expect(result).toContain('[SPEC_GENERATED]');
|
||||
expect(result).toContain('DO NOT proceed with implementation');
|
||||
});
|
||||
|
||||
it('should return spec prompt for spec mode', () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('spec');
|
||||
expect(result).toContain('Specification Phase (Spec Mode)');
|
||||
expect(result).toContain('```tasks');
|
||||
expect(result).toContain('T001');
|
||||
@@ -74,11 +55,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
|
||||
it('should return full prompt for full mode', () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'full' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('full');
|
||||
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
|
||||
expect(result).toContain('Phase 1: Foundation');
|
||||
expect(result).toContain('Phase 2: Core Implementation');
|
||||
@@ -86,11 +63,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
|
||||
it('should include the separator and Feature Request header', () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('spec');
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('## Feature Request');
|
||||
});
|
||||
@@ -98,8 +71,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
it('should instruct agent to NOT output exploration text', () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix(mode);
|
||||
expect(result).toContain('Do NOT output exploration text');
|
||||
expect(result).toContain('Start DIRECTLY');
|
||||
}
|
||||
@@ -198,17 +170,14 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFeaturePrompt', () => {
|
||||
const buildFeaturePrompt = (svc: any, feature: any) => {
|
||||
return svc.buildFeaturePrompt(feature);
|
||||
};
|
||||
|
||||
describe('buildFeaturePrompt (from @automaker/prompts)', () => {
|
||||
it('should include feature ID and description', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
category: 'Test',
|
||||
description: 'Add user authentication',
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
const result = buildFeaturePrompt(feature);
|
||||
expect(result).toContain('feat-123');
|
||||
expect(result).toContain('Add user authentication');
|
||||
});
|
||||
@@ -216,10 +185,11 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
it('should include specification when present', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
category: 'Test',
|
||||
description: 'Test feature',
|
||||
spec: 'Detailed specification here',
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
const result = buildFeaturePrompt(feature);
|
||||
expect(result).toContain('Specification:');
|
||||
expect(result).toContain('Detailed specification here');
|
||||
});
|
||||
@@ -227,13 +197,14 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
it('should include image paths when present', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
category: 'Test',
|
||||
description: 'Test feature',
|
||||
imagePaths: [
|
||||
{ path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' },
|
||||
'/tmp/image2.jpg',
|
||||
],
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
const result = buildFeaturePrompt(feature);
|
||||
expect(result).toContain('Context Images Attached');
|
||||
expect(result).toContain('image1.png');
|
||||
expect(result).toContain('/tmp/image2.jpg');
|
||||
@@ -242,55 +213,46 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
it('should include summary tags instruction', () => {
|
||||
const feature = {
|
||||
id: 'feat-123',
|
||||
category: 'Test',
|
||||
description: 'Test feature',
|
||||
};
|
||||
const result = buildFeaturePrompt(service, feature);
|
||||
const result = buildFeaturePrompt(feature);
|
||||
expect(result).toContain('<summary>');
|
||||
expect(result).toContain('</summary>');
|
||||
expect(result).toContain('summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTitleFromDescription', () => {
|
||||
const extractTitle = (svc: any, description: string) => {
|
||||
return svc.extractTitleFromDescription(description);
|
||||
};
|
||||
|
||||
describe('extractTitleFromDescription (from @automaker/prompts)', () => {
|
||||
it("should return 'Untitled Feature' for empty description", () => {
|
||||
expect(extractTitle(service, '')).toBe('Untitled Feature');
|
||||
expect(extractTitle(service, ' ')).toBe('Untitled Feature');
|
||||
expect(extractTitleFromDescription('')).toBe('Untitled Feature');
|
||||
expect(extractTitleFromDescription(' ')).toBe('Untitled Feature');
|
||||
});
|
||||
|
||||
it('should return first line if under 60 characters', () => {
|
||||
const description = 'Add user login\nWith email validation';
|
||||
expect(extractTitle(service, description)).toBe('Add user login');
|
||||
expect(extractTitleFromDescription(description)).toBe('Add user login');
|
||||
});
|
||||
|
||||
it('should truncate long first lines to 60 characters', () => {
|
||||
const description =
|
||||
'This is a very long feature description that exceeds the sixty character limit significantly';
|
||||
const result = extractTitle(service, description);
|
||||
const result = extractTitleFromDescription(description);
|
||||
expect(result.length).toBe(60);
|
||||
expect(result).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLANNING_PROMPTS structure', () => {
|
||||
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
describe('PLANNING_PROMPTS structure (from @automaker/prompts)', () => {
|
||||
it('should have all required planning modes', () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix(mode);
|
||||
expect(result.length).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('lite prompt should include correct structure', () => {
|
||||
const feature = { id: 'test', planningMode: 'lite' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('lite');
|
||||
expect(result).toContain('Goal');
|
||||
expect(result).toContain('Approach');
|
||||
expect(result).toContain('Files to Touch');
|
||||
@@ -299,8 +261,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
|
||||
it('spec prompt should include task format instructions', () => {
|
||||
const feature = { id: 'test', planningMode: 'spec' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('spec');
|
||||
expect(result).toContain('Problem');
|
||||
expect(result).toContain('Solution');
|
||||
expect(result).toContain('Acceptance Criteria');
|
||||
@@ -310,8 +271,7 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
});
|
||||
|
||||
it('full prompt should include phases', () => {
|
||||
const feature = { id: 'test', planningMode: 'full' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = getPlanningPromptPrefix('full');
|
||||
expect(result).toContain('Problem Statement');
|
||||
expect(result).toContain('User Story');
|
||||
expect(result).toContain('Technical Context');
|
||||
|
||||
@@ -1,92 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Test the task parsing logic by reimplementing the parsing functions
|
||||
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
|
||||
*/
|
||||
|
||||
interface ParsedTask {
|
||||
id: string;
|
||||
description: string;
|
||||
filePath?: string;
|
||||
phase?: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||
// Match pattern: - [ ] T###: Description | File: path
|
||||
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
||||
if (!taskMatch) {
|
||||
// Try simpler pattern without file
|
||||
const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
id: simpleMatch[1],
|
||||
description: simpleMatch[2].trim(),
|
||||
phase: currentPhase,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskMatch[1],
|
||||
description: taskMatch[2].trim(),
|
||||
filePath: taskMatch[3]?.trim(),
|
||||
phase: currentPhase,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
function parseTasksFromSpec(specContent: string): ParsedTask[] {
|
||||
const tasks: ParsedTask[] = [];
|
||||
|
||||
// Extract content within ```tasks ... ``` block
|
||||
const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
|
||||
if (!tasksBlockMatch) {
|
||||
// Try fallback: look for task lines anywhere in content
|
||||
const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
|
||||
if (!taskLines) {
|
||||
return tasks;
|
||||
}
|
||||
// Parse fallback task lines
|
||||
let currentPhase: string | undefined;
|
||||
for (const line of taskLines) {
|
||||
const parsed = parseTaskLine(line, currentPhase);
|
||||
if (parsed) {
|
||||
tasks.push(parsed);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
const tasksContent = tasksBlockMatch[1];
|
||||
const lines = tasksContent.split('\n');
|
||||
|
||||
let currentPhase: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Check for phase header (e.g., "## Phase 1: Foundation")
|
||||
const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
|
||||
if (phaseMatch) {
|
||||
currentPhase = phaseMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for task line
|
||||
if (trimmedLine.startsWith('- [ ]')) {
|
||||
const parsed = parseTaskLine(trimmedLine, currentPhase);
|
||||
if (parsed) {
|
||||
tasks.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
import { parseTaskLine, parseTasksFromSpec } from '@automaker/prompts';
|
||||
|
||||
describe('Task Parsing', () => {
|
||||
describe('parseTaskLine', () => {
|
||||
|
||||
@@ -16,12 +16,10 @@ import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PlanSpec } from '@/store/app-store';
|
||||
|
||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
import type { PlanningMode, PlanSpec, ParsedTask } from '@automaker/types';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ParsedTask, PlanSpec } from '@/store/app-store';
|
||||
export type { PlanningMode, ParsedTask, PlanSpec };
|
||||
|
||||
interface PlanningModeSelectorProps {
|
||||
mode: PlanningMode;
|
||||
|
||||
@@ -4,11 +4,25 @@ import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import type {
|
||||
Feature as BaseFeature,
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath,
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
ParsedTask,
|
||||
PlanSpec,
|
||||
ThinkingLevel,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Re-export types from @automaker/types for backwards compatibility
|
||||
export type {
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
ThinkingLevel,
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath,
|
||||
};
|
||||
|
||||
// Re-export ThemeMode for convenience
|
||||
export type { ThemeMode };
|
||||
|
||||
@@ -269,28 +283,8 @@ export interface Feature extends Omit<
|
||||
prUrl?: string; // UI-specific: Pull request URL
|
||||
}
|
||||
|
||||
// Parsed task from spec (for spec and full planning modes)
|
||||
export interface ParsedTask {
|
||||
id: string; // e.g., "T001"
|
||||
description: string; // e.g., "Create user model"
|
||||
filePath?: string; // e.g., "src/models/user.ts"
|
||||
phase?: string; // e.g., "Phase 1: Foundation" (for full mode)
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
// PlanSpec status for feature planning/specification
|
||||
export interface PlanSpec {
|
||||
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
||||
content?: string; // The actual spec/plan markdown content
|
||||
version: number;
|
||||
generatedAt?: string; // ISO timestamp
|
||||
approvedAt?: string; // ISO timestamp
|
||||
reviewedByUser: boolean; // True if user has seen the spec
|
||||
tasksCompleted?: number;
|
||||
tasksTotal?: number;
|
||||
currentTaskId?: string; // ID of the task currently being worked on
|
||||
tasks?: ParsedTask[]; // Parsed tasks from the spec
|
||||
}
|
||||
// Re-export planning types for backwards compatibility with existing imports
|
||||
export type { ParsedTask, PlanSpec };
|
||||
|
||||
// File tree node for project analysis
|
||||
export interface FileTreeNode {
|
||||
|
||||
112
libs/git-utils/src/commit.ts
Normal file
112
libs/git-utils/src/commit.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Git Commit Utilities - Commit operations for git repositories
|
||||
*
|
||||
* Provides utilities for staging and committing changes.
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Check if there are uncommitted changes in the working directory
|
||||
*
|
||||
* @param workDir - The working directory to check
|
||||
* @returns True if there are uncommitted changes
|
||||
*/
|
||||
export async function hasUncommittedChanges(workDir: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd: workDir });
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage all changes and commit with a message
|
||||
*
|
||||
* @param workDir - The working directory
|
||||
* @param message - The commit message
|
||||
* @returns The commit hash if successful, null otherwise
|
||||
*/
|
||||
export async function commitAll(workDir: string, message: string): Promise<string | null> {
|
||||
try {
|
||||
// Check for changes first
|
||||
const hasChanges = await hasUncommittedChanges(workDir);
|
||||
if (!hasChanges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
await execAsync('git add -A', { cwd: workDir });
|
||||
|
||||
// Commit with message (escape double quotes)
|
||||
const escapedMessage = message.replace(/"/g, '\\"');
|
||||
await execAsync(`git commit -m "${escapedMessage}"`, { cwd: workDir });
|
||||
|
||||
// Get the commit hash
|
||||
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: workDir });
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current HEAD commit hash
|
||||
*
|
||||
* @param workDir - The working directory
|
||||
* @returns The commit hash or null if not a git repo
|
||||
*/
|
||||
export async function getHeadHash(workDir: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: workDir });
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the short version of a commit hash
|
||||
*
|
||||
* @param hash - The full commit hash
|
||||
* @param length - Length of short hash (default 8)
|
||||
* @returns The shortened hash
|
||||
*/
|
||||
export function shortHash(hash: string, length = 8): string {
|
||||
return hash.substring(0, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run verification commands (lint, typecheck, test, build)
|
||||
*
|
||||
* @param workDir - The working directory
|
||||
* @param checks - Optional custom checks (defaults to npm scripts)
|
||||
* @returns Object with success status and failed check name if any
|
||||
*/
|
||||
export async function runVerificationChecks(
|
||||
workDir: string,
|
||||
checks?: Array<{ cmd: string; name: string }>
|
||||
): Promise<{ success: boolean; failedCheck?: string }> {
|
||||
const defaultChecks = [
|
||||
{ cmd: 'npm run lint', name: 'Lint' },
|
||||
{ cmd: 'npm run typecheck', name: 'Type check' },
|
||||
{ cmd: 'npm test', name: 'Tests' },
|
||||
{ cmd: 'npm run build', name: 'Build' },
|
||||
];
|
||||
|
||||
const checksToRun = checks || defaultChecks;
|
||||
|
||||
for (const check of checksToRun) {
|
||||
try {
|
||||
await execAsync(check.cmd, { cwd: workDir, timeout: 120000 });
|
||||
} catch {
|
||||
return { success: false, failedCheck: check.name };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -17,3 +17,12 @@ export {
|
||||
generateDiffsForNonGitDirectory,
|
||||
getGitRepositoryDiffs,
|
||||
} from './diff.js';
|
||||
|
||||
// Export commit utilities
|
||||
export {
|
||||
hasUncommittedChanges,
|
||||
commitAll,
|
||||
getHeadHash,
|
||||
shortHash,
|
||||
runVerificationChecks,
|
||||
} from './commit.js';
|
||||
|
||||
138
libs/prompts/src/feature-prompt.ts
Normal file
138
libs/prompts/src/feature-prompt.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Feature Prompt - Prompt building for feature implementation
|
||||
*
|
||||
* Contains utilities for building prompts from Feature objects.
|
||||
*/
|
||||
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Extract a title from feature description
|
||||
*
|
||||
* Takes the first line of the description and truncates if needed.
|
||||
*
|
||||
* @param description - The feature description
|
||||
* @returns A title string (max 60 chars)
|
||||
*/
|
||||
export function extractTitleFromDescription(description: string): string {
|
||||
if (!description?.trim()) {
|
||||
return 'Untitled Feature';
|
||||
}
|
||||
|
||||
const firstLine = description.split('\n')[0].trim();
|
||||
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a feature implementation prompt
|
||||
*
|
||||
* Creates a structured prompt for the AI agent to implement a feature.
|
||||
*
|
||||
* @param feature - The feature to build a prompt for
|
||||
* @returns The formatted prompt string
|
||||
*/
|
||||
export function buildFeaturePrompt(feature: Feature): string {
|
||||
const title = extractTitleFromDescription(feature.description);
|
||||
|
||||
let prompt = `## Feature Implementation Task
|
||||
|
||||
**Feature ID:** ${feature.id}
|
||||
**Title:** ${title}
|
||||
**Description:** ${feature.description}
|
||||
`;
|
||||
|
||||
if (feature.spec) {
|
||||
prompt += `\n**Specification:**\n${feature.spec}\n`;
|
||||
}
|
||||
|
||||
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
||||
const imagesList = feature.imagePaths
|
||||
.map((img, idx) => {
|
||||
const imgPath = typeof img === 'string' ? img : img.path;
|
||||
const filename =
|
||||
typeof img === 'string'
|
||||
? imgPath.split('/').pop()
|
||||
: (img as { filename?: string }).filename || imgPath.split('/').pop();
|
||||
return ` ${idx + 1}. ${filename}\n Path: ${imgPath}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
prompt += `\n**Context Images Attached:**\n${imagesList}\n`;
|
||||
}
|
||||
|
||||
if (feature.skipTests) {
|
||||
prompt += `
|
||||
## Instructions
|
||||
|
||||
Implement this feature by:
|
||||
1. Explore the codebase to understand the existing structure
|
||||
2. Plan your implementation approach
|
||||
3. Write the necessary code changes
|
||||
4. Ensure the code follows existing patterns
|
||||
|
||||
When done, wrap your final summary in <summary> tags.`;
|
||||
} else {
|
||||
prompt += `
|
||||
## Instructions
|
||||
|
||||
Implement and verify this feature:
|
||||
1. Explore the codebase
|
||||
2. Plan your approach
|
||||
3. Write the code changes
|
||||
4. Verify with Playwright tests
|
||||
|
||||
When done, wrap your final summary in <summary> tags.`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a follow-up prompt for continuing work on a feature
|
||||
*
|
||||
* @param feature - The feature being followed up on
|
||||
* @param previousContext - Previous agent work context
|
||||
* @param followUpInstructions - New instructions from user
|
||||
* @returns The formatted follow-up prompt
|
||||
*/
|
||||
export function buildFollowUpPrompt(
|
||||
feature: Feature | null,
|
||||
featureId: string,
|
||||
previousContext: string,
|
||||
followUpInstructions: string
|
||||
): string {
|
||||
let prompt = `## Follow-up on Feature Implementation\n\n`;
|
||||
|
||||
if (feature) {
|
||||
prompt += buildFeaturePrompt(feature) + '\n';
|
||||
} else {
|
||||
prompt += `**Feature ID:** ${featureId}\n`;
|
||||
}
|
||||
|
||||
if (previousContext) {
|
||||
prompt += `\n## Previous Agent Work\n${previousContext}\n`;
|
||||
}
|
||||
|
||||
prompt += `\n## Follow-up Instructions\n${followUpInstructions}\n\n## Task\nAddress the follow-up instructions above.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a continuation prompt for resuming work
|
||||
*
|
||||
* @param feature - The feature to continue
|
||||
* @param context - Previous work context
|
||||
* @returns The continuation prompt
|
||||
*/
|
||||
export function buildContinuationPrompt(feature: Feature, context: string): string {
|
||||
return `## Continuing Feature Implementation
|
||||
|
||||
${buildFeaturePrompt(feature)}
|
||||
|
||||
## Previous Context
|
||||
${context}
|
||||
|
||||
## Instructions
|
||||
Review the previous work and continue the implementation.`;
|
||||
}
|
||||
@@ -21,5 +21,31 @@ export {
|
||||
getAvailableEnhancementModes,
|
||||
} from './enhancement.js';
|
||||
|
||||
// Planning prompts (spec-driven development)
|
||||
export {
|
||||
PLANNING_PROMPTS,
|
||||
getPlanningPrompt,
|
||||
getPlanningPromptPrefix,
|
||||
parseTasksFromSpec,
|
||||
parseTaskLine,
|
||||
buildTaskPrompt,
|
||||
isSpecGeneratingMode,
|
||||
canRequireApproval,
|
||||
getPlanningModeDisplayName,
|
||||
} from './planning.js';
|
||||
|
||||
// Feature prompts (implementation)
|
||||
export {
|
||||
buildFeaturePrompt,
|
||||
buildFollowUpPrompt,
|
||||
buildContinuationPrompt,
|
||||
extractTitleFromDescription,
|
||||
} from './feature-prompt.js';
|
||||
|
||||
// Re-export types from @automaker/types
|
||||
export type { EnhancementMode, EnhancementExample } from '@automaker/types';
|
||||
export type {
|
||||
EnhancementMode,
|
||||
EnhancementExample,
|
||||
PlanningMode,
|
||||
ParsedTask,
|
||||
} from '@automaker/types';
|
||||
|
||||
411
libs/prompts/src/planning.ts
Normal file
411
libs/prompts/src/planning.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Planning Prompts - AI prompt templates for spec-driven development
|
||||
*
|
||||
* Contains planning mode prompts, task parsing utilities, and prompt builders
|
||||
* for the multi-agent task execution workflow.
|
||||
*/
|
||||
|
||||
import type { PlanningMode, ParsedTask } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Planning mode prompt templates
|
||||
*
|
||||
* Each mode has a specific prompt format that instructs the AI to generate
|
||||
* a planning document with task breakdowns in a parseable format.
|
||||
*/
|
||||
export const PLANNING_PROMPTS = {
|
||||
lite: `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[PLAN_GENERATED] Planning outline complete."
|
||||
|
||||
Then proceed with implementation.`,
|
||||
|
||||
lite_with_approval: `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.`,
|
||||
|
||||
spec: `## Specification Phase (Spec Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a specification with an actionable task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem**: What problem are we solving? (user perspective)
|
||||
|
||||
2. **Solution**: Brief approach (1-2 sentences)
|
||||
|
||||
3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format
|
||||
- GIVEN [context], WHEN [action], THEN [outcome]
|
||||
|
||||
4. **Files to Modify**:
|
||||
| File | Purpose | Action |
|
||||
|------|---------|--------|
|
||||
| path/to/file | description | create/modify/delete |
|
||||
|
||||
5. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential: T001, T002, T003, etc.
|
||||
- Description: Clear action (e.g., "Create user model", "Add API endpoint")
|
||||
- File: Primary file affected (helps with context)
|
||||
- Order by dependencies (foundational tasks first)
|
||||
|
||||
6. **Verification**: How to confirm feature works
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY in order. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
This allows real-time progress tracking during implementation.`,
|
||||
|
||||
full: `## Full Specification Phase (Full SDD Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem Statement**: 2-3 sentences from user perspective
|
||||
|
||||
2. **User Story**: As a [user], I want [goal], so that [benefit]
|
||||
|
||||
3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN
|
||||
- **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome]
|
||||
- **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling]
|
||||
- **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response]
|
||||
|
||||
4. **Technical Context**:
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Affected Files | list of files |
|
||||
| Dependencies | external libs if any |
|
||||
| Constraints | technical limitations |
|
||||
| Patterns to Follow | existing patterns in codebase |
|
||||
|
||||
5. **Non-Goals**: What this feature explicitly does NOT include
|
||||
|
||||
6. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
## Phase 1: Foundation
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 2: Core Implementation
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
- [ ] T004: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 3: Integration & Testing
|
||||
- [ ] T005: [Description] | File: [path/to/file]
|
||||
- [ ] T006: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential across all phases: T001, T002, T003, etc.
|
||||
- Description: Clear action verb + target
|
||||
- File: Primary file affected
|
||||
- Order by dependencies within each phase
|
||||
- Phase structure helps organize complex work
|
||||
|
||||
7. **Success Metrics**: How we know it's done (measurable criteria)
|
||||
|
||||
8. **Risks & Mitigations**:
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| description | approach |
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY by phase. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
After completing all tasks in a phase, output:
|
||||
"[PHASE_COMPLETE] Phase N complete"
|
||||
|
||||
This allows real-time progress tracking during implementation.`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get the planning prompt for a given mode
|
||||
*
|
||||
* @param mode - The planning mode (skip, lite, spec, full)
|
||||
* @param requireApproval - Whether to use approval variant for lite mode
|
||||
* @returns The prompt string, or empty string for 'skip' mode
|
||||
*/
|
||||
export function getPlanningPrompt(mode: PlanningMode, requireApproval?: boolean): string {
|
||||
if (mode === 'skip') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// For lite mode, use approval variant if required
|
||||
if (mode === 'lite' && requireApproval) {
|
||||
return PLANNING_PROMPTS.lite_with_approval;
|
||||
}
|
||||
|
||||
return PLANNING_PROMPTS[mode] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the planning prompt prefix for a feature prompt
|
||||
*
|
||||
* Used to prepend planning instructions before the feature description.
|
||||
*
|
||||
* @param mode - The planning mode
|
||||
* @param requireApproval - Whether approval is required
|
||||
* @returns Formatted prompt prefix with separator, or empty string
|
||||
*/
|
||||
export function getPlanningPromptPrefix(mode: PlanningMode, requireApproval?: boolean): string {
|
||||
const prompt = getPlanningPrompt(mode, requireApproval);
|
||||
if (!prompt) {
|
||||
return '';
|
||||
}
|
||||
return prompt + '\n\n---\n\n## Feature Request\n\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tasks from generated spec content
|
||||
*
|
||||
* Looks for the ```tasks code block and extracts task lines.
|
||||
* Falls back to finding task lines anywhere in content if no block found.
|
||||
*
|
||||
* @param specContent - The full spec content string
|
||||
* @returns Array of parsed tasks
|
||||
*/
|
||||
export function parseTasksFromSpec(specContent: string): ParsedTask[] {
|
||||
const tasks: ParsedTask[] = [];
|
||||
|
||||
// Extract content within ```tasks ... ``` block
|
||||
const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
|
||||
if (!tasksBlockMatch) {
|
||||
// Try fallback: look for task lines anywhere in content
|
||||
const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
|
||||
if (!taskLines) {
|
||||
return tasks;
|
||||
}
|
||||
// Parse fallback task lines
|
||||
let currentPhase: string | undefined;
|
||||
for (const line of taskLines) {
|
||||
const parsed = parseTaskLine(line, currentPhase);
|
||||
if (parsed) {
|
||||
tasks.push(parsed);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
const tasksContent = tasksBlockMatch[1];
|
||||
const lines = tasksContent.split('\n');
|
||||
|
||||
let currentPhase: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Check for phase header (e.g., "## Phase 1: Foundation")
|
||||
const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
|
||||
if (phaseMatch) {
|
||||
currentPhase = phaseMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for task line
|
||||
if (trimmedLine.startsWith('- [ ]')) {
|
||||
const parsed = parseTaskLine(trimmedLine, currentPhase);
|
||||
if (parsed) {
|
||||
tasks.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single task line
|
||||
*
|
||||
* Format: - [ ] T###: Description | File: path/to/file
|
||||
*
|
||||
* @param line - The task line to parse
|
||||
* @param currentPhase - Optional phase context
|
||||
* @returns Parsed task or null if line doesn't match format
|
||||
*/
|
||||
export function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||
// Match pattern: - [ ] T###: Description | File: path
|
||||
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
||||
if (!taskMatch) {
|
||||
// Try simpler pattern without file
|
||||
const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
id: simpleMatch[1],
|
||||
description: simpleMatch[2].trim(),
|
||||
phase: currentPhase,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskMatch[1],
|
||||
description: taskMatch[2].trim(),
|
||||
filePath: taskMatch[3]?.trim(),
|
||||
phase: currentPhase,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a focused prompt for executing a single task
|
||||
*
|
||||
* Creates a prompt that shows the current task, completed tasks,
|
||||
* and remaining tasks to give the agent context while keeping focus.
|
||||
*
|
||||
* @param task - The current task to execute
|
||||
* @param allTasks - All tasks in the spec
|
||||
* @param taskIndex - Index of current task in allTasks
|
||||
* @param planContent - The full approved plan content
|
||||
* @param userFeedback - Optional user feedback to incorporate
|
||||
* @returns Formatted prompt for task execution
|
||||
*/
|
||||
export function buildTaskPrompt(
|
||||
task: ParsedTask,
|
||||
allTasks: ParsedTask[],
|
||||
taskIndex: number,
|
||||
planContent: string,
|
||||
userFeedback?: string
|
||||
): string {
|
||||
const completedTasks = allTasks.slice(0, taskIndex);
|
||||
const remainingTasks = allTasks.slice(taskIndex + 1);
|
||||
|
||||
let prompt = `# Task Execution: ${task.id}
|
||||
|
||||
You are executing a specific task as part of a larger feature implementation.
|
||||
|
||||
## Your Current Task
|
||||
|
||||
**Task ID:** ${task.id}
|
||||
**Description:** ${task.description}
|
||||
${task.filePath ? `**Primary File:** ${task.filePath}` : ''}
|
||||
${task.phase ? `**Phase:** ${task.phase}` : ''}
|
||||
|
||||
## Context
|
||||
|
||||
`;
|
||||
|
||||
// Show what's already done
|
||||
if (completedTasks.length > 0) {
|
||||
prompt += `### Already Completed (${completedTasks.length} tasks)
|
||||
${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Show remaining tasks
|
||||
if (remainingTasks.length > 0) {
|
||||
prompt += `### Coming Up Next (${remainingTasks.length} tasks remaining)
|
||||
${remainingTasks
|
||||
.slice(0, 3)
|
||||
.map((t) => `- [ ] ${t.id}: ${t.description}`)
|
||||
.join('\n')}
|
||||
${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ''}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Add user feedback if any
|
||||
if (userFeedback) {
|
||||
prompt += `### User Feedback
|
||||
${userFeedback}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Add relevant excerpt from plan (just the task-related part to save context)
|
||||
prompt += `### Reference: Full Plan
|
||||
<details>
|
||||
${planContent}
|
||||
</details>
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Focus ONLY on completing task ${task.id}: "${task.description}"
|
||||
2. Do not work on other tasks
|
||||
3. Use the existing codebase patterns
|
||||
4. When done, summarize what you implemented
|
||||
|
||||
Begin implementing task ${task.id} now.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a planning mode requires spec generation
|
||||
*/
|
||||
export function isSpecGeneratingMode(mode: PlanningMode): boolean {
|
||||
return mode === 'spec' || mode === 'full' || mode === 'lite';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a planning mode can require approval
|
||||
*/
|
||||
export function canRequireApproval(mode: PlanningMode): boolean {
|
||||
return mode !== 'skip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a planning mode
|
||||
*/
|
||||
export function getPlanningModeDisplayName(mode: PlanningMode): string {
|
||||
const names: Record<PlanningMode, string> = {
|
||||
skip: 'Skip Planning',
|
||||
lite: 'Lite Planning',
|
||||
spec: 'Specification',
|
||||
full: 'Full SDD',
|
||||
};
|
||||
return names[mode] || mode;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
141
libs/types/src/planning.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Planning Types - Types for spec-driven development and task execution
|
||||
*
|
||||
* These types support the planning/specification workflow in auto-mode:
|
||||
* - PlanningMode: skip, lite, spec, full
|
||||
* - ParsedTask: Individual tasks extracted from specs
|
||||
* - PlanSpec: Specification state and content
|
||||
* - AutoModeEventType: Type-safe event names for auto-mode
|
||||
*/
|
||||
|
||||
import type { PlanningMode } from './settings.js';
|
||||
|
||||
// Re-export PlanningMode for convenience
|
||||
export type { PlanningMode };
|
||||
|
||||
/**
|
||||
* TaskStatus - Status of an individual task within a spec
|
||||
*/
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
|
||||
/**
|
||||
* PlanSpecStatus - Status of a plan/specification document
|
||||
*/
|
||||
export type PlanSpecStatus = 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
||||
|
||||
/**
|
||||
* ParsedTask - A single task extracted from a generated specification
|
||||
*
|
||||
* Tasks are identified by ID (e.g., "T001") and may belong to a phase.
|
||||
* Format in spec: `- [ ] T###: Description | File: path/to/file`
|
||||
*/
|
||||
export interface ParsedTask {
|
||||
/** Task identifier, e.g., "T001", "T002" */
|
||||
id: string;
|
||||
/** Human-readable description of what the task accomplishes */
|
||||
description: string;
|
||||
/** Primary file affected by this task (optional) */
|
||||
filePath?: string;
|
||||
/** Phase this task belongs to, e.g., "Phase 1: Foundation" (for full mode) */
|
||||
phase?: string;
|
||||
/** Current execution status of the task */
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanSpec - Specification/plan state for a feature
|
||||
*
|
||||
* Tracks the generated spec content, approval status, and task progress.
|
||||
* Stored in feature.json as `planSpec` property.
|
||||
*/
|
||||
export interface PlanSpec {
|
||||
/** Current status of the spec */
|
||||
status: PlanSpecStatus;
|
||||
/** The spec/plan content (markdown) */
|
||||
content?: string;
|
||||
/** Version number, incremented on each revision */
|
||||
version: number;
|
||||
/** ISO timestamp when spec was first generated */
|
||||
generatedAt?: string;
|
||||
/** ISO timestamp when spec was approved */
|
||||
approvedAt?: string;
|
||||
/** Whether user has reviewed (approved/rejected) the spec */
|
||||
reviewedByUser: boolean;
|
||||
/** Number of tasks completed during execution */
|
||||
tasksCompleted?: number;
|
||||
/** Total number of tasks parsed from spec */
|
||||
tasksTotal?: number;
|
||||
/** ID of the task currently being executed */
|
||||
currentTaskId?: string;
|
||||
/** All parsed tasks from the spec */
|
||||
tasks?: ParsedTask[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AutoModeEventType - Type-safe event names emitted by auto-mode service
|
||||
*
|
||||
* All events are wrapped as `auto-mode:event` with `type` field containing
|
||||
* one of these values.
|
||||
*/
|
||||
export type AutoModeEventType =
|
||||
// Auto-loop lifecycle events
|
||||
| 'auto_mode_started'
|
||||
| 'auto_mode_stopped'
|
||||
| 'auto_mode_idle'
|
||||
// Feature execution events
|
||||
| 'auto_mode_feature_start'
|
||||
| 'auto_mode_feature_complete'
|
||||
| 'auto_mode_progress'
|
||||
| 'auto_mode_tool'
|
||||
| 'auto_mode_error'
|
||||
// Task execution events (multi-agent)
|
||||
| 'auto_mode_task_started'
|
||||
| 'auto_mode_task_complete'
|
||||
| 'auto_mode_phase_complete'
|
||||
// Planning/spec events
|
||||
| 'planning_started'
|
||||
| 'plan_approval_required'
|
||||
| 'plan_approved'
|
||||
| 'plan_rejected'
|
||||
| 'plan_auto_approved'
|
||||
| 'plan_revision_requested';
|
||||
|
||||
/**
|
||||
* AutoModeEvent - Base event payload structure
|
||||
*/
|
||||
export interface AutoModeEventPayload {
|
||||
/** The specific event type */
|
||||
type: AutoModeEventType;
|
||||
/** Feature ID this event relates to */
|
||||
featureId?: string;
|
||||
/** Project path */
|
||||
projectPath?: string;
|
||||
/** Additional event-specific data */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskProgressPayload - Event payload for task progress events
|
||||
*/
|
||||
export interface TaskProgressPayload {
|
||||
type: 'auto_mode_task_started' | 'auto_mode_task_complete';
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
taskId: string;
|
||||
taskDescription?: string;
|
||||
taskIndex: number;
|
||||
tasksTotal: number;
|
||||
tasksCompleted?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanApprovalPayload - Event payload for plan approval events
|
||||
*/
|
||||
export interface PlanApprovalPayload {
|
||||
type: 'plan_approval_required';
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
planContent: string;
|
||||
planningMode: PlanningMode;
|
||||
planVersion: number;
|
||||
}
|
||||
@@ -54,3 +54,15 @@ export {
|
||||
type ContextFilesResult,
|
||||
type LoadContextFilesOptions,
|
||||
} from './context-loader.js';
|
||||
|
||||
// Stream processing
|
||||
export {
|
||||
processStream,
|
||||
collectStreamText,
|
||||
processStreamWithProgress,
|
||||
hasMarker,
|
||||
extractBeforeMarker,
|
||||
sleep,
|
||||
type StreamHandlers,
|
||||
type StreamResult,
|
||||
} from './stream-processor.js';
|
||||
|
||||
173
libs/utils/src/stream-processor.ts
Normal file
173
libs/utils/src/stream-processor.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Stream Processor - Unified stream handling for provider messages
|
||||
*
|
||||
* Eliminates duplication of the stream processing pattern for handling
|
||||
* async generators from AI providers.
|
||||
*/
|
||||
|
||||
import type { ProviderMessage, ContentBlock } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Callbacks for handling different stream events
|
||||
*/
|
||||
export interface StreamHandlers {
|
||||
/** Called for each text block in the stream */
|
||||
onText?: (text: string) => void | Promise<void>;
|
||||
/** Called for each tool use in the stream */
|
||||
onToolUse?: (name: string, input: unknown) => void | Promise<void>;
|
||||
/** Called when an error occurs in the stream */
|
||||
onError?: (error: string) => void | Promise<void>;
|
||||
/** Called when the stream completes successfully */
|
||||
onComplete?: (result: string) => void | Promise<void>;
|
||||
/** Called for thinking blocks (if present) */
|
||||
onThinking?: (thinking: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from processing a stream
|
||||
*/
|
||||
export interface StreamResult {
|
||||
/** All accumulated text from the stream */
|
||||
text: string;
|
||||
/** Whether the stream completed successfully */
|
||||
success: boolean;
|
||||
/** Error message if stream failed */
|
||||
error?: string;
|
||||
/** Final result message if stream completed */
|
||||
result?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a provider message stream with unified handling
|
||||
*
|
||||
* @param stream - The async generator from provider.executeQuery()
|
||||
* @param handlers - Callbacks for different event types
|
||||
* @returns Accumulated result with text and status
|
||||
*/
|
||||
export async function processStream(
|
||||
stream: AsyncGenerator<ProviderMessage>,
|
||||
handlers: StreamHandlers
|
||||
): Promise<StreamResult> {
|
||||
let accumulatedText = '';
|
||||
let success = true;
|
||||
let errorMessage: string | undefined;
|
||||
let resultMessage: string | undefined;
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
await processContentBlock(block, handlers, (text) => {
|
||||
accumulatedText += text;
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
success = false;
|
||||
errorMessage = msg.error || 'Unknown error';
|
||||
if (handlers.onError) {
|
||||
await handlers.onError(errorMessage);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
resultMessage = msg.result || '';
|
||||
if (handlers.onComplete) {
|
||||
await handlers.onComplete(resultMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!errorMessage) {
|
||||
success = false;
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
text: accumulatedText,
|
||||
success,
|
||||
error: errorMessage,
|
||||
result: resultMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single content block
|
||||
*/
|
||||
async function processContentBlock(
|
||||
block: ContentBlock,
|
||||
handlers: StreamHandlers,
|
||||
appendText: (text: string) => void
|
||||
): Promise<void> {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (block.text) {
|
||||
appendText(block.text);
|
||||
if (handlers.onText) {
|
||||
await handlers.onText(block.text);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_use':
|
||||
if (block.name && handlers.onToolUse) {
|
||||
await handlers.onToolUse(block.name, block.input);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thinking':
|
||||
if (block.thinking && handlers.onThinking) {
|
||||
await handlers.onThinking(block.thinking);
|
||||
}
|
||||
break;
|
||||
|
||||
// tool_result blocks are handled internally by the SDK
|
||||
case 'tool_result':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple stream processor that just collects text
|
||||
*/
|
||||
export async function collectStreamText(stream: AsyncGenerator<ProviderMessage>): Promise<string> {
|
||||
const result = await processStream(stream, {});
|
||||
return result.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process stream with progress callback
|
||||
*/
|
||||
export async function processStreamWithProgress(
|
||||
stream: AsyncGenerator<ProviderMessage>,
|
||||
onProgress: (text: string) => void
|
||||
): Promise<StreamResult> {
|
||||
return processStream(stream, {
|
||||
onText: onProgress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stream result contains a specific marker
|
||||
*/
|
||||
export function hasMarker(result: StreamResult, marker: string): boolean {
|
||||
return result.text.includes(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content before a marker
|
||||
*/
|
||||
export function extractBeforeMarker(text: string, marker: string): string | null {
|
||||
const index = text.indexOf(marker);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
return text.substring(0, index).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility - delay execution for specified milliseconds
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user