Merge branch 'AutoMaker-Org:main' into feat/claude-usage-clean

This commit is contained in:
Mohamad Yahia
2025-12-21 09:18:13 +04:00
committed by GitHub
183 changed files with 9233 additions and 2472 deletions

View File

@@ -3,17 +3,18 @@
* Manages conversation sessions and streams responses via WebSocket
*/
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import * as secureFs from "../lib/secure-fs.js";
import type { EventEmitter } from "../lib/events.js";
import type { ExecuteOptions } from "@automaker/types";
import {
readImageAsBase64,
buildPromptWithImages,
isAbortError,
} from "@automaker/utils";
import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
import { readImageAsBase64 } from "../lib/image-handler.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { createChatOptions } from "../lib/sdk-options.js";
import { isAbortError } from "../lib/error-handler.js";
import { isPathAllowed, PathNotAllowedError } from "../lib/security.js";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
interface Message {
id: string;
@@ -87,7 +88,9 @@ export class AgentService {
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new PathNotAllowedError(effectiveWorkingDirectory);
throw new Error(
`Working directory ${effectiveWorkingDirectory} is not allowed`
);
}
this.sessions.set(sessionId, {
@@ -401,7 +404,7 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = await secureFs.readFile(sessionFile, "utf-8") as string;
const data = (await secureFs.readFile(sessionFile, "utf-8")) as string;
return JSON.parse(data);
} catch {
return [];
@@ -425,7 +428,10 @@ export class AgentService {
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
try {
const data = await secureFs.readFile(this.metadataFile, "utf-8") as string;
const data = (await secureFs.readFile(
this.metadataFile,
"utf-8"
)) as string;
return JSON.parse(data);
} catch {
return {};
@@ -472,7 +478,8 @@ export class AgentService {
const metadata = await this.loadMetadata();
// Determine the effective working directory
const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd();
const effectiveWorkingDirectory =
workingDirectory || projectPath || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed

View File

@@ -10,37 +10,47 @@
*/
import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
import type { ExecuteOptions, Feature } from "@automaker/types";
import {
buildPromptWithImages,
isAbortError,
classifyError,
} from "@automaker/utils";
import { resolveModelString, DEFAULT_MODELS } from "@automaker/model-resolver";
import {
resolveDependencies,
areDependenciesSatisfied,
} from "@automaker/dependency-resolver";
import {
getFeatureDir,
getAutomakerDir,
getFeaturesDir,
getContextDir,
} from "@automaker/platform";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import * as secureFs from "../lib/secure-fs.js";
import type { EventEmitter } from "../lib/events.js";
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 { FeatureLoader } from "./feature-loader.js";
import { getFeatureDir, getAutomakerDir, getFeaturesDir, getContextDir } from "../lib/automaker-paths.js";
import { isPathAllowed, PathNotAllowedError } from "../lib/security.js";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
const execAsync = promisify(exec);
// Planning mode types for spec-driven development
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
type PlanningMode = "skip" | "lite" | "spec" | "full";
interface ParsedTask {
id: string; // e.g., "T001"
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';
filePath?: string; // e.g., "src/models/user.ts"
phase?: string; // e.g., "Phase 1: Foundation" (for full mode)
status: "pending" | "in_progress" | "completed" | "failed";
}
interface PlanSpec {
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
status: "pending" | "generating" | "generated" | "approved" | "rejected";
content?: string;
version: number;
generatedAt?: string;
@@ -205,7 +215,7 @@ When approved, execute tasks SEQUENTIALLY by phase. For each task:
After completing all tasks in a phase, output:
"[PHASE_COMPLETE] Phase N complete"
This allows real-time progress tracking during implementation.`
This allows real-time progress tracking during implementation.`,
};
/**
@@ -236,7 +246,7 @@ function parseTasksFromSpec(specContent: string): ParsedTask[] {
}
const tasksContent = tasksBlockMatch[1];
const lines = tasksContent.split('\n');
const lines = tasksContent.split("\n");
let currentPhase: string | undefined;
@@ -251,7 +261,7 @@ function parseTasksFromSpec(specContent: string): ParsedTask[] {
}
// Check for task line
if (trimmedLine.startsWith('- [ ]')) {
if (trimmedLine.startsWith("- [ ]")) {
const parsed = parseTaskLine(trimmedLine, currentPhase);
if (parsed) {
tasks.push(parsed);
@@ -268,7 +278,9 @@ function parseTasksFromSpec(specContent: string): ParsedTask[] {
*/
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*(.+))?$/);
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*(.+)$/);
@@ -277,7 +289,7 @@ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
id: simpleMatch[1],
description: simpleMatch[2].trim(),
phase: currentPhase,
status: 'pending',
status: "pending",
};
}
return null;
@@ -288,7 +300,7 @@ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
description: taskMatch[2].trim(),
filePath: taskMatch[3]?.trim(),
phase: currentPhase,
status: 'pending',
status: "pending",
};
}
@@ -318,7 +330,11 @@ interface AutoLoopState {
}
interface PendingApproval {
resolve: (result: { approved: boolean; editedPlan?: string; feedback?: string }) => void;
resolve: (result: {
approved: boolean;
editedPlan?: string;
feedback?: string;
}) => void;
reject: (error: Error) => void;
featureId: string;
projectPath: string;
@@ -576,7 +592,9 @@ export class AutoModeService {
// Continuation prompt is used when recovering from a plan approval
// The plan was already approved, so skip the planning phase
prompt = options.continuationPrompt;
console.log(`[AutoMode] Using continuation prompt for feature ${featureId}`);
console.log(
`[AutoMode] Using continuation prompt for feature ${featureId}`
);
} else {
// Normal flow: build prompt with planning phase
const featurePrompt = this.buildFeaturePrompt(feature);
@@ -584,11 +602,11 @@ export class AutoModeService {
prompt = planningPrefix + featurePrompt;
// Emit planning mode info
if (feature.planningMode && feature.planningMode !== 'skip') {
this.emitAutoModeEvent('planning_started', {
if (feature.planningMode && feature.planningMode !== "skip") {
this.emitAutoModeEvent("planning_started", {
featureId: feature.id,
mode: feature.planningMode,
message: `Starting ${feature.planningMode} planning phase`
message: `Starting ${feature.planningMode} planning phase`,
});
}
}
@@ -658,8 +676,12 @@ export class AutoModeService {
});
}
} finally {
console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`);
console.log(`[AutoMode] Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`);
console.log(
`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`
);
console.log(
`[AutoMode] Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(", ") || "none"}`
);
this.runningFeatures.delete(featureId);
}
}
@@ -706,7 +728,7 @@ export class AutoModeService {
if (hasContext) {
// Load previous context and continue
const context = await secureFs.readFile(contextPath, "utf-8") as string;
const context = (await secureFs.readFile(contextPath, "utf-8")) as string;
return this.executeFeatureWithContext(
projectPath,
featureId,
@@ -766,7 +788,10 @@ export class AutoModeService {
const contextPath = path.join(featureDir, "agent-output.md");
let previousContext = "";
try {
previousContext = await secureFs.readFile(contextPath, "utf-8") as string;
previousContext = (await secureFs.readFile(
contextPath,
"utf-8"
)) as string;
} catch {
// No previous context
}
@@ -883,7 +908,10 @@ Address the follow-up instructions above. Review the previous work and make the
const featurePath = path.join(featureDirForSave, "feature.json");
try {
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
await secureFs.writeFile(
featurePath,
JSON.stringify(feature, null, 2)
);
} catch (error) {
console.error(`[AutoMode] Failed to save feature.json:`, error);
}
@@ -903,7 +931,7 @@ Address the follow-up instructions above. Review the previous work and make the
model,
{
projectPath,
planningMode: 'skip', // Follow-ups don't require approval
planningMode: "skip", // Follow-ups don't require approval
previousContent: previousContext || undefined,
systemPrompt: contextFiles || undefined,
}
@@ -1130,7 +1158,7 @@ Address the follow-up instructions above. Review the previous work and make the
for (const file of textFiles) {
// Use path.join for cross-platform path construction
const filePath = path.join(contextDir, file);
const content = await secureFs.readFile(filePath, "utf-8") as string;
const content = (await secureFs.readFile(filePath, "utf-8")) as string;
contents.push(`## ${file}\n\n${content}`);
}
@@ -1249,7 +1277,6 @@ Format your response as a structured markdown document.`;
}
}
/**
* Get current status
*/
@@ -1290,8 +1317,12 @@ Format your response as a structured markdown document.`;
featureId: string,
projectPath: string
): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> {
console.log(`[AutoMode] Registering pending approval for feature ${featureId}`);
console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`);
console.log(
`[AutoMode] Registering pending approval for feature ${featureId}`
);
console.log(
`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(", ") || "none"}`
);
return new Promise((resolve, reject) => {
this.pendingApprovals.set(featureId, {
resolve,
@@ -1299,7 +1330,9 @@ Format your response as a structured markdown document.`;
featureId,
projectPath,
});
console.log(`[AutoMode] Pending approval registered for feature ${featureId}`);
console.log(
`[AutoMode] Pending approval registered for feature ${featureId}`
);
});
}
@@ -1314,61 +1347,89 @@ Format your response as a structured markdown document.`;
feedback?: string,
projectPathFromClient?: string
): Promise<{ success: boolean; error?: string }> {
console.log(`[AutoMode] resolvePlanApproval called for feature ${featureId}, approved=${approved}`);
console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`);
console.log(
`[AutoMode] resolvePlanApproval called for feature ${featureId}, approved=${approved}`
);
console.log(
`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(", ") || "none"}`
);
const pending = this.pendingApprovals.get(featureId);
if (!pending) {
console.log(`[AutoMode] No pending approval in Map for feature ${featureId}`);
console.log(
`[AutoMode] No pending approval in Map for feature ${featureId}`
);
// RECOVERY: If no pending approval but we have projectPath from client,
// check if feature's planSpec.status is 'generated' and handle recovery
if (projectPathFromClient) {
console.log(`[AutoMode] Attempting recovery with projectPath: ${projectPathFromClient}`);
const feature = await this.loadFeature(projectPathFromClient, featureId);
console.log(
`[AutoMode] Attempting recovery with projectPath: ${projectPathFromClient}`
);
const feature = await this.loadFeature(
projectPathFromClient,
featureId
);
if (feature?.planSpec?.status === 'generated') {
console.log(`[AutoMode] Feature ${featureId} has planSpec.status='generated', performing recovery`);
if (feature?.planSpec?.status === "generated") {
console.log(
`[AutoMode] Feature ${featureId} has planSpec.status='generated', performing recovery`
);
if (approved) {
// Update planSpec to approved
await this.updateFeaturePlanSpec(projectPathFromClient, featureId, {
status: 'approved',
status: "approved",
approvedAt: new Date().toISOString(),
reviewedByUser: true,
content: editedPlan || feature.planSpec.content,
});
// Build continuation prompt and re-run the feature
const planContent = editedPlan || feature.planSpec.content || '';
const planContent = editedPlan || feature.planSpec.content || "";
let continuationPrompt = `The plan/specification has been approved. `;
if (feedback) {
continuationPrompt += `\n\nUser feedback: ${feedback}\n\n`;
}
continuationPrompt += `Now proceed with the implementation as specified in the plan:\n\n${planContent}\n\nImplement the feature now.`;
console.log(`[AutoMode] Starting recovery execution for feature ${featureId}`);
console.log(
`[AutoMode] Starting recovery execution for feature ${featureId}`
);
// Start feature execution with the continuation prompt (async, don't await)
// Pass undefined for providedWorktreePath, use options for continuation prompt
this.executeFeature(projectPathFromClient, featureId, true, false, undefined, {
continuationPrompt,
})
.catch((error) => {
console.error(`[AutoMode] Recovery execution failed for feature ${featureId}:`, error);
});
this.executeFeature(
projectPathFromClient,
featureId,
true,
false,
undefined,
{
continuationPrompt,
}
).catch((error) => {
console.error(
`[AutoMode] Recovery execution failed for feature ${featureId}:`,
error
);
});
return { success: true };
} else {
// Rejected - update status and emit event
await this.updateFeaturePlanSpec(projectPathFromClient, featureId, {
status: 'rejected',
status: "rejected",
reviewedByUser: true,
});
await this.updateFeatureStatus(projectPathFromClient, featureId, 'backlog');
await this.updateFeatureStatus(
projectPathFromClient,
featureId,
"backlog"
);
this.emitAutoModeEvent('plan_rejected', {
this.emitAutoModeEvent("plan_rejected", {
featureId,
projectPath: projectPathFromClient,
feedback,
@@ -1379,16 +1440,23 @@ Format your response as a structured markdown document.`;
}
}
console.log(`[AutoMode] ERROR: No pending approval found for feature ${featureId} and recovery not possible`);
return { success: false, error: `No pending approval for feature ${featureId}` };
console.log(
`[AutoMode] ERROR: No pending approval found for feature ${featureId} and recovery not possible`
);
return {
success: false,
error: `No pending approval for feature ${featureId}`,
};
}
console.log(`[AutoMode] Found pending approval for feature ${featureId}, proceeding...`);
console.log(
`[AutoMode] Found pending approval for feature ${featureId}, proceeding...`
);
const { projectPath } = pending;
// Update feature's planSpec status
await this.updateFeaturePlanSpec(projectPath, featureId, {
status: approved ? 'approved' : 'rejected',
status: approved ? "approved" : "rejected",
approvedAt: approved ? new Date().toISOString() : undefined,
reviewedByUser: true,
content: editedPlan, // Update content if user provided an edited version
@@ -1397,7 +1465,7 @@ Format your response as a structured markdown document.`;
// If rejected with feedback, we can store it for the user to see
if (!approved && feedback) {
// Emit event so client knows the rejection reason
this.emitAutoModeEvent('plan_rejected', {
this.emitAutoModeEvent("plan_rejected", {
featureId,
projectPath,
feedback,
@@ -1415,15 +1483,25 @@ Format your response as a structured markdown document.`;
* Cancel a pending plan approval (e.g., when feature is stopped).
*/
cancelPlanApproval(featureId: string): void {
console.log(`[AutoMode] cancelPlanApproval called for feature ${featureId}`);
console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`);
console.log(
`[AutoMode] cancelPlanApproval called for feature ${featureId}`
);
console.log(
`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(", ") || "none"}`
);
const pending = this.pendingApprovals.get(featureId);
if (pending) {
console.log(`[AutoMode] Found and cancelling pending approval for feature ${featureId}`);
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
console.log(
`[AutoMode] Found and cancelling pending approval for feature ${featureId}`
);
pending.reject(
new Error("Plan approval cancelled - feature was stopped")
);
this.pendingApprovals.delete(featureId);
} else {
console.log(`[AutoMode] No pending approval to cancel for feature ${featureId}`);
console.log(
`[AutoMode] No pending approval to cancel for feature ${featureId}`
);
}
}
@@ -1436,7 +1514,6 @@ Format your response as a structured markdown document.`;
// Private helpers
/**
* Find an existing worktree for a given branch by checking git worktree list
*/
@@ -1498,7 +1575,7 @@ Format your response as a structured markdown document.`;
const featurePath = path.join(featureDir, "feature.json");
try {
const data = await secureFs.readFile(featurePath, "utf-8") as string;
const data = (await secureFs.readFile(featurePath, "utf-8")) as string;
return JSON.parse(data);
} catch {
return null;
@@ -1515,7 +1592,7 @@ Format your response as a structured markdown document.`;
const featurePath = path.join(featureDir, "feature.json");
try {
const data = await secureFs.readFile(featurePath, "utf-8") as string;
const data = (await secureFs.readFile(featurePath, "utf-8")) as string;
const feature = JSON.parse(data);
feature.status = status;
feature.updatedAt = new Date().toISOString();
@@ -1550,13 +1627,13 @@ Format your response as a structured markdown document.`;
);
try {
const data = await secureFs.readFile(featurePath, "utf-8") as string;
const data = (await secureFs.readFile(featurePath, "utf-8")) as string;
const feature = JSON.parse(data);
// Initialize planSpec if it doesn't exist
if (!feature.planSpec) {
feature.planSpec = {
status: 'pending',
status: "pending",
version: 1,
reviewedByUser: false,
};
@@ -1573,7 +1650,10 @@ Format your response as a structured markdown document.`;
feature.updatedAt = new Date().toISOString();
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch (error) {
console.error(`[AutoMode] Failed to update planSpec for ${featureId}:`, error);
console.error(
`[AutoMode] Failed to update planSpec for ${featureId}:`,
error
);
}
}
@@ -1582,7 +1662,9 @@ Format your response as a structured markdown document.`;
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
const entries = await secureFs.readdir(featuresDir, {
withFileTypes: true,
});
const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = [];
@@ -1595,7 +1677,10 @@ Format your response as a structured markdown document.`;
"feature.json"
);
try {
const data = await secureFs.readFile(featurePath, "utf-8") as string;
const data = (await secureFs.readFile(
featurePath,
"utf-8"
)) as string;
const feature = JSON.parse(data);
allFeatures.push(feature);
@@ -1617,7 +1702,7 @@ Format your response as a structured markdown document.`;
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter(feature =>
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
areDependenciesSatisfied(feature, allFeatures)
);
@@ -1649,24 +1734,25 @@ Format your response as a structured markdown document.`;
* Get the planning prompt prefix based on feature's planning mode
*/
private getPlanningPromptPrefix(feature: Feature): string {
const mode = feature.planningMode || 'skip';
const mode = feature.planningMode || "skip";
if (mode === 'skip') {
return ''; // No planning phase
if (mode === "skip") {
return ""; // No planning phase
}
// For lite mode, use the approval variant if requirePlanApproval is true
let promptKey: string = mode;
if (mode === 'lite' && feature.requirePlanApproval === true) {
promptKey = 'lite_with_approval';
if (mode === "lite" && feature.requirePlanApproval === true) {
promptKey = "lite_with_approval";
}
const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS];
const planningPrompt =
PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS];
if (!planningPrompt) {
return '';
return "";
}
return planningPrompt + '\n\n---\n\n## Feature Request\n\n';
return planningPrompt + "\n\n---\n\n## Feature Request\n\n";
}
private buildFeaturePrompt(feature: Feature): string {
@@ -1760,17 +1846,18 @@ This helps parse your summary correctly in the output logs.`;
}
): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath;
const planningMode = options?.planningMode || 'skip';
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
const planningModeRequiresApproval =
planningMode === 'spec' ||
planningMode === 'full' ||
(planningMode === 'lite' && options?.requirePlanApproval === true);
const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true;
planningMode === "spec" ||
planningMode === "full" ||
(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
@@ -1953,11 +2040,15 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
scheduleWrite();
// Check for [SPEC_GENERATED] marker in planning modes (spec or full)
if (planningModeRequiresApproval && !specDetected && responseText.includes('[SPEC_GENERATED]')) {
if (
planningModeRequiresApproval &&
!specDetected &&
responseText.includes("[SPEC_GENERATED]")
) {
specDetected = true;
// Extract plan content (everything before the marker)
const markerIndex = responseText.indexOf('[SPEC_GENERATED]');
const markerIndex = responseText.indexOf("[SPEC_GENERATED]");
const planContent = responseText.substring(0, markerIndex).trim();
// Parse tasks from the generated spec (for spec and full modes)
@@ -1965,14 +2056,18 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
let parsedTasks = parseTasksFromSpec(planContent);
const tasksTotal = parsedTasks.length;
console.log(`[AutoMode] Parsed ${tasksTotal} tasks from spec for feature ${featureId}`);
console.log(
`[AutoMode] Parsed ${tasksTotal} tasks from spec for feature ${featureId}`
);
if (parsedTasks.length > 0) {
console.log(`[AutoMode] Tasks: ${parsedTasks.map(t => t.id).join(', ')}`);
console.log(
`[AutoMode] Tasks: ${parsedTasks.map((t) => t.id).join(", ")}`
);
}
// Update planSpec status to 'generated' and save content with parsed tasks
await this.updateFeaturePlanSpec(projectPath, featureId, {
status: 'generated',
status: "generated",
content: planContent,
version: 1,
generatedAt: new Date().toISOString(),
@@ -1996,13 +2091,18 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
let planApproved = false;
while (!planApproved) {
console.log(`[AutoMode] Spec v${planVersion} generated for feature ${featureId}, waiting for approval`);
console.log(
`[AutoMode] Spec v${planVersion} generated for feature ${featureId}, waiting for approval`
);
// CRITICAL: Register pending approval BEFORE emitting event
const approvalPromise = this.waitForPlanApproval(featureId, projectPath);
const approvalPromise = this.waitForPlanApproval(
featureId,
projectPath
);
// Emit plan_approval_required event
this.emitAutoModeEvent('plan_approval_required', {
this.emitAutoModeEvent("plan_approval_required", {
featureId,
projectPath,
planContent: currentPlanContent,
@@ -2016,15 +2116,21 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
if (approvalResult.approved) {
// User approved the plan
console.log(`[AutoMode] Plan v${planVersion} approved for feature ${featureId}`);
console.log(
`[AutoMode] Plan v${planVersion} approved for feature ${featureId}`
);
planApproved = true;
// If user provided edits, use the edited version
if (approvalResult.editedPlan) {
approvedPlanContent = approvalResult.editedPlan;
await this.updateFeaturePlanSpec(projectPath, featureId, {
content: approvalResult.editedPlan,
});
await this.updateFeaturePlanSpec(
projectPath,
featureId,
{
content: approvalResult.editedPlan,
}
);
} else {
approvedPlanContent = currentPlanContent;
}
@@ -2033,30 +2139,37 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
userFeedback = approvalResult.feedback;
// Emit approval event
this.emitAutoModeEvent('plan_approved', {
this.emitAutoModeEvent("plan_approved", {
featureId,
projectPath,
hasEdits: !!approvalResult.editedPlan,
planVersion,
});
} else {
// User rejected - check if they provided feedback for revision
const hasFeedback = approvalResult.feedback && approvalResult.feedback.trim().length > 0;
const hasEdits = approvalResult.editedPlan && approvalResult.editedPlan.trim().length > 0;
const hasFeedback =
approvalResult.feedback &&
approvalResult.feedback.trim().length > 0;
const hasEdits =
approvalResult.editedPlan &&
approvalResult.editedPlan.trim().length > 0;
if (!hasFeedback && !hasEdits) {
// No feedback or edits = explicit cancel
console.log(`[AutoMode] Plan rejected without feedback for feature ${featureId}, cancelling`);
throw new Error('Plan cancelled by user');
console.log(
`[AutoMode] Plan rejected without feedback for feature ${featureId}, cancelling`
);
throw new Error("Plan cancelled by user");
}
// User wants revisions - regenerate the plan
console.log(`[AutoMode] Plan v${planVersion} rejected with feedback for feature ${featureId}, regenerating...`);
console.log(
`[AutoMode] Plan v${planVersion} rejected with feedback for feature ${featureId}, regenerating...`
);
planVersion++;
// Emit revision event
this.emitAutoModeEvent('plan_revision_requested', {
this.emitAutoModeEvent("plan_revision_requested", {
featureId,
projectPath,
feedback: approvalResult.feedback,
@@ -2071,7 +2184,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
${hasEdits ? approvalResult.editedPlan : currentPlanContent}
## User Feedback
${approvalResult.feedback || 'Please revise the plan based on the edits above.'}
${approvalResult.feedback || "Please revise the plan based on the edits above."}
## Instructions
Please regenerate the specification incorporating the user's feedback.
@@ -2082,7 +2195,7 @@ After generating the revised spec, output:
// Update status to regenerating
await this.updateFeaturePlanSpec(projectPath, featureId, {
status: 'generating',
status: "generating",
version: planVersion,
});
@@ -2109,27 +2222,38 @@ After generating the revised spec, output:
}
}
} else if (msg.type === "error") {
throw new Error(msg.error || "Error during plan revision");
} else if (msg.type === "result" && msg.subtype === "success") {
throw new Error(
msg.error || "Error during plan revision"
);
} else if (
msg.type === "result" &&
msg.subtype === "success"
) {
revisionText += msg.result || "";
}
}
// Extract new plan content
const markerIndex = revisionText.indexOf('[SPEC_GENERATED]');
const markerIndex =
revisionText.indexOf("[SPEC_GENERATED]");
if (markerIndex > 0) {
currentPlanContent = revisionText.substring(0, markerIndex).trim();
currentPlanContent = revisionText
.substring(0, markerIndex)
.trim();
} else {
currentPlanContent = revisionText.trim();
}
// Re-parse tasks from revised plan
const revisedTasks = parseTasksFromSpec(currentPlanContent);
console.log(`[AutoMode] Revised plan has ${revisedTasks.length} tasks`);
const revisedTasks =
parseTasksFromSpec(currentPlanContent);
console.log(
`[AutoMode] Revised plan has ${revisedTasks.length} tasks`
);
// Update planSpec with revised content
await this.updateFeaturePlanSpec(projectPath, featureId, {
status: 'generated',
status: "generated",
content: currentPlanContent,
version: planVersion,
tasks: revisedTasks,
@@ -2142,21 +2266,23 @@ After generating the revised spec, output:
responseText += revisionText;
}
} catch (error) {
if ((error as Error).message.includes('cancelled')) {
if ((error as Error).message.includes("cancelled")) {
throw error;
}
throw new Error(`Plan approval failed: ${(error as Error).message}`);
throw new Error(
`Plan approval failed: ${(error as Error).message}`
);
}
}
} else {
// Auto-approve: requirePlanApproval is false, just continue without pausing
console.log(`[AutoMode] Spec generated for feature ${featureId}, auto-approving (requirePlanApproval=false)`);
console.log(
`[AutoMode] Spec generated for feature ${featureId}, auto-approving (requirePlanApproval=false)`
);
// Emit info event for frontend
this.emitAutoModeEvent('plan_auto_approved', {
this.emitAutoModeEvent("plan_auto_approved", {
featureId,
projectPath,
planContent,
@@ -2168,11 +2294,13 @@ After generating the revised spec, output:
// CRITICAL: After approval, we need to make a second call to continue implementation
// The agent is waiting for "approved" - we need to send it and continue
console.log(`[AutoMode] Making continuation call after plan approval for feature ${featureId}`);
console.log(
`[AutoMode] Making continuation call after plan approval for feature ${featureId}`
);
// Update planSpec status to approved (handles both manual and auto-approval paths)
await this.updateFeaturePlanSpec(projectPath, featureId, {
status: 'approved',
status: "approved",
approvedAt: new Date().toISOString(),
reviewedByUser: requiresApproval,
});
@@ -2183,19 +2311,27 @@ After generating the revised spec, output:
// ========================================
if (parsedTasks.length > 0) {
console.log(`[AutoMode] Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}`);
console.log(
`[AutoMode] Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}`
);
// Execute each task with a separate agent
for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) {
for (
let taskIndex = 0;
taskIndex < parsedTasks.length;
taskIndex++
) {
const task = parsedTasks[taskIndex];
// Check for abort
if (abortController.signal.aborted) {
throw new Error('Feature execution aborted');
throw new Error("Feature execution aborted");
}
// Emit task started
console.log(`[AutoMode] Starting task ${task.id}: ${task.description}`);
console.log(
`[AutoMode] Starting task ${task.id}: ${task.description}`
);
this.emitAutoModeEvent("auto_mode_task_started", {
featureId,
projectPath,
@@ -2211,7 +2347,13 @@ After generating the revised spec, output:
});
// Build focused prompt for this specific task
const taskPrompt = this.buildTaskPrompt(task, parsedTasks, taskIndex, approvedPlanContent, userFeedback);
const taskPrompt = this.buildTaskPrompt(
task,
parsedTasks,
taskIndex,
approvedPlanContent,
userFeedback
);
// Execute task with dedicated agent
const taskStream = provider.executeQuery({
@@ -2245,15 +2387,22 @@ After generating the revised spec, output:
}
}
} else if (msg.type === "error") {
throw new Error(msg.error || `Error during task ${task.id}`);
} else if (msg.type === "result" && msg.subtype === "success") {
throw new Error(
msg.error || `Error during task ${task.id}`
);
} else if (
msg.type === "result" &&
msg.subtype === "success"
) {
taskOutput += msg.result || "";
responseText += msg.result || "";
}
}
// Emit task completed
console.log(`[AutoMode] Task ${task.id} completed for feature ${featureId}`);
console.log(
`[AutoMode] Task ${task.id} completed for feature ${featureId}`
);
this.emitAutoModeEvent("auto_mode_task_complete", {
featureId,
projectPath,
@@ -2284,13 +2433,17 @@ After generating the revised spec, output:
}
}
console.log(`[AutoMode] All ${parsedTasks.length} tasks completed for feature ${featureId}`);
console.log(
`[AutoMode] All ${parsedTasks.length} tasks completed for feature ${featureId}`
);
} else {
// No parsed tasks - fall back to single-agent execution
console.log(`[AutoMode] No parsed tasks, using single-agent execution for feature ${featureId}`);
console.log(
`[AutoMode] No parsed tasks, using single-agent execution for feature ${featureId}`
);
const continuationPrompt = `The plan/specification has been approved. Now implement it.
${userFeedback ? `\n## User Feedback\n${userFeedback}\n` : ''}
${userFeedback ? `\n## User Feedback\n${userFeedback}\n` : ""}
## Approved Plan
${approvedPlanContent}
@@ -2326,14 +2479,21 @@ Implement all the changes described in the plan above.`;
}
}
} else if (msg.type === "error") {
throw new Error(msg.error || "Unknown error during implementation");
} else if (msg.type === "result" && msg.subtype === "success") {
throw new Error(
msg.error || "Unknown error during implementation"
);
} else if (
msg.type === "result" &&
msg.subtype === "success"
) {
responseText += msg.result || "";
}
}
}
console.log(`[AutoMode] Implementation completed for feature ${featureId}`);
console.log(
`[AutoMode] Implementation completed for feature ${featureId}`
);
// Exit the original stream loop since continuation is done
break streamLoop;
}
@@ -2410,9 +2570,16 @@ ${context}
## Instructions
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
continuationPrompt: prompt,
});
return this.executeFeature(
projectPath,
featureId,
useWorktrees,
false,
undefined,
{
continuationPrompt: prompt,
}
);
}
/**
@@ -2437,8 +2604,8 @@ You are executing a specific task as part of a larger feature implementation.
**Task ID:** ${task.id}
**Description:** ${task.description}
${task.filePath ? `**Primary File:** ${task.filePath}` : ''}
${task.phase ? `**Phase:** ${task.phase}` : ''}
${task.filePath ? `**Primary File:** ${task.filePath}` : ""}
${task.phase ? `**Phase:** ${task.phase}` : ""}
## Context
@@ -2447,7 +2614,7 @@ ${task.phase ? `**Phase:** ${task.phase}` : ''}
// 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')}
${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join("\n")}
`;
}
@@ -2455,8 +2622,11 @@ ${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` : ''}
${remainingTasks
.slice(0, 3)
.map((t) => `- [ ] ${t.id}: ${t.description}`)
.join("\n")}
${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ""}
`;
}

View File

@@ -4,49 +4,20 @@
*/
import path from "path";
import type { Feature } from "@automaker/types";
import { createLogger } from "@automaker/utils";
import * as secureFs from "../lib/secure-fs.js";
import {
getFeaturesDir,
getFeatureDir,
getFeatureImagesDir,
ensureAutomakerDir,
} from "../lib/automaker-paths.js";
} from "@automaker/platform";
export interface Feature {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps?: string[];
passes?: boolean;
priority?: number;
status?: string;
dependencies?: string[];
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean;
thinkingLevel?: string;
planningMode?: 'skip' | 'lite' | 'spec' | 'full';
requirePlanApproval?: boolean;
planSpec?: {
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
content?: string;
version: number;
generatedAt?: string;
approvedAt?: string;
reviewedByUser: boolean;
tasksCompleted?: number;
tasksTotal?: number;
};
error?: string;
summary?: string;
startedAt?: string;
[key: string]: unknown; // Keep catch-all for extensibility
}
const logger = createLogger("FeatureLoader");
// Re-export Feature type for convenience
export type { Feature };
export class FeatureLoader {
/**
@@ -68,8 +39,12 @@ export class FeatureLoader {
*/
private async deleteOrphanedImages(
projectPath: string,
oldPaths: Array<string | { path: string; [key: string]: unknown }> | undefined,
newPaths: Array<string | { path: string; [key: string]: unknown }> | undefined
oldPaths:
| Array<string | { path: string; [key: string]: unknown }>
| undefined,
newPaths:
| Array<string | { path: string; [key: string]: unknown }>
| undefined
): Promise<void> {
if (!oldPaths || oldPaths.length === 0) {
return;
@@ -92,7 +67,7 @@ export class FeatureLoader {
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
} catch (error) {
// Ignore errors when deleting (file may already be gone)
console.warn(
logger.warn(
`[FeatureLoader] Failed to delete image: ${oldPath}`,
error
);
@@ -118,8 +93,9 @@ export class FeatureLoader {
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
await secureFs.mkdir(featureImagesDir, { recursive: true });
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> =
[];
const updatedPaths: Array<
string | { path: string; [key: string]: unknown }
> = [];
for (const imagePath of imagePaths) {
try {
@@ -141,7 +117,7 @@ export class FeatureLoader {
try {
await secureFs.access(fullOriginalPath);
} catch {
console.warn(
logger.warn(
`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`
);
continue;
@@ -171,9 +147,10 @@ export class FeatureLoader {
updatedPaths.push({ ...imagePath, path: newPath });
}
} catch (error) {
console.error(`[FeatureLoader] Failed to migrate image:`, error);
// Keep original path if migration fails
updatedPaths.push(imagePath);
logger.error(`Failed to migrate image:`, error);
// Rethrow error to let caller decide how to handle it
// Keeping original path could lead to broken references
throw error;
}
}
@@ -191,14 +168,20 @@ export class FeatureLoader {
* Get the path to a feature's feature.json file
*/
getFeatureJsonPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "feature.json");
return path.join(
this.getFeatureDir(projectPath, featureId),
"feature.json"
);
}
/**
* Get the path to a feature's agent-output.md file
*/
getAgentOutputPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md");
return path.join(
this.getFeatureDir(projectPath, featureId),
"agent-output.md"
);
}
/**
@@ -223,7 +206,9 @@ export class FeatureLoader {
}
// Read all feature directories
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }) as any[];
const entries = (await secureFs.readdir(featuresDir, {
withFileTypes: true,
})) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
@@ -233,11 +218,14 @@ export class FeatureLoader {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await secureFs.readFile(featureJsonPath, "utf-8") as string;
const content = (await secureFs.readFile(
featureJsonPath,
"utf-8"
)) as string;
const feature = JSON.parse(content);
if (!feature.id) {
console.warn(
logger.warn(
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
);
continue;
@@ -248,11 +236,11 @@ export class FeatureLoader {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue;
} else if (error instanceof SyntaxError) {
console.warn(
logger.warn(
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
);
} else {
console.error(
logger.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
(error as Error).message
);
@@ -269,7 +257,7 @@ export class FeatureLoader {
return features;
} catch (error) {
console.error("[FeatureLoader] Failed to get all features:", error);
logger.error("Failed to get all features:", error);
return [];
}
}
@@ -280,13 +268,16 @@ export class FeatureLoader {
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await secureFs.readFile(featureJsonPath, "utf-8") as string;
const content = (await secureFs.readFile(
featureJsonPath,
"utf-8"
)) as string;
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
console.error(
logger.error(
`[FeatureLoader] Failed to get feature ${featureId}:`,
error
);
@@ -334,7 +325,7 @@ export class FeatureLoader {
"utf-8"
);
console.log(`[FeatureLoader] Created feature ${featureId}`);
logger.info(`Created feature ${featureId}`);
return feature;
}
@@ -386,7 +377,7 @@ export class FeatureLoader {
"utf-8"
);
console.log(`[FeatureLoader] Updated feature ${featureId}`);
logger.info(`Updated feature ${featureId}`);
return updatedFeature;
}
@@ -400,7 +391,7 @@ export class FeatureLoader {
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
console.error(
logger.error(
`[FeatureLoader] Failed to delete feature ${featureId}:`,
error
);
@@ -417,13 +408,16 @@ export class FeatureLoader {
): Promise<string | null> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await secureFs.readFile(agentOutputPath, "utf-8") as string;
const content = (await secureFs.readFile(
agentOutputPath,
"utf-8"
)) as string;
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
console.error(
logger.error(
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
error
);

View File

@@ -7,15 +7,16 @@
* - Per-project settings ({projectPath}/.automaker/settings.json)
*/
import { createLogger } from "@automaker/utils";
import * as secureFs from "../lib/secure-fs.js";
import { createLogger } from "../lib/logger.js";
import {
getGlobalSettingsPath,
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
ensureAutomakerDir,
} from "../lib/automaker-paths.js";
} from "@automaker/platform";
import type {
GlobalSettings,
Credentials,
@@ -64,7 +65,7 @@ async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = await secureFs.readFile(filePath, "utf-8") as string;
const content = (await secureFs.readFile(filePath, "utf-8")) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -231,9 +232,7 @@ export class SettingsService {
* @param updates - Partial Credentials (usually just apiKeys)
* @returns Promise resolving to complete updated Credentials object
*/
async updateCredentials(
updates: Partial<Credentials>
): Promise<Credentials> {
async updateCredentials(updates: Partial<Credentials>): Promise<Credentials> {
await ensureDataDir(this.dataDir);
const credentialsPath = getCredentialsPath(this.dataDir);
@@ -495,10 +494,14 @@ export class SettingsService {
if (appState.apiKeys) {
const apiKeys = appState.apiKeys as {
anthropic?: string;
google?: string;
openai?: string;
};
await this.updateCredentials({
apiKeys: {
anthropic: apiKeys.anthropic || "",
google: apiKeys.google || "",
openai: apiKeys.openai || "",
},
});
migratedCredentials = true;
@@ -548,8 +551,7 @@ export class SettingsService {
// Get theme from project object
const project = projects.find((p) => p.path === projectPath);
if (project?.theme) {
projectSettings.theme =
project.theme as ProjectSettings["theme"];
projectSettings.theme = project.theme as ProjectSettings["theme"];
}
if (boardBackgroundByProject?.[projectPath]) {
@@ -571,7 +573,9 @@ export class SettingsService {
migratedProjectCount++;
}
} catch (e) {
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
errors.push(
`Failed to migrate project settings for ${projectPath}: ${e}`
);
}
}