Merge remote-tracking branch 'origin/main' into implement-planning/speckits

This commit is contained in:
SuperComboGamer
2025-12-17 21:40:42 -05:00
126 changed files with 16113 additions and 1069 deletions

View File

@@ -20,9 +20,18 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
import type { Feature } from "./feature-loader.js";
import {
getFeatureDir,
getFeaturesDir,
getAutomakerDir,
getWorktreesDir,
} from "../lib/automaker-paths.js";
const execAsync = promisify(exec);
// Planning mode types for spec-driven development
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
interface ParsedTask {
@@ -278,27 +287,12 @@ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
};
}
interface Feature {
id: string;
category: string;
description: string;
steps?: string[];
status: string;
priority?: number;
spec?: string;
model?: string; // Model to use for this feature
imagePaths?: Array<
| string
| {
path: string;
filename?: string;
mimeType?: string;
[key: string]: unknown;
}
>;
// Feature type is imported from feature-loader.js
// Extended type with planning fields for local use
interface FeatureWithPlanning extends Feature {
planningMode?: PlanningMode;
planSpec?: PlanSpec;
requirePlanApproval?: boolean; // If true, pause for user approval before implementation
requirePlanApproval?: boolean;
}
interface RunningFeature {
@@ -444,13 +438,18 @@ export class AutoModeService {
/**
* Execute a single feature
* @param projectPath - The main project path
* @param featureId - The feature ID to execute
* @param useWorktrees - Whether to use worktrees for isolation
* @param isAutoMode - Whether this is running in auto mode
* @param providedWorktreePath - Optional: use this worktree path instead of creating a new one
*/
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = true,
useWorktrees = false,
isAutoMode = false,
customPrompt?: string
providedWorktreePath?: string
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
@@ -460,8 +459,29 @@ export class AutoModeService {
const branchName = `feature/${featureId}`;
let worktreePath: string | null = null;
// Setup worktree if enabled
if (useWorktrees) {
// Use provided worktree path if given, otherwise setup new worktree if enabled
if (providedWorktreePath) {
// Resolve to absolute path - critical for cross-platform compatibility
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd
// On all platforms, absolute paths ensure commands execute in the correct directory
try {
// Resolve relative paths relative to projectPath, absolute paths as-is
const resolvedPath = path.isAbsolute(providedWorktreePath)
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath);
// Verify the path exists before using it
await fs.access(resolvedPath);
worktreePath = resolvedPath;
console.log(`[AutoMode] Using provided worktree path (resolved): ${worktreePath}`);
} catch (error) {
console.error(`[AutoMode] Provided worktree path invalid or doesn't exist: ${providedWorktreePath}`, error);
// Fall through to create new worktree or use project path
}
}
if (!worktreePath && useWorktrees) {
// No specific worktree provided, create a new one for this feature
worktreePath = await this.setupWorktree(
projectPath,
featureId,
@@ -469,7 +489,8 @@ export class AutoModeService {
);
}
const workDir = worktreePath || projectPath;
// Ensure workDir is always an absolute path for cross-platform compatibility
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
this.runningFeatures.set(featureId, {
featureId,
@@ -611,16 +632,11 @@ export class AutoModeService {
async resumeFeature(
projectPath: string,
featureId: string,
useWorktrees = true
useWorktrees = false
): Promise<void> {
// Check if context exists
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
// Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
let hasContext = false;
try {
@@ -652,7 +668,8 @@ export class AutoModeService {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
providedWorktreePath?: string
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
@@ -660,33 +677,35 @@ export class AutoModeService {
const abortController = new AbortController();
// Check if worktree exists
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
// Use the provided worktreePath (from the feature's assigned branch)
// Fall back to project path if not provided
let workDir = path.resolve(projectPath);
let worktreePath: string | null = null;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree, use project path
if (providedWorktreePath) {
try {
// Resolve to absolute path - critical for cross-platform compatibility
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd
// On all platforms, absolute paths ensure commands execute in the correct directory
const resolvedPath = path.isAbsolute(providedWorktreePath)
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath);
await fs.access(resolvedPath);
workDir = resolvedPath;
worktreePath = resolvedPath;
} catch {
// Worktree path provided but doesn't exist, use project path
console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`);
}
}
// Load feature info for context
const feature = await this.loadFeature(projectPath, featureId);
// Load previous agent output if it exists
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
let previousContext = "";
try {
previousContext = await fs.readFile(contextPath, "utf-8");
@@ -719,8 +738,8 @@ Address the follow-up instructions above. Review the previous work and make the
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath: workDir !== projectPath ? worktreePath : null,
branchName: `feature/${featureId}`,
worktreePath,
branchName: worktreePath ? path.basename(worktreePath) : null,
abortController,
isAutoMode: false,
startTime: Date.now(),
@@ -749,13 +768,8 @@ Address the follow-up instructions above. Review the previous work and make the
// Copy follow-up images to feature folder
const copiedImagePaths: string[] = [];
if (imagePaths && imagePaths.length > 0) {
const featureImagesDir = path.join(
projectPath,
".automaker",
"features",
featureId,
"images"
);
const featureDirForImages = getFeatureDir(projectPath, featureId);
const featureImagesDir = path.join(featureDirForImages, "images");
await fs.mkdir(featureImagesDir, { recursive: true });
@@ -768,15 +782,8 @@ Address the follow-up instructions above. Review the previous work and make the
// Copy the image
await fs.copyFile(imagePath, destPath);
// Store the relative path (like FeatureLoader does)
const relativePath = path.join(
".automaker",
"features",
featureId,
"images",
filename
);
copiedImagePaths.push(relativePath);
// Store the absolute path (external storage uses absolute paths)
copiedImagePaths.push(destPath);
} catch (error) {
console.error(
`[AutoMode] Failed to copy follow-up image ${imagePath}:`,
@@ -811,13 +818,8 @@ Address the follow-up instructions above. Review the previous work and make the
// Save updated feature.json with new images
if (copiedImagePaths.length > 0 && feature) {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
const featureDirForSave = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDirForSave, "feature.json");
try {
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
@@ -828,6 +830,7 @@ Address the follow-up instructions above. Review the previous work and make the
// Use fullPrompt (already built above) with model and all images
// Note: Follow-ups skip planning mode - they continue from previous work
// Pass previousContext so the history is preserved in the output file
await this.runAgent(
workDir,
featureId,
@@ -838,6 +841,7 @@ Address the follow-up instructions above. Review the previous work and make the
{
projectPath,
planningMode: 'skip', // Follow-ups don't require approval
previousContent: previousContext || undefined,
}
);
@@ -874,12 +878,8 @@ Address the follow-up instructions above. Review the previous work and make the
projectPath: string,
featureId: string
): Promise<boolean> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
// Worktrees are in project dir
const worktreePath = path.join(projectPath, ".worktrees", featureId);
let workDir = projectPath;
try {
@@ -938,24 +938,36 @@ Address the follow-up instructions above. Review the previous work and make the
/**
* Commit feature changes
* @param projectPath - The main project path
* @param featureId - The feature ID to commit
* @param providedWorktreePath - Optional: the worktree path where the feature's changes are located
*/
async commitFeature(
projectPath: string,
featureId: string
featureId: string,
providedWorktreePath?: string
): Promise<string | null> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
// Use the provided worktree path if given
if (providedWorktreePath) {
try {
await fs.access(providedWorktreePath);
workDir = providedWorktreePath;
console.log(`[AutoMode] Committing in provided worktree: ${workDir}`);
} catch {
console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`);
}
} else {
// Fallback: try to find worktree at legacy location
const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId);
try {
await fs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`);
} catch {
console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`);
}
}
try {
@@ -1006,13 +1018,9 @@ Address the follow-up instructions above. Review the previous work and make the
projectPath: string,
featureId: string
): Promise<boolean> {
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
// Context is stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
try {
await fs.access(contextPath);
@@ -1085,13 +1093,10 @@ Format your response as a structured markdown document.`;
}
}
// Save analysis
const analysisPath = path.join(
projectPath,
".automaker",
"project-analysis.md"
);
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
// Save analysis to .automaker directory
const automakerDir = getAutomakerDir(projectPath);
const analysisPath = path.join(automakerDir, "project-analysis.md");
await fs.mkdir(automakerDir, { recursive: true });
await fs.writeFile(analysisPath, analysisResult);
this.emitAutoModeEvent("auto_mode_feature_complete", {
@@ -1294,20 +1299,82 @@ Format your response as a structured markdown document.`;
// Private helpers
/**
* Find an existing worktree for a given branch by checking git worktree list
*/
private async findExistingWorktreeForBranch(
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
// On all platforms, absolute paths ensure consistent behavior
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) {
// Resolve to absolute path for cross-platform compatibility
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return resolvedPath;
}
return null;
} catch {
return null;
}
}
private async setupWorktree(
projectPath: string,
featureId: string,
branchName: string
): Promise<string> {
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
// First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await this.findExistingWorktreeForBranch(projectPath, branchName);
if (existingWorktree) {
// Path is already resolved to absolute in findExistingWorktreeForBranch
console.log(`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`);
return existingWorktree;
}
// Git worktrees stay in project directory
const worktreesDir = path.join(projectPath, ".worktrees");
const worktreePath = path.join(worktreesDir, featureId);
await fs.mkdir(worktreesDir, { recursive: true });
// Check if worktree already exists
// Check if worktree directory already exists (might not be linked to branch)
try {
await fs.access(worktreePath);
return worktreePath;
// Return absolute path for cross-platform compatibility
return path.resolve(worktreePath);
} catch {
// Create new worktree
}
@@ -1324,26 +1391,22 @@ Format your response as a structured markdown document.`;
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, {
cwd: projectPath,
});
// Return absolute path for cross-platform compatibility
return path.resolve(worktreePath);
} catch (error) {
// Worktree creation failed, fall back to direct execution
console.error(`[AutoMode] Worktree creation failed:`, error);
return projectPath;
return path.resolve(projectPath);
}
return worktreePath;
}
private async loadFeature(
projectPath: string,
featureId: string
): Promise<Feature | null> {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
// Features are stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, "feature.json");
try {
const data = await fs.readFile(featurePath, "utf-8");
@@ -1358,13 +1421,9 @@ Format your response as a structured markdown document.`;
featureId: string,
status: string
): Promise<void> {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
// Features are stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, "feature.json");
try {
const data = await fs.readFile(featurePath, "utf-8");
@@ -1430,12 +1489,15 @@ Format your response as a structured markdown document.`;
}
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
const featuresDir = path.join(projectPath, ".automaker", "features");
// Features are stored in .automaker directory
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const features: Feature[] = [];
const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = [];
// Load all features (for dependency checking)
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(
@@ -1446,12 +1508,15 @@ Format your response as a structured markdown document.`;
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
allFeatures.push(feature);
// Track pending features separately
if (
feature.status === "pending" ||
feature.status === "ready" ||
feature.status === "backlog"
) {
features.push(feature);
pendingFeatures.push(feature);
}
} catch {
// Skip invalid features
@@ -1459,8 +1524,15 @@ Format your response as a structured markdown document.`;
}
}
// Sort by priority
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter(feature =>
areDependenciesSatisfied(feature, allFeatures)
);
return readyFeatures;
} catch {
return [];
}
@@ -1562,7 +1634,22 @@ Implement this feature by:
4. Add or update tests as needed
5. Ensure the code follows existing patterns and conventions
When done, summarize what you implemented and any notes for the developer.`;
When done, wrap your final summary in <summary> tags like this:
<summary>
## Summary: [Feature Title]
### Changes Implemented
- [List of changes made]
### Files Modified
- [List of files]
### Notes for Developer
- [Any important notes]
</summary>
This helps parse your summary correctly in the output logs.`;
return prompt;
}
@@ -1578,10 +1665,13 @@ When done, summarize what you implemented and any notes for the developer.`;
projectPath?: string;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
previousContent?: string;
}
): Promise<void> {
const projectPath = options?.projectPath || workDir;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
// Check if this planning mode can generate a spec/plan that needs approval
// - spec and full always generate specs
// - lite only generates approval-ready content when requirePlanApproval is true
@@ -1591,6 +1681,64 @@ When done, summarize what you implemented and any notes for the developer.`;
(planningMode === 'lite' && options?.requirePlanApproval === true);
const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true;
// CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set
// This prevents actual API calls during automated testing
if (process.env.AUTOMAKER_MOCK_AGENT === "true") {
console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`);
// Simulate some work being done
await this.sleep(500);
// Emit mock progress events to simulate agent activity
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: "Mock agent: Analyzing the codebase...",
});
await this.sleep(300);
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: "Mock agent: Implementing the feature...",
});
await this.sleep(300);
// Create a mock file with "yellow" content as requested in the test
const mockFilePath = path.join(workDir, "yellow.txt");
await fs.writeFile(mockFilePath, "yellow");
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: "Mock agent: Created yellow.txt file with content 'yellow'",
});
await this.sleep(200);
// Save mock agent output
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
const mockOutput = `# Mock Agent Output
## Summary
This is a mock agent response for CI/CD testing.
## Changes Made
- Created \`yellow.txt\` with content "yellow"
## Notes
This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
`;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, mockOutput);
console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`);
return;
}
// Build SDK options using centralized configuration for feature implementation
const sdkOptions = createAutoModeOptions({
cwd: workDir,
@@ -1633,20 +1781,56 @@ When done, summarize what you implemented and any notes for the developer.`;
// Execute via provider
const stream = provider.executeQuery(executeOptions);
let responseText = "";
// Initialize with previous content if this is a follow-up, with a separator
let responseText = previousContent
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
: "";
let specDetected = false;
const outputPath = path.join(
workDir,
".automaker",
"features",
featureId,
"agent-output.md"
);
// Agent output goes to .automaker directory
// Note: We use the original projectPath here (from config), not workDir
// because workDir might be a worktree path
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
// Incremental file writing state
let writeTimeout: ReturnType<typeof setTimeout> | null = null;
const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
// Helper to write current responseText to file
const writeToFile = async (): Promise<void> => {
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, responseText);
} catch (error) {
// Log but don't crash - file write errors shouldn't stop execution
console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error);
}
};
// Debounced write - schedules a write after WRITE_DEBOUNCE_MS
const scheduleWrite = (): void => {
if (writeTimeout) {
clearTimeout(writeTimeout);
}
writeTimeout = setTimeout(() => {
writeToFile();
}, WRITE_DEBOUNCE_MS);
};
streamLoop: for await (const msg of stream) {
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
// Add separator before new text if we already have content and it doesn't end with newlines
if (responseText.length > 0 && !responseText.endsWith('\n\n')) {
if (responseText.endsWith('\n')) {
responseText += '\n';
} else {
responseText += '\n\n';
}
}
responseText += block.text || "";
// Check for authentication errors in the response
@@ -1662,6 +1846,9 @@ When done, summarize what you implemented and any notes for the developer.`;
);
}
// Schedule incremental file write (debounced)
scheduleWrite();
// Check for [SPEC_GENERATED] marker in planning modes (spec or full)
if (planningModeRequiresApproval && !specDetected && responseText.includes('[SPEC_GENERATED]')) {
specDetected = true;
@@ -2056,28 +2243,41 @@ Implement all the changes described in the plan above.`;
});
}
} else if (block.type === "tool_use") {
// Emit event for real-time UI
this.emitAutoModeEvent("auto_mode_tool", {
featureId,
tool: block.name,
input: block.input,
});
// Also add to file output for persistence
if (responseText.length > 0 && !responseText.endsWith('\n')) {
responseText += '\n';
}
responseText += `\n🔧 Tool: ${block.name}\n`;
if (block.input) {
responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
}
scheduleWrite();
}
}
} else if (msg.type === "error") {
// Handle error messages
throw new Error(msg.error || "Unknown error");
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
// Don't replace responseText - the accumulated content is the full history
// The msg.result is just a summary which would lose all tool use details
// Just ensure final write happens
scheduleWrite();
}
}
// Save agent output
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, responseText);
} catch {
// May fail if directory doesn't exist
// Clear any pending timeout and do a final write to ensure all content is saved
if (writeTimeout) {
clearTimeout(writeTimeout);
}
// Final write - ensure all accumulated content is saved
await writeToFile();
}
private async executeFeatureWithContext(