#!/usr/bin/env npx tsx /** * AI Product Manager - Implementation Script * * This script implements an approved idea by: * 1. Cloning/updating the repo in a separate workspace * 2. Creating a feature branch * 3. Running Claude Code to implement the changes * 4. Creating a PR * * Usage: * npm run implement -- --idea=idea-demo003 * npx tsx scripts/implement.ts --idea=idea-demo003 */ import { execFileSync, spawn } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { cleanGitState, exec, execGit } from './lib/git-utils'; import { validateBranchName, sanitizeForCommitMessage } from './lib/shell-safe'; import { atomicWriteFileSync } from './lib/json-lock'; import { requireClaudeCLI, cleanEnvForClaude } from './lib/ai-provider'; // Types (duplicated to avoid import issues with tsx) interface RepoConfig { name: string; path: string; type: 'nextjs' | 'fastapi' | 'nodejs'; github_url?: string; default_branch?: string; test_commands?: string[]; } interface AutonomyConfig { enabled: boolean; max_sub_task_timeout_ms: number; max_sub_tasks_per_idea: number; max_retries_per_sub_task: number; } interface AnalystConfig { repos: RepoConfig[]; workspace_dir: string; schedules: Record; autonomy?: AutonomyConfig; } interface BusinessIdea { id: string; title: string; summary: string; category: string; implementation_plan: string; success_metrics: string[]; source: { files_analyzed?: string[]; }; implementation: { branch_name: string | null; pr_url: string | null; pr_number: number | null; }; } interface ImplementationSession { id: string; idea_id: string; status: string; error_message?: string; repo_name: string; branch_name: string; workspace_path: string; logs: string[]; } // Paths — centralized in lib/paths.ts (uses process.cwd() for portability) import { IDEAS_FILE, CONFIG_FILE, IMPLEMENTATIONS_FILE, } from './lib/paths'; // Parse command line arguments interface ParsedArgs { ideaId: string; repoName?: string; scope?: string; // Base64-encoded sub-task description (JSON: {description, files_to_modify}) skipPr: boolean; // Implement + commit but don't create PR createPrOnly: boolean; // Just create PR from existing branch timeout: number; // Claude Code timeout in ms model?: string; // Claude model to use (e.g., 'sonnet', 'haiku') workspacePath?: string; // Override workspace path (used by worktree orchestrator) } function parseArgs(): ParsedArgs { const args = process.argv.slice(2); let ideaId = ''; let repoName: string | undefined; let scope: string | undefined; let skipPr = false; let createPrOnly = false; let timeout = 300000; // 5 minutes default let model: string | undefined; let workspacePath: string | undefined; for (const arg of args) { if (arg.startsWith('--idea=')) { ideaId = arg.split('=')[1]; } if (arg.startsWith('--repo=')) { repoName = arg.split('=')[1]; } if (arg.startsWith('--scope=')) { scope = arg.substring('--scope='.length); } if (arg === '--skip-pr') { skipPr = true; } if (arg === '--create-pr-only') { createPrOnly = true; } if (arg.startsWith('--timeout=')) { timeout = Math.max(60000, parseInt(arg.split('=')[1], 10) || 300000); // min 60s } if (arg.startsWith('--model=')) { model = arg.split('=')[1]; } if (arg.startsWith('--workspace-path=')) { workspacePath = arg.substring('--workspace-path='.length); } } if (!ideaId) { console.error('Usage: npm run implement -- --idea= [--repo=] [--scope=] [--skip-pr] [--create-pr-only] [--timeout=] [--model=] [--workspace-path=]'); process.exit(1); } return { ideaId, repoName, scope, skipPr, createPrOnly, timeout, model, workspacePath }; } // Load JSON file (returns defaultValue if file doesn't exist) function loadJson(filePath: string, defaultValue?: T): T { try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { if (defaultValue !== undefined) return defaultValue; throw new Error(`Failed to load ${filePath}`); } } // Save JSON file (atomic write to prevent readers seeing truncated content) function saveJson(filePath: string, data: T): void { atomicWriteFileSync(filePath, JSON.stringify(data, null, 2)); } // Load config function loadConfig(): AnalystConfig { return loadJson(CONFIG_FILE); } // Load idea function loadIdea(ideaId: string): BusinessIdea | null { const data = loadJson<{ ideas: BusinessIdea[] }>(IDEAS_FILE); return data.ideas.find((i) => i.id === ideaId) || null; } // Update idea function updateIdea(ideaId: string, updates: Partial): void { const data = loadJson<{ ideas: BusinessIdea[] }>(IDEAS_FILE); const index = data.ideas.findIndex((i) => i.id === ideaId); if (index !== -1) { data.ideas[index].implementation = { ...data.ideas[index].implementation, ...updates, }; saveJson(IDEAS_FILE, data); } } // Revert idea stage from 'in_progress' back to 'approved' on failure export function revertIdeaStage(ideaId: string, reason: string): void { try { const data = loadJson<{ ideas: Array<{ id: string; stage: string; comments: Array<{ id: string; author: string; body: string; created_at: string }> }> }>(IDEAS_FILE); const index = data.ideas.findIndex((i) => i.id === ideaId); if (index !== -1) { data.ideas[index].stage = 'approved'; if (!data.ideas[index].comments) data.ideas[index].comments = []; data.ideas[index].comments.push({ id: `comment-${Date.now()}`, author: 'system', body: reason, created_at: new Date().toISOString(), }); saveJson(IDEAS_FILE, data); } } catch (e) { console.error('[implement] Failed to revert idea stage', { ideaId, error: (e as Error).message }); } } // Create/update implementation session function createImplementation( ideaId: string, repoName: string, branchName: string, workspacePath: string ): ImplementationSession { const data = loadJson<{ implementations: ImplementationSession[] }>(IMPLEMENTATIONS_FILE, { implementations: [] }); const now = new Date().toISOString(); const impl: ImplementationSession = { id: `impl-${Date.now().toString(36)}`, idea_id: ideaId, status: 'cloning', repo_name: repoName, branch_name: branchName, workspace_path: workspacePath, logs: [`[${now}] Implementation started`], }; data.implementations.push(impl); saveJson(IMPLEMENTATIONS_FILE, data); return impl; } export function updateImplementation(id: string, updates: Partial): void { const data = loadJson<{ implementations: ImplementationSession[] }>(IMPLEMENTATIONS_FILE, { implementations: [] }); const index = data.implementations.findIndex((i) => i.id === id); if (index !== -1) { data.implementations[index] = { ...data.implementations[index], ...updates }; saveJson(IMPLEMENTATIONS_FILE, data); } } function addLog(implId: string, message: string): void { const data = loadJson<{ implementations: ImplementationSession[] }>(IMPLEMENTATIONS_FILE, { implementations: [] }); const index = data.implementations.findIndex((i) => i.id === implId); if (index !== -1) { const timestamp = new Date().toISOString(); data.implementations[index].logs.push(`[${timestamp}] ${message}`); saveJson(IMPLEMENTATIONS_FILE, data); } console.log(message); } // cleanGitState and exec are imported from ./lib/git-utils // Generate slug from title function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 40); } // Detect which repo the idea targets based on files_analyzed function detectTargetRepo(idea: BusinessIdea, config: AnalystConfig): RepoConfig | null { const filesAnalyzed = idea.source.files_analyzed || []; for (const file of filesAnalyzed) { for (const repo of config.repos) { if (file.includes(repo.name) || file.includes(path.basename(repo.path))) { return repo; } } } // Default to backend for security/api ideas, frontend for ux ideas if (idea.category === 'security' || idea.category === 'performance') { return config.repos.find((r) => r.type === 'fastapi') || config.repos[0]; } if (idea.category === 'ux_design') { return config.repos.find((r) => r.type === 'nextjs') || config.repos[0]; } return config.repos[0]; } // Clone or update repo in workspace function setupWorkspace(repo: RepoConfig, workspaceDir: string, implId: string): string { const repoWorkspace = path.join(workspaceDir, repo.name); // Create workspace directory if it doesn't exist if (!fs.existsSync(workspaceDir)) { addLog(implId, `Creating workspace directory: ${workspaceDir}`); fs.mkdirSync(workspaceDir, { recursive: true }); } if (fs.existsSync(repoWorkspace)) { // Pre-flight: clean up stale git state before operations cleanGitState(repoWorkspace); // Fix origin if it points to a local path instead of GitHub if (repo.github_url) { try { const currentOrigin = exec('git remote get-url origin', repoWorkspace); if (!currentOrigin.includes('github.com')) { addLog(implId, `Repointing origin from local path to ${repo.github_url}`); execGit(['remote', 'set-url', 'origin', repo.github_url], repoWorkspace); } } catch { // If remote doesn't exist, add it execGit(['remote', 'add', 'origin', repo.github_url], repoWorkspace); } } // Update existing clone addLog(implId, `Updating existing clone at ${repoWorkspace}`); exec('git fetch origin', repoWorkspace); execGit(['checkout', repo.default_branch || 'main'], repoWorkspace); execGit(['pull', 'origin', repo.default_branch || 'main'], repoWorkspace); } else { // Clone fresh — prefer github_url, fall back to local path const cloneSource = repo.github_url || repo.path; if (!cloneSource) { throw new Error(`No github_url or path configured for repo: ${repo.name}`); } addLog(implId, `Cloning ${cloneSource} to ${repoWorkspace}`); execGit(['clone', cloneSource, repoWorkspace]); } return repoWorkspace; } // Create feature branch function createBranch(workspacePath: string, branchName: string, defaultBranch: string, implId: string): void { validateBranchName(branchName); addLog(implId, `Creating branch: ${branchName}`); // Make sure we're on the default branch execGit(['checkout', defaultBranch], workspacePath); // Check if branch already exists try { execGit(['rev-parse', '--verify', branchName], workspacePath); // Branch exists, check it out addLog(implId, `Branch ${branchName} already exists, checking out`); execGit(['checkout', branchName], workspacePath); } catch { // Branch doesn't exist, create it execGit(['checkout', '-b', branchName], workspacePath); } } // Generate implementation prompt function generatePrompt(idea: BusinessIdea, scopeBase64?: string): string { // Scoped prompt: focused on a single sub-task if (scopeBase64) { const scopeRaw = Buffer.from(scopeBase64, 'base64').toString('utf-8'); // Try parsing as JSON (new format: {description, files_to_modify}) let scopeDescription: string; let filesToModify: string[] = []; let observability: string | undefined; try { const parsed = JSON.parse(scopeRaw); scopeDescription = parsed.description; filesToModify = parsed.files_to_modify || []; observability = parsed.observability; } catch { // Likely corrupted base64 or legacy plain text format console.error(`[WARN] Scope is not valid JSON (${scopeRaw.length} chars), treating as plain text`); scopeDescription = scopeRaw; } const filesSection = filesToModify.length > 0 ? `\n## Files to Modify\n\nOnly touch these files:\n${filesToModify.map((f) => `- ${f}`).join('\n')}\n` : ''; const observabilitySection = observability ? `\n## Observability Requirements\n\n${observability}\n` : ''; return `You are implementing a specific sub-task. ## Feature: ${idea.title} ${idea.summary} ## Your Specific Task ${scopeDescription} ${filesSection}${observabilitySection} ## Instructions 1. Focus ONLY on this specific sub-task — do NOT explore the broader codebase 2. Go directly to the files listed above and make the required changes 3. Keep changes minimal — only modify what is strictly necessary 4. Do NOT create new files unless the task explicitly requires it 5. Do NOT refactor or improve surrounding code 6. Add proper error handling: wrap external calls in try/catch, log errors with context 7. Add structured logging at service boundaries (entry, exit, errors) 8. If there is an "Observability Requirements" section above, follow those instructions exactly IMPORTANT: - Make real code changes, not just comments or TODOs - Do NOT modify files outside the scope of this sub-task - Be fast and direct — read the target file, make the edit, done - Do NOT run pip install, npm install, or any package manager commands - Do NOT run the application or tests — that is handled separately - Do NOT create any files that are not in the "Files to Modify" list `; } // Full prompt: original behavior return `You are implementing an improvement. ## Task: ${idea.title} ${idea.summary} ## Implementation Plan ${idea.implementation_plan} ## Success Metrics ${idea.success_metrics.map((m) => `- ${m}`).join('\n')} ## Instructions 1. Analyze the codebase to understand the current implementation 2. Implement the changes following the plan above 3. Make atomic commits with clear messages 4. Ensure the code follows existing patterns and conventions 5. Do NOT break existing functionality 6. Add structured logging at key points (service entry/exit, errors, data validation) 7. Add error handling with contextual logging (don't silently swallow errors) 8. For performance-critical code, add timing instrumentation When you're done, provide a summary of the changes made. IMPORTANT: - Make real code changes, not just comments or TODOs - Test that the code compiles/runs if possible - Keep changes focused on the task `; } // Run Claude Code to implement changes async function runImplementation( workspacePath: string, prompt: string, implId: string, timeoutMs: number = 300000, options: { model?: string; scoped?: boolean } = {} ): Promise { requireClaudeCLI('implementation'); const modelLabel = options.model ? ` (model: ${options.model})` : ''; addLog(implId, `Running Claude Code to implement changes (timeout: ${timeoutMs / 1000}s${modelLabel})...`); const claudeArgs = [ '--print', '--dangerously-skip-permissions', ]; // Use a faster model for scoped sub-tasks if (options.model) { claudeArgs.push('--model', options.model); } // Restrict tools for scoped sub-tasks to prevent excessive exploration if (options.scoped) { claudeArgs.push('--allowedTools', 'Read Edit Write Bash'); } claudeArgs.push('-p', prompt); return new Promise((resolve, reject) => { const claude = spawn('claude', claudeArgs, { cwd: workspacePath, env: cleanEnvForClaude(), stdio: ['ignore', 'pipe', 'pipe'], // stdin ignored — Claude uses -p prompt, not interactive input }); let output = ''; let stderrOutput = ''; let isResolved = false; let cleaned = false; const startTime = Date.now(); let lastOutputTime = Date.now(); // Consolidated cleanup — called from all exit paths exactly once function cleanup() { if (cleaned) return; cleaned = true; clearTimeout(timeoutTimer); clearInterval(progressInterval); } // Progress indicator — log elapsed time every 30 seconds if no output const progressInterval = setInterval(() => { if (!isResolved) { const elapsed = Math.round((Date.now() - startTime) / 1000); const sinceLast = Math.round((Date.now() - lastOutputTime) / 1000); const outputLen = output.length; addLog(implId, ` ...Claude Code working (${elapsed}s elapsed, ${outputLen} chars output, ${sinceLast}s since last output)`); } }, 30000); // Timeout handler: SIGTERM, then SIGKILL after 5s grace period const timeoutTimer = setTimeout(() => { if (isResolved) return; isResolved = true; cleanup(); addLog(implId, `Claude Code timed out after ${timeoutMs / 1000}s (${output.length} chars output), sending SIGTERM`); if (stderrOutput.length > 0) { addLog(implId, `Claude Code stderr (at timeout): ${stderrOutput.slice(-2000)}`); } claude.kill('SIGTERM'); setTimeout(() => { try { claude.kill('SIGKILL'); } catch {} }, 5000); reject(new Error(`Implementation timed out after ${timeoutMs / 1000}s`)); }, timeoutMs); claude.stdout.on('data', (data) => { const text = data.toString(); output += text; lastOutputTime = Date.now(); process.stdout.write(text); }); claude.stderr.on('data', (data) => { const text = data.toString(); stderrOutput += text; process.stderr.write(text); }); claude.on('close', (code) => { if (isResolved) return; isResolved = true; cleanup(); const elapsed = Math.round((Date.now() - startTime) / 1000); if (code === 0) { addLog(implId, `Claude Code completed successfully in ${elapsed}s (${output.length} chars)`); resolve(); } else { addLog(implId, `Claude Code exited with code ${code} after ${elapsed}s`); if (stderrOutput.length > 0) { addLog(implId, `Claude Code stderr: ${stderrOutput.slice(-2000)}`); } reject(new Error(`Claude Code exited with code ${code}`)); } }); claude.on('error', (error) => { if (isResolved) return; isResolved = true; cleanup(); addLog(implId, `Claude Code error: ${error.message}`); if (stderrOutput.length > 0) { addLog(implId, `Claude Code stderr: ${stderrOutput.slice(-2000)}`); } reject(error); }); }); } // Check if there are changes to commit function hasChanges(workspacePath: string): boolean { try { const status = exec('git status --porcelain', workspacePath); return status.length > 0; } catch { return false; } } // Create PR using gh CLI function createPR( workspacePath: string, branchName: string, defaultBranch: string, idea: BusinessIdea, implId: string ): { url: string; number: number } | null { // Check if there are commits ahead of default branch try { const ahead = execGit(['rev-list', '--count', `${defaultBranch}..${branchName}`], workspacePath); if (parseInt(ahead, 10) === 0) { // Diagnostic: log HEAD commits to help diagnose if branch and main are the same try { const branchHead = execGit(['rev-parse', '--short', branchName], workspacePath); const mainHead = execGit(['rev-parse', '--short', defaultBranch], workspacePath); addLog(implId, `No commits to push — branch HEAD: ${branchHead}, ${defaultBranch} HEAD: ${mainHead} (same=${branchHead === mainHead})`); } catch { addLog(implId, 'No commits to push, skipping PR creation'); } return null; } } catch { addLog(implId, 'Could not determine commit count, attempting PR anyway'); } addLog(implId, `Pushing branch ${branchName} to origin`); execGit(['push', '-u', 'origin', branchName], workspacePath, 30000); addLog(implId, 'Creating PR with gh CLI'); const title = `[AI] ${idea.title}`; const body = `## Summary ${idea.summary} ## Implementation Plan ${idea.implementation_plan} ## Success Metrics ${idea.success_metrics.map((m) => `- ${m}`).join('\n')} --- This PR was automatically generated by the AI Product Manager. Idea ID: \`${idea.id}\` `; // Write body to temp file to avoid shell metacharacter issues (backticks, $(), etc.) const bodyFile = path.join(os.tmpdir(), `pr-body-${implId}.md`); fs.writeFileSync(bodyFile, body); try { const prOutput = execFileSync( 'gh', ['pr', 'create', '--title', title, '--body-file', bodyFile, '--base', defaultBranch], { cwd: workspacePath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000 } ).trim(); // Extract PR URL and number const urlMatch = prOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/); if (urlMatch) { return { url: urlMatch[0], number: parseInt(urlMatch[1], 10), }; } addLog(implId, `PR created: ${prOutput}`); return { url: prOutput, number: 0 }; } catch (error: unknown) { const err = error as Error; addLog(implId, `Failed to create PR: ${err.message}`); return null; } finally { try { fs.unlinkSync(bodyFile); } catch { /* ignore cleanup errors */ } } } // Main function async function main(): Promise { const { ideaId, repoName, scope, skipPr, createPrOnly, timeout, model, workspacePath: workspacePathOverride } = parseArgs(); console.log(`\n=== AI Product Manager - Implementation ===\n`); console.log(`Idea ID: ${ideaId}`); if (scope) console.log(`Mode: Scoped sub-task`); if (skipPr) console.log(`Mode: Skip PR creation`); if (createPrOnly) console.log(`Mode: Create PR only`); // Load config and idea let config: ReturnType; try { config = loadConfig(); } catch (err: unknown) { const e = err as Error; console.error('[implement] Failed to load config', { error: e.message, file: CONFIG_FILE }); console.error(`[implement] Cannot continue without config — exiting. Check that ${CONFIG_FILE} exists and contains valid JSON.`); process.exit(1); } let idea: ReturnType; try { idea = loadIdea(ideaId); } catch (err: unknown) { const e = err as Error; console.error('[implement] Failed to load idea', { ideaId, error: e.message, file: IDEAS_FILE }); console.error(`[implement] Cannot continue without idea record — exiting. Check that ${IDEAS_FILE} exists and contains valid JSON.`); process.exit(1); } if (!idea) { console.error('[implement] Failed to load idea', { ideaId, error: 'idea not found in ideas file', file: IDEAS_FILE }); console.error(`[implement] Idea ${ideaId} not found — exiting.`); process.exit(1); } console.log(`Title: ${idea.title}`); console.log(`Category: ${idea.category}`); // Determine target repo let targetRepo: RepoConfig | null; if (repoName) { targetRepo = config.repos.find((r) => r.name === repoName) || null; if (!targetRepo) { console.error(`Repo not found: ${repoName}`); console.error(`Available repos: ${config.repos.map((r) => r.name).join(', ')}`); process.exit(1); } } else { targetRepo = detectTargetRepo(idea, config); } if (!targetRepo) { console.error('Could not determine target repository'); process.exit(1); } console.log(`Target repo: ${targetRepo.name}`); // Use stored branch name (from worktree orchestrator) or generate one const branchName = idea.implementation.branch_name || `ai/${idea.id}-${slugify(idea.title)}`; const defaultBranch = targetRepo.default_branch || 'main'; console.log(`Branch: ${branchName}`); console.log(`Workspace: ${config.workspace_dir}`); // Try to reuse an existing implementation session let implData: { implementations: ImplementationSession[] }; try { implData = loadJson<{ implementations: ImplementationSession[] }>(IMPLEMENTATIONS_FILE); } catch (err: unknown) { const e = err as Error; console.error('[implement] Failed to load implementations file, falling back to fresh session', { error: e.message, file: IMPLEMENTATIONS_FILE }); implData = { implementations: [] }; } const existingImpl = implData.implementations.find( (i) => i.idea_id === ideaId && i.status !== 'failed' && i.status !== 'completed' ); const workspacePath = workspacePathOverride || path.join(config.workspace_dir, targetRepo.name); const isWorktreeMode = !!workspacePathOverride; const impl = existingImpl || createImplementation(ideaId, targetRepo.name, branchName, workspacePath); try { // Pre-flight: clean up any stale git state before doing anything cleanGitState(workspacePath); // Worktree mode: workspace is pre-configured, skip all setup if (isWorktreeMode && scope) { if (!fs.existsSync(workspacePath)) { throw new Error(`Worktree path does not exist: ${workspacePath}`); } addLog(impl.id, `Using pre-configured worktree: ${workspacePath}`); // For scoped execution (sub-tasks): skip full workspace reset, just ensure branch exists } else if (scope || createPrOnly) { // Ensure workspace exists if (!fs.existsSync(workspacePath)) { updateImplementation(impl.id, { status: 'cloning' }); setupWorkspace(targetRepo, config.workspace_dir, impl.id); } // Fetch latest from origin to keep workspace current addLog(impl.id, 'Fetching latest from origin...'); try { exec('git fetch origin', workspacePath); } catch {} // Check out or create the feature branch validateBranchName(branchName); addLog(impl.id, `Checking out branch: ${branchName}`); try { execGit(['checkout', branchName], workspacePath); // Merge latest main into feature branch to stay current addLog(impl.id, `Merging origin/${defaultBranch} into ${branchName}...`); try { execGit(['merge', `origin/${defaultBranch}`, '--no-edit'], workspacePath); } catch { addLog(impl.id, 'Merge conflict detected, aborting merge'); execGit(['merge', '--abort'], workspacePath); } } catch { // Branch doesn't exist yet, create it from latest default branch execGit(['checkout', defaultBranch], workspacePath); execGit(['pull', 'origin', defaultBranch], workspacePath); execGit(['checkout', '-b', branchName], workspacePath); } } else { // Full workspace setup (original behavior) updateImplementation(impl.id, { status: 'cloning' }); setupWorkspace(targetRepo, config.workspace_dir, impl.id); updateImplementation(impl.id, { status: 'branching' }); createBranch(workspacePath, branchName, defaultBranch, impl.id); } // Create PR only mode: skip implementation, just push and create PR if (createPrOnly) { addLog(impl.id, 'Create PR only mode — skipping implementation'); updateImplementation(impl.id, { status: 'creating_pr' }); const pr = createPR(workspacePath, branchName, defaultBranch, idea, impl.id); updateIdea(ideaId, { branch_name: branchName, pr_url: pr?.url || null, pr_number: pr?.number || null, }); if (pr) { updateImplementation(impl.id, { status: 'completed' }); console.log(`\n=== PR Created ===`); console.log(`PR: ${pr.url}`); } else { updateImplementation(impl.id, { status: 'pr_failed' }); console.log(`\n=== PR Creation Failed ===`); console.log(`Branch: ${branchName}`); console.log(`No PR was created. Check that commits exist and GitHub remote is configured.`); process.exit(1); } return; } // Pre-flight: verify workspace path exists before spawning Claude Code if (!fs.existsSync(workspacePath)) { addLog(impl.id, `Workspace path does not exist: ${workspacePath}`); updateImplementation(impl.id, { status: 'failed', error_message: `Workspace path does not exist: ${workspacePath}` }); revertIdeaStage(ideaId, `Implementation failed: workspace path does not exist: ${workspacePath}`); process.exit(1); } // Guard against duplicate spawns: PID-based lockfile per idea const lockDir = path.join(os.tmpdir(), 'vibebusiness-impl-locks'); if (!fs.existsSync(lockDir)) fs.mkdirSync(lockDir, { recursive: true }); const lockFile = path.join(lockDir, `${ideaId}.lock`); // Check if another process is already implementing this idea if (fs.existsSync(lockFile)) { try { const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf-8')); // Check if the owning process is still alive try { process.kill(lockData.pid, 0); // Signal 0 = check existence addLog(impl.id, `Another process (PID ${lockData.pid}) is already implementing ${ideaId} — skipping`); console.log(`Skipping: another process (PID ${lockData.pid}) is already running for ${ideaId}`); return; } catch { // Process is dead — stale lock, remove and continue addLog(impl.id, `Removing stale lock from dead process (PID ${lockData.pid})`); fs.unlinkSync(lockFile); } } catch { // Corrupted lock file — remove and continue try { fs.unlinkSync(lockFile); } catch {} } } // Acquire lock fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, started: new Date().toISOString() })); // Run implementation updateImplementation(impl.id, { status: 'implementing' }); const prompt = generatePrompt(idea, scope); try { await runImplementation(workspacePath, prompt, impl.id, timeout, { model: model || (scope ? 'sonnet' : undefined), // Default to sonnet for sub-tasks scoped: !!scope, }); } finally { // Release lock try { fs.unlinkSync(lockFile); } catch {} } // Check for changes and commit if (hasChanges(workspacePath)) { addLog(impl.id, 'Changes detected, staging and committing...'); if (scope) { // Scoped mode: only add tracked files that were modified (no untracked junk) // This prevents committing artifacts from failed shell commands exec('git add -u', workspacePath); // Also add any new files that Claude explicitly created (should be rare for sub-tasks) // But only if they're real source files, not shell redirect artifacts const untrackedRaw = exec('git ls-files --others --exclude-standard', workspacePath); const untrackedFiles = untrackedRaw.split('\n').filter((f) => f.trim().length > 0); for (const file of untrackedFiles) { // Skip obvious junk files (shell redirect artifacts, temp files) if (/^[=<>]/.test(file) || file.endsWith('.tmp') || file.startsWith('.')) { addLog(impl.id, `Skipping junk file: ${file}`); continue; } execGit(['add', file], workspacePath); } } else { exec('git add -A', workspacePath); } // Only commit if there are staged changes const staged = exec('git diff --cached --stat', workspacePath); if (staged.trim().length > 0) { const safeTitle = sanitizeForCommitMessage(idea.title); const commitMsg = scope ? `feat: ${safeTitle} (sub-task)\n\nImplemented by AI Product Manager\nIdea: ${idea.id}` : `feat: ${safeTitle}\n\nImplemented by AI Product Manager\nIdea: ${idea.id}`; execGit(['commit', '-m', commitMsg], workspacePath); } else { addLog(impl.id, 'No staged changes after filtering, skipping commit'); } } // Skip PR creation if --skip-pr flag is set if (skipPr) { addLog(impl.id, 'Skipping PR creation (--skip-pr)'); // In worktree mode, skip push — the orchestrator handles push after merging if (!isWorktreeMode) { // Still push the branch so work isn't lost when workspace is deleted try { addLog(impl.id, `Pushing branch ${branchName} to origin`); execGit(['push', '-u', 'origin', branchName], workspacePath, 30000); } catch (pushError) { addLog(impl.id, `Warning: Failed to push branch: ${(pushError as Error).message}`); // Don't fail the whole task if push fails - code is still committed locally } } updateIdea(ideaId, { branch_name: branchName }); updateImplementation(impl.id, { status: 'completed' }); console.log(`\n=== Implementation Complete (PR skipped) ===`); console.log(`Branch: ${branchName}`); return; } // Create PR (original behavior) updateImplementation(impl.id, { status: 'creating_pr' }); const pr = createPR(workspacePath, branchName, defaultBranch, idea, impl.id); updateIdea(ideaId, { branch_name: branchName, pr_url: pr?.url || null, pr_number: pr?.number || null, }); if (pr) { updateImplementation(impl.id, { status: 'completed' }); console.log(`\n=== Implementation Complete ===`); console.log(`Branch: ${branchName}`); console.log(`PR: ${pr.url}`); } else { updateImplementation(impl.id, { status: 'pr_failed' }); console.log(`\n=== Implementation Complete (PR creation failed) ===`); console.log(`Branch: ${branchName}`); console.log(`Code is ready but no PR was created. Check GitHub remote config.`); process.exit(1); } } catch (error: unknown) { const err = error as Error; addLog(impl.id, `Error: ${err.message}`); updateImplementation(impl.id, { status: 'failed', error_message: err.message, }); revertIdeaStage(ideaId, `Implementation failed: ${err.message}`); console.error('[implement] Fatal error', { ideaId, implId: impl.id, error: err.message, stack: err.stack }); console.error(`\n=== Implementation Failed ===`); console.error(err.message); process.exit(1); } } // Only run main() when executed directly (not when imported in tests) const _scriptPath = process.argv[1] ?? ''; if (_scriptPath.endsWith('implement.ts') || _scriptPath.endsWith('implement.js')) { main().catch(console.error); }