#!/usr/bin/env npx tsx /** * AI Product Manager - Analysis Script * * This script runs Claude Code to analyze the codebase and generate improvement ideas. * * Usage: * npm run analyze -- --type=quick * npm run analyze -- --type=deep * npm run analyze -- --type=metrics * npm run analyze -- --type=seo * npm run analyze -- --type=research --topic="response time optimization" --context= * npm run analyze -- --type=growth * npm run analyze -- --type=vision */ import 'dotenv/config'; import { execSync, spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { getAnalysisPrompt } from '../src/lib/prompts'; import type { AnalystConfig as ImportedAnalystConfig, BusinessConfig } from '../src/lib/types'; import { atomicWriteFileSync } from './lib/json-lock'; import { requireClaudeCLI, cleanEnvForClaude } from './lib/ai-provider'; import { generateProductVision } from './lib/vision/define'; import { seedHypothesesFromVision } from './lib/vision/hypotheses'; import { logError, logWarn } from './lib/logger'; import { ProgressReporter } from './lib/progress'; import { formatIdeaLine, formatROISummary, formatValueSummary, pickTopFinding, type EnrichedIdea } from './lib/format-results'; // Local type alias for compatibility type AnalystConfig = ImportedAnalystConfig; interface IdeaInput { title: string; summary: string; category: string; priority: string; effort: string; impact: string; context: string; rationale: string; implementation_plan: string; success_metrics: string[]; tags?: string[]; files_analyzed?: string[]; goal_id?: string; hypothesis_id?: string; severity_score?: number; estimated_hours_saved?: number; business_impact_summary?: string; specific_code_refs?: Array<{ file: string; line?: number; snippet?: string }>; surprise_factor?: 'low' | 'medium' | 'high'; } interface BusinessIdea { id: string; created_at: string; updated_at: string; title: string; summary: string; category: string; priority: string; effort: string; impact: string; context: string; rationale: string; implementation_plan: string; success_metrics: string[]; stage: string; source: { type: string; session_id: string; files_analyzed?: string[]; }; implementation: { branch_name: string | null; pr_url: string | null; pr_number: number | null; commits: string[]; started_at: string | null; completed_at: string | null; }; comments: Array; tags: string[]; related_ideas: string[]; goal_id?: string | null; hypothesis_id?: string | null; epic_id?: string | null; severity_score?: number | null; estimated_hours_saved?: number | null; business_impact_summary?: string | null; specific_code_refs?: Array<{ file: string; line?: number; snippet?: string }> | null; surprise_factor?: 'low' | 'medium' | 'high' | null; } interface Session { id: string; created_at: string; completed_at: string | null; type: string; status: string; repos_analyzed: string[]; ideas_generated: string[]; error_message: string | null; logs: string[]; } // Paths — centralized in lib/paths.ts (uses process.cwd() for portability) import { DATA_DIR, IDEAS_FILE, SESSIONS_FILE, CONFIG_FILE, GOALS_FILE, HYPOTHESES_FILE, BUSINESS_CONTEXT_FILE, ROADMAP_FILE, } from './lib/paths'; // Analysis types type AnalysisType = 'quick' | 'deep' | 'metrics' | 'seo' | 'research' | 'growth' | 'vision'; // Parse command line arguments function parseArgs(): { type: AnalysisType; topic?: string; context?: string } { const args = process.argv.slice(2); let type: AnalysisType = 'quick'; let topic: string | undefined; let context: string | undefined; for (const arg of args) { if (arg.startsWith('--type=')) { const value = arg.split('=')[1]; if (['quick', 'deep', 'metrics', 'seo', 'research', 'growth', 'vision'].includes(value)) { type = value as AnalysisType; } } else if (arg.startsWith('--topic=')) { topic = arg.split('=').slice(1).join('='); // Handle topics with = in them } else if (arg.startsWith('--context=')) { context = arg.split('=').slice(1).join('='); } } return { type, topic, context }; } // Load config function loadConfig(): AnalystConfig { if (!fs.existsSync(CONFIG_FILE)) { throw new Error('Config file not found. Please ensure data/config.json exists.'); } return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); } // Load business context function loadBusinessContext(): BusinessConfig | undefined { if (!fs.existsSync(BUSINESS_CONTEXT_FILE)) { return undefined; } try { return JSON.parse(fs.readFileSync(BUSINESS_CONTEXT_FILE, 'utf-8')); } catch { return undefined; } } // Load ideas function loadIdeas(): { ideas: BusinessIdea[] } { if (!fs.existsSync(IDEAS_FILE)) { return { ideas: [] }; } return JSON.parse(fs.readFileSync(IDEAS_FILE, 'utf-8')); } // Save ideas (atomic write to prevent concurrent read corruption) function saveIdeas(data: { ideas: BusinessIdea[] }): void { atomicWriteFileSync(IDEAS_FILE, JSON.stringify(data, null, 2)); } // Load sessions function loadSessions(): { sessions: Session[] } { if (!fs.existsSync(SESSIONS_FILE)) { return { sessions: [] }; } return JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8')); } // Save sessions (atomic write to prevent concurrent read corruption) function saveSessions(data: { sessions: Session[] }): void { atomicWriteFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2)); } // Clean up stuck sessions (running for more than 30 minutes) function cleanupStuckSessions(): number { const sessions = loadSessions(); const now = new Date(); const STUCK_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes let cleanedCount = 0; for (const session of sessions.sessions) { if (session.status === 'running') { const createdAt = new Date(session.created_at); const ageMs = now.getTime() - createdAt.getTime(); if (ageMs > STUCK_THRESHOLD_MS) { session.status = 'failed'; session.completed_at = now.toISOString(); session.error_message = `Session timed out (stuck in running state for ${Math.round(ageMs / 60000)} minutes)`; session.logs.push(`[${now.toISOString()}] Session marked as failed (stuck timeout)`); cleanedCount++; console.log(`Cleaned up stuck session: ${session.id} (was running for ${Math.round(ageMs / 60000)} min)`); } } } if (cleanedCount > 0) { saveSessions(sessions); } return cleanedCount; } // Create a new session function createSession(type: string, repos: string[]): Session { // First, clean up any stuck sessions cleanupStuckSessions(); const now = new Date().toISOString(); const session: Session = { id: `session-${uuidv4().slice(0, 8)}`, created_at: now, completed_at: null, type, status: 'running', repos_analyzed: repos, ideas_generated: [], error_message: null, logs: [`[${now}] Session started`], }; const sessions = loadSessions(); sessions.sessions.push(session); saveSessions(sessions); return session; } // Update session function updateSession(sessionId: string, updates: Partial): void { const sessions = loadSessions(); const index = sessions.sessions.findIndex((s) => s.id === sessionId); if (index !== -1) { sessions.sessions[index] = { ...sessions.sessions[index], ...updates }; saveSessions(sessions); } } // Buffer for session log lines, keyed by session id const pendingLogs = new Map(); // Add log to session (buffered — call flushSessionLogs to persist) function addSessionLog(sessionId: string, message: string): void { console.log(message); const timestamp = new Date().toISOString(); const entry = `[${timestamp}] ${message}`; const existing = pendingLogs.get(sessionId) ?? []; existing.push(entry); pendingLogs.set(sessionId, existing); } // Flush buffered log lines to disk in a single write function flushSessionLogs(sessionId: string): void { const lines = pendingLogs.get(sessionId); const n = lines?.length ?? 0; console.log(`[analyze] Flushing ${n} buffered log lines for session ${sessionId}`); try { const sessions = loadSessions(); const index = sessions.sessions.findIndex((s) => s.id === sessionId); if (index !== -1 && lines && lines.length > 0) { sessions.sessions[index].logs.push(...lines); saveSessions(sessions); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.log(`[analyze] ERROR: failed to flush session logs for ${sessionId}: ${msg}`); throw err; } finally { pendingLogs.delete(sessionId); } } // Load goals and format as context string for prompts function loadGoalsContext(): string { try { if (!fs.existsSync(GOALS_FILE)) return ''; const goalsData = JSON.parse(fs.readFileSync(GOALS_FILE, 'utf-8')); const goals = goalsData.goals || []; if (goals.length === 0) return ''; return goals.map((g: { id: string; title: string; status: string; current_value: number | null; target_value: number; target_unit: string; deadline: string; kpis: Array<{ name: string; current_value: number | null; target_value: number; unit: string; direction: string }> }) => { const kpiLines = g.kpis.map((k) => ` - ${k.name}: ${k.current_value ?? 'N/A'} / ${k.target_value} ${k.unit} (${k.direction})` ).join('\n'); return `**${g.id}: ${g.title}** — Status: ${g.status}, Progress: ${g.current_value ?? 0} / ${g.target_value} ${g.target_unit}, Deadline: ${g.deadline}\n${kpiLines}`; }).join('\n\n'); } catch { return ''; } } // Auto-link ideas with hypothesis_id to the hypothesis's validation_ideas array function linkIdeasToHypotheses(ideas: BusinessIdea[]): void { const ideasWithHypothesis = ideas.filter((i) => i.hypothesis_id); if (ideasWithHypothesis.length === 0) return; try { if (!fs.existsSync(HYPOTHESES_FILE)) return; const data = JSON.parse(fs.readFileSync(HYPOTHESES_FILE, 'utf-8')); const hypotheses = data.hypotheses || []; let changed = false; for (const idea of ideasWithHypothesis) { const hypIndex = hypotheses.findIndex( (h: { id: string }) => h.id === idea.hypothesis_id ); if (hypIndex === -1) continue; if (!hypotheses[hypIndex].validation_ideas.includes(idea.id)) { hypotheses[hypIndex].validation_ideas.push(idea.id); hypotheses[hypIndex].updated_at = new Date().toISOString(); changed = true; } } if (changed) { atomicWriteFileSync(HYPOTHESES_FILE, JSON.stringify({ hypotheses }, null, 2)); console.log(`Linked ${ideasWithHypothesis.length} idea(s) to their hypotheses`); } } catch (error) { console.error('Failed to link ideas to hypotheses:', error); } } /** * Match new ideas to existing epics by goal_id + category. * Updates both ideas (in ideasData) and roadmap.json in place. */ function matchNewIdeasToEpics(newIdeas: BusinessIdea[], ideasData: { ideas: BusinessIdea[] }): void { try { if (!fs.existsSync(ROADMAP_FILE)) return; const roadmapData = JSON.parse(fs.readFileSync(ROADMAP_FILE, 'utf-8')); const epics = roadmapData.epics || []; if (epics.length === 0) return; let matched = 0; const now = new Date().toISOString(); for (const idea of newIdeas) { if (idea.epic_id) continue; // already assigned // Find matching epic: same category + same goal (or both orphan) const matchingEpic = epics.find((epic: { category: string; goal_id: string | null; id: string }) => epic.category === idea.category && (idea.goal_id ? epic.goal_id === idea.goal_id : !epic.goal_id) ); if (matchingEpic) { idea.epic_id = matchingEpic.id; if (!matchingEpic.idea_ids.includes(idea.id)) { matchingEpic.idea_ids.push(idea.id); matchingEpic.updated_at = now; } // Also update in the full ideas store const storeIdea = ideasData.ideas.find((i) => i.id === idea.id); if (storeIdea) { storeIdea.epic_id = matchingEpic.id; } matched++; } } if (matched > 0) { atomicWriteFileSync(ROADMAP_FILE, JSON.stringify(roadmapData, null, 2)); atomicWriteFileSync(IDEAS_FILE, JSON.stringify(ideasData, null, 2)); console.log(`Auto-assigned ${matched} new idea(s) to existing epics`); } } catch (error) { console.error('Failed to match ideas to epics:', error); } } // Load hypotheses and format as context string for prompts function loadHypothesesContext(): string { try { if (!fs.existsSync(HYPOTHESES_FILE)) return ''; const data = JSON.parse(fs.readFileSync(HYPOTHESES_FILE, 'utf-8')); const hypotheses = data.hypotheses || []; if (hypotheses.length === 0) return ''; return hypotheses .filter((h: { status: string }) => h.status === 'stated' || h.status === 'testing') .map((h: { id: string; title: string; statement: string; funnel_stage: string; priority: string; status: string; effort_to_test: string }) => `**${h.id}: ${h.title}** [${h.funnel_stage}] — Status: ${h.status}, Priority: ${h.priority}, Effort: ${h.effort_to_test}\n Statement: ${h.statement}` ).join('\n\n'); } catch { return ''; } } // Generate the analysis prompt using the centralized prompts module function generatePrompt( type: Exclude, config: AnalystConfig, topic?: string, context?: string ): string { const schedule = config.schedules[type] || config.schedules.quick; const goalsContext = loadGoalsContext(); const hypothesesContext = loadHypothesesContext(); const businessConfig = loadBusinessContext(); return getAnalysisPrompt({ type, config, maxIdeas: schedule.max_ideas, topic, context, goalsContext: goalsContext || undefined, hypothesesContext: hypothesesContext || undefined, businessConfig, }); } // Parse ideas from Claude's response export function parseIdeas(response: string, sessionId: string): BusinessIdea[] { // Try to extract JSON array from the response // Strategy: first try markdown-fenced JSON, then raw JSON array let jsonStr: string | null = null; // 1. Try ```json ... ``` fenced block const fencedMatch = response.match(/```json\s*\n?([\s\S]*?)```/); if (fencedMatch) { const inner = fencedMatch[1].trim(); if (inner.startsWith('[')) { jsonStr = inner; } } // 2. Fallback: raw JSON array anywhere in the response if (!jsonStr) { const rawMatch = response.match(/\[\s*\{[\s\S]*\}\s*\]/); if (rawMatch) { jsonStr = rawMatch[0]; } } if (!jsonStr) { console.error('No JSON array found in response'); console.error('Response preview (first 500 chars):', response.slice(0, 500)); return []; } let rawIdeas: IdeaInput[]; let parseError: unknown; // Stage 1: direct parse try { rawIdeas = JSON.parse(jsonStr) as IdeaInput[]; parseError = undefined; } catch (err) { parseError = err; // Stage 2: repair known bad patterns from code snippets: // - invalid JSON escapes like \` \$ \@ (not in the \bfnrtu set) // - stray control characters (literal newlines/tabs inside strings) const repaired = jsonStr .replace(/\\([^"\\/bfnrtu\n])/g, '$1') // strip invalid escapes .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // remove stray ctrl chars try { rawIdeas = JSON.parse(repaired) as IdeaInput[]; parseError = undefined; } catch (err2) { parseError = err2; rawIdeas = []; } } if (rawIdeas!.length === 0 && parseError) { console.error('Failed to parse ideas JSON:', parseError); return []; } const now = new Date().toISOString(); return rawIdeas.map((idea) => { // --- Validate and propagate enrichment fields --- // severity_score: must be a number let severity_score: number | null = null; if (idea.severity_score !== undefined && idea.severity_score !== null) { if (typeof idea.severity_score === 'number') { severity_score = idea.severity_score; } else { logError('analyze:parseIdeas', new Error(`malformed field "severity_score" for idea "${idea.title}" — expected number, got ${typeof idea.severity_score}`)); } } // estimated_hours_saved: must be a number let estimated_hours_saved: number | null = null; if (idea.estimated_hours_saved !== undefined && idea.estimated_hours_saved !== null) { if (typeof idea.estimated_hours_saved === 'number') { estimated_hours_saved = idea.estimated_hours_saved; } else { logError('analyze:parseIdeas', new Error(`malformed field "estimated_hours_saved" for idea "${idea.title}" — expected number, got ${typeof idea.estimated_hours_saved}`)); } } // business_impact_summary: must be a string let business_impact_summary: string | null = null; if (idea.business_impact_summary !== undefined && idea.business_impact_summary !== null) { if (typeof idea.business_impact_summary === 'string') { business_impact_summary = idea.business_impact_summary; } else { logError('analyze:parseIdeas', new Error(`malformed field "business_impact_summary" for idea "${idea.title}" — expected string, got ${typeof idea.business_impact_summary}`)); } } // surprise_factor: must be low | medium | high const VALID_SURPRISE = ['low', 'medium', 'high']; let surprise_factor: 'low' | 'medium' | 'high' | null = null; if (idea.surprise_factor !== undefined && idea.surprise_factor !== null) { if (VALID_SURPRISE.includes(idea.surprise_factor)) { surprise_factor = idea.surprise_factor; } else { logError('analyze:parseIdeas', new Error(`malformed field "surprise_factor" for idea "${idea.title}" — expected low|medium|high, got "${idea.surprise_factor}"`)); } } // specific_code_refs: must be an array of objects with a "file" string key let specific_code_refs: Array<{ file: string; line?: number; snippet?: string }> | null = null; const rawRefs = idea.specific_code_refs as unknown; if (rawRefs !== undefined && rawRefs !== null) { if (!Array.isArray(rawRefs)) { logError('analyze:parseIdeas', new Error(`malformed field "specific_code_refs" for idea "${idea.title}" — expected array, got ${typeof rawRefs}`)); } else { const validRefs = (rawRefs as unknown[]).filter((item) => { if (!item || typeof item !== 'object' || typeof (item as Record).file !== 'string') { logError('analyze:parseIdeas', new Error(`malformed item in "specific_code_refs" for idea "${idea.title}" — missing "file" key`)); return false; } return true; }) as Array<{ file: string; line?: number; snippet?: string }>; specific_code_refs = validRefs.length > 0 ? validRefs : null; } } return { id: `idea-${uuidv4().slice(0, 8)}`, created_at: now, updated_at: now, title: idea.title || 'Untitled Idea', summary: idea.summary || '', category: idea.category || 'product', priority: idea.priority || 'medium', effort: idea.effort || 'm', impact: idea.impact || 'm', context: idea.context || '', rationale: idea.rationale || '', implementation_plan: idea.implementation_plan || '', success_metrics: idea.success_metrics || [], stage: 'inbox', source: { type: 'codebase_analysis', session_id: sessionId, files_analyzed: idea.files_analyzed, }, implementation: { branch_name: null, pr_url: null, pr_number: null, commits: [], started_at: null, completed_at: null, sub_tasks: [], decomposition_attempts: 0, }, comments: [], tags: idea.tags || [], related_ideas: [], goal_id: idea.goal_id || null, hypothesis_id: idea.hypothesis_id || null, epic_id: null, severity_score, estimated_hours_saved, business_impact_summary, specific_code_refs, surprise_factor, }; }); } // Run Claude Code with the analysis prompt async function runClaudeAnalysis( prompt: string, config: AnalystConfig, timeoutMs: number = 600000, // 10 minute default timeout type?: string ): Promise { requireClaudeCLI('codebase analysis'); return new Promise((resolve, reject) => { // Build the command to run Claude Code // We'll use the --print flag to get the response directly const repoPaths = config.repos.map((r) => r.path); const workDir = repoPaths[0]; // Use frontend repo as working directory console.log('Starting Claude Code analysis...'); console.log(`Working directory: ${workDir}`); console.log(`Timeout: ${timeoutMs / 1000}s`); // Run Claude Code with the prompt via stdin (avoids shell arg length limits) console.log('[analyze] Prompt sent via stdin — no temp file written'); const claude = spawn('claude', [ '--print', '--output-format', 'text', '--dangerously-skip-permissions', ], { cwd: workDir, env: cleanEnvForClaude(), stdio: ['pipe', 'pipe', 'pipe'], }); // Write prompt to stdin and close it claude.stdin.write(prompt); claude.stdin.end(); let output = ''; let errorOutput = ''; let isResolved = false; const startTime = Date.now(); // Phase-aware progress reporter const progress = new ProgressReporter(type ?? 'unknown'); progress.start(); // Timeout handler const timeout = setTimeout(() => { if (!isResolved) { isResolved = true; progress.stop(); claude.kill('SIGTERM'); // Give it 5 seconds to clean up, then force kill setTimeout(() => { try { claude.kill('SIGKILL'); } catch {} }, 5000); const elapsed = Math.round((Date.now() - startTime) / 1000); reject(new Error(`Claude Code timed out after ${elapsed}s (limit: ${timeoutMs / 1000}s)`)); } }, timeoutMs); claude.stdout.on('data', (data) => { const text = data.toString(); output += text; progress.updateOutput(text.length); process.stdout.write(text); }); claude.stderr.on('data', (data) => { const text = data.toString(); errorOutput += text; process.stderr.write(text); }); claude.on('close', (code) => { if (isResolved) return; // Already timed out isResolved = true; const timing = progress.stop(); clearTimeout(timeout); const elapsed = timing.totalSeconds; if (code === 0) { logWarn('analyze:runClaudeAnalysis', `completed in ${elapsed}s phases=[${timing.phases.join(',')}]`); console.log(`Claude Code completed in ${elapsed}s`); console.log(`[analyze] stdout length: ${output.length}, stderr length: ${errorOutput.length}`); if (output.length === 0 && errorOutput.length > 0) { console.log('[analyze] WARNING: stdout empty but stderr has content — using stderr as response'); resolve(errorOutput); } else { resolve(output); } } else { reject(new Error(`Claude Code exited with code ${code} after ${elapsed}s: ${errorOutput}`)); } }); claude.on('error', (error) => { if (isResolved) return; isResolved = true; progress.stop(); clearTimeout(timeout); reject(error); }); }); } // Generate a codebase snapshot for the heartbeat async function generateCodebaseSnapshot(config: AnalystConfig): Promise { try { requireClaudeCLI('codebase snapshot'); } catch { console.log('Skipping codebase snapshot (Claude Code CLI not available)'); return; } const snapshotPath = path.join(DATA_DIR, 'codebase-snapshot.json'); console.log('Generating codebase snapshot...'); const prompt = `You are analyzing the codebase to generate a structured snapshot. ## Repositories ${config.repos.map((r) => `- ${r.name}: ${r.path} (${r.type})`).join('\n')} ## Your Task Generate a JSON snapshot of the codebase architecture. This will be used by the heartbeat to understand what exists. Focus on: 1. What notification/alert systems exist (email, WhatsApp, push, in-app) 2. What API endpoints handle leads, lawyers, marketplace 3. What background jobs/schedulers exist 4. What monitoring/analytics integrations exist 5. What's notably MISSING that's relevant to a CRM Be FAST — use Glob to find files by pattern, don't read every file. ## Output Format Return ONLY this JSON structure (no markdown code blocks): { "generated_at": "ISO timestamp", "services": { "notifications": ["list of notification channels that exist"], "api_endpoints": ["key endpoints for leads/lawyers/marketplace"], "background_jobs": ["schedulers, cron, workers that exist"], "integrations": ["external services: Loops, Whapi, PostHog, etc."] }, "missing": ["notable gaps relevant to CRM/lawyer success"], "file_counts": { "frontend_components": 0, "api_routes": 0, "backend_routers": 0 } }`; return new Promise((resolve) => { const claude = spawn('claude', [ '--print', '--dangerously-skip-permissions', '--model', 'sonnet', ], { cwd: config.repos[0].path, env: cleanEnvForClaude(), stdio: ['pipe', 'pipe', 'pipe'], }); // Write prompt to stdin and close it claude.stdin.write(prompt); claude.stdin.end(); let output = ''; const timeout = setTimeout(() => { claude.kill('SIGTERM'); console.log('Snapshot generation timed out'); resolve(); }, 60000); // 60s timeout claude.stdout.on('data', (data) => { output += data.toString(); }); claude.stderr.on('data', (data) => { process.stderr.write(data.toString()); }); claude.on('close', (code) => { clearTimeout(timeout); if (code === 0) { try { // Extract JSON from response const jsonMatch = output.match(/\{[\s\S]*\}/); if (jsonMatch) { const snapshot = JSON.parse(jsonMatch[0]); fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2)); console.log(`Codebase snapshot saved to ${snapshotPath}`); } } catch (error) { console.log(`Failed to parse snapshot: ${error instanceof Error ? error.message : 'Unknown'}`); } } resolve(); }); claude.on('error', () => { clearTimeout(timeout); resolve(); }); }); } // Main function async function main() { const { type, topic, context } = parseArgs(); console.log(`\n=== AI Product Manager - ${type.toUpperCase()} Analysis ===\n`); if (type === 'research' && topic) { console.log(`Research topic: ${topic}`); } // Vision analysis — separate flow (generates vision + hypotheses, not ideas) if (type === 'vision') { console.log('Generating product vision (Obviously Awesome + StoryBrand)...\n'); const vision = await generateProductVision(); if (!vision) { console.error('Vision generation failed'); process.exit(1); } console.log(`\n✓ Vision saved to data/product-vision.json (v${vision.version})`); console.log(` One-liner: ${vision.one_liner}`); console.log(` Focus metric: ${vision.focus_metric}`); console.log(` Anti-goals: ${vision.anti_goals.join(', ')}\n`); // Seed hypotheses from vision console.log('Deriving hypotheses from vision...'); const hypotheses = await seedHypothesesFromVision(vision); if (hypotheses.length > 0) { console.log(`✓ ${hypotheses.length} hypotheses seeded to data/hypotheses.json\n`); for (const h of hypotheses) { console.log(` - [${h.funnel_stage}] ${h.title}`); } } else { console.log(' No hypotheses generated (you can add them manually)'); } console.log('\n=== Vision Analysis Complete ==='); return; } // Load config const config = loadConfig(); console.log(`Analyzing ${config.repos.length} repositories...`); // Create session const session = createSession(type, config.repos.map((r) => r.name)); console.log(`Session ID: ${session.id}`); try { // Generate prompt const prompt = generatePrompt(type as Exclude, config, topic, context); addSessionLog(session.id, `Generated ${type} analysis prompt`); // Set timeout based on analysis type (research needs more time) const timeoutMs = type === 'research' ? 900000 : 600000; // 15 min for research, 10 min otherwise // Run analysis addSessionLog(session.id, 'Running Claude Code analysis...'); const analysisStartTime = Date.now(); const response = await runClaudeAnalysis(prompt, config, timeoutMs, type); const analysisDurationSeconds = Math.round((Date.now() - analysisStartTime) / 1000); addSessionLog(session.id, 'Analysis complete'); // Parse ideas from response const newIdeas = parseIdeas(response, session.id); addSessionLog(session.id, `Parsed ${newIdeas.length} ideas from response`); if (newIdeas.length > 0) { // Save ideas (with dedup against existing ideas by title similarity) const ideasData = loadIdeas(); const existingTitles = ideasData.ideas.map(i => i.title); const deduped = newIdeas.filter(idea => { const newWords = idea.title.toLowerCase().split(/\s+/).filter(w => w.length > 3); if (newWords.length === 0) return true; const isDuplicate = existingTitles.some(existingTitle => { const existingWords = existingTitle.toLowerCase().split(/\s+/).filter(w => w.length > 3); if (existingWords.length === 0) return false; const overlapCount = newWords.filter(w => existingWords.includes(w)).length; return overlapCount / newWords.length > 0.6; }); if (isDuplicate) { console.log(`Skipped duplicate idea: "${idea.title}"`); } return !isDuplicate; }); if (deduped.length < newIdeas.length) { addSessionLog(session.id, `Deduplicated ${newIdeas.length - deduped.length} duplicate idea(s)`); } ideasData.ideas.push(...deduped); saveIdeas(ideasData); addSessionLog(session.id, `Saved ${deduped.length} new ideas (${newIdeas.length - deduped.length} duplicates skipped)`); // Auto-link ideas to hypotheses linkIdeasToHypotheses(newIdeas); // Auto-assign new ideas to matching epics matchNewIdeasToEpics(newIdeas, ideasData); // Update session updateSession(session.id, { status: 'completed', completed_at: new Date().toISOString(), ideas_generated: newIdeas.map((i) => i.id), }); console.log(`\n=== Analysis Complete ===`); const enrichedDeduped = deduped as unknown as EnrichedIdea[]; console.log(formatROISummary(enrichedDeduped)); enrichedDeduped.forEach((idea, i) => { console.log(formatIdeaLine(idea, i)); }); // Value summary — the "screenshot block" const repoName = config.repos.length > 0 ? config.repos[0].name : undefined; console.log('\n' + formatValueSummary(enrichedDeduped, type, analysisDurationSeconds, repoName)); // Session log + observability const totalHours = enrichedDeduped.reduce((acc, i) => acc + (i.estimated_hours_saved ?? 0), 0); addSessionLog(session.id, `Value summary: ${deduped.length} findings, ~${totalHours}h estimated value`); const topFinding = pickTopFinding(enrichedDeduped); logWarn('analyze:valueSummary', JSON.stringify({ finding_count: deduped.length, total_hours: totalHours, top_severity: topFinding?.score ?? null, top_surprise: topFinding?.surprise_factor ?? null, })); } else { addSessionLog(session.id, 'No ideas generated'); updateSession(session.id, { status: 'completed', completed_at: new Date().toISOString(), }); } // Generate codebase snapshot after analysis (for heartbeat context) await generateCodebaseSnapshot(config); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; addSessionLog(session.id, `Error: ${errorMessage}`); updateSession(session.id, { status: 'failed', completed_at: new Date().toISOString(), error_message: errorMessage, }); console.error('\n=== Analysis Failed ==='); console.error(error); process.exit(1); } finally { flushSessionLogs(session.id); } } // Only run when executed directly (not when imported by tests or other modules) if (process.argv[1] && /[/\\]analyze\.(ts|js)$/.test(process.argv[1])) { main().catch(console.error); }