UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

339 lines (298 loc) 9.89 kB
/** * Output Analyzer for External Ralph Loop * * Analyzes Claude Code session output to determine completion status, * extract learnings, and decide on next actions. * * Uses a separate Claude session for intelligent analysis, with * pattern-matching fallback. * * @implements @.aiwg/requirements/design-ralph-external.md */ import { readFileSync, existsSync } from 'fs'; import { spawnSync } from 'child_process'; import PromptGenerator from './prompt-generator.mjs'; /** * @typedef {Object} AnalysisResult * @property {boolean} completed - Whether the task completed * @property {boolean|null} success - Whether completion was successful * @property {string|null} failureClass - Classification of failure * @property {number} completionPercentage - Estimated progress 0-100 * @property {boolean} shouldContinue - Whether to try another iteration * @property {string} learnings - Extracted insights for next iteration * @property {string[]} artifactsModified - Files that were changed * @property {string[]} blockers - Things preventing completion * @property {string} nextApproach - Suggested approach for continuation */ /** * @typedef {Object} AnalysisOptions * @property {string} stdoutPath - Path to stdout log * @property {string} stderrPath - Path to stderr log * @property {number} exitCode - Process exit code * @property {Object} context - Task context * @property {string} context.objective - Original objective * @property {string} context.criteria - Completion criteria * @property {boolean} [useClaude=true] - Whether to use Claude for analysis * @property {string} [model='sonnet'] - Model for analysis */ // Maximum characters to include in analysis const MAX_OUTPUT_CHARS = 50000; // Completion marker patterns const COMPLETION_PATTERNS = [ /\{"ralph_external_completion":\s*true.*?\}/s, /Ralph Loop:\s*(SUCCESS|COMPLETE)/i, /\[Ralph\]\s*Completed/i, ]; // Failure patterns const FAILURE_PATTERNS = { context_exhausted: [ /context.*limit/i, /maximum context/i, /token limit/i, ], budget_exceeded: [ /budget.*exceeded/i, /spending limit/i, /cost limit/i, ], internal_loop_limit: [ /MAX_ITERATIONS/i, /maximum iterations/i, /iteration limit/i, ], crash: [ /SIGTERM/i, /SIGKILL/i, /process.*killed/i, /unexpected.*termination/i, ], }; export class OutputAnalyzer { constructor() { this.promptGenerator = new PromptGenerator(); /** @type {import('./lib/provider-adapter.mjs').ProviderAdapter|null} */ this.providerAdapter = null; } /** * Set the provider adapter for CLI abstraction * @param {import('./lib/provider-adapter.mjs').ProviderAdapter} adapter */ setProviderAdapter(adapter) { this.providerAdapter = adapter; } /** * Read and truncate output file * @param {string} path - File path * @param {number} maxChars - Maximum characters * @returns {string} */ readOutput(path, maxChars = MAX_OUTPUT_CHARS) { if (!existsSync(path)) { return ''; } const content = readFileSync(path, 'utf8'); // Take last N characters (most recent output is most relevant) return content.slice(-maxChars); } /** * Analyze output using Claude * @param {AnalysisOptions} options * @returns {AnalysisResult|null} */ analyzeWithClaude(options) { const stdout = this.readOutput(options.stdoutPath); const stderr = this.readOutput(options.stderrPath); const analysisPrompt = this.promptGenerator.buildAnalysisPrompt({ stdout, stderr, exitCode: options.exitCode, objective: options.context.objective, criteria: options.context.criteria, }); try { // Use adapter for binary and args if available const binary = this.providerAdapter ? this.providerAdapter.getBinary() : 'claude'; const args = this.providerAdapter ? this.providerAdapter.buildAnalysisArgs({ prompt: analysisPrompt, model: options.model || 'sonnet', agent: 'ralph-output-analyzer', }) : [ '--dangerously-skip-permissions', '--print', '--output-format', 'json', '--model', options.model || 'sonnet', '--agent', 'ralph-output-analyzer', analysisPrompt, ]; const result = spawnSync(binary, args, { encoding: 'utf8', timeout: 60000, // 1 minute timeout for analysis }); if (result.status !== 0) { console.error('Claude analysis failed:', result.stderr); return null; } // Parse JSON from output const output = result.stdout.trim(); // Try to extract JSON from the output const jsonMatch = output.match(/\{[\s\S]*\}/); if (jsonMatch) { const parsed = JSON.parse(jsonMatch[0]); // Merge with defaults to ensure all fields are present return { completed: false, success: null, failureClass: null, completionPercentage: 0, shouldContinue: true, learnings: '', artifactsModified: [], blockers: [], nextApproach: 'Continue with accumulated context', ...parsed, }; } return null; } catch (e) { console.error('Claude analysis error:', e.message); return null; } } /** * Analyze output using pattern matching (fallback) * @param {AnalysisOptions} options * @returns {AnalysisResult} */ analyzeWithPatterns(options) { const stdout = this.readOutput(options.stdoutPath); const stderr = this.readOutput(options.stderrPath); const combined = stdout + '\n' + stderr; // Default result /** @type {AnalysisResult} */ const result = { completed: false, success: null, failureClass: null, completionPercentage: 0, shouldContinue: true, learnings: '', artifactsModified: [], blockers: [], nextApproach: 'Continue with accumulated context', }; // Check for explicit completion markers for (const pattern of COMPLETION_PATTERNS) { const match = combined.match(pattern); if (match) { result.completed = true; // Try to parse JSON completion marker if (match[0].startsWith('{')) { try { const marker = JSON.parse(match[0]); result.success = marker.success; result.shouldContinue = !marker.success; if (marker.reason) { result.learnings = marker.reason; } } catch { result.success = true; } } else { result.success = true; } result.shouldContinue = !result.success; return result; } } // Check for failure patterns for (const [failureClass, patterns] of Object.entries(FAILURE_PATTERNS)) { for (const pattern of patterns) { if (pattern.test(combined)) { result.failureClass = failureClass; result.learnings = `Session ended due to: ${failureClass}`; // Context exhaustion should continue if (failureClass === 'context_exhausted') { result.shouldContinue = true; result.nextApproach = 'Continue in new session with accumulated context'; } else if (failureClass === 'internal_loop_limit') { result.shouldContinue = true; result.nextApproach = 'Retry with different approach based on learnings'; } else { result.shouldContinue = false; } return result; } } } // Check exit code if (options.exitCode !== 0) { result.failureClass = 'crash'; result.learnings = `Session exited with code ${options.exitCode}`; result.shouldContinue = true; result.nextApproach = 'Retry after crash recovery'; } // Estimate completion percentage from output characteristics result.completionPercentage = this.estimateProgress(stdout); // Extract modified files from git-like patterns result.artifactsModified = this.extractModifiedFiles(stdout); return result; } /** * Estimate completion percentage from output * @param {string} output * @returns {number} */ estimateProgress(output) { let score = 0; // Look for progress indicators if (/tests?\s+(pass|passing)/i.test(output)) score += 30; if (/build.*success/i.test(output)) score += 20; if (/commit/i.test(output)) score += 10; if (/created.*file/i.test(output)) score += 10; if (/modified.*file/i.test(output)) score += 10; if (/ralph.*iteration/i.test(output)) score += 10; // Cap at 90% unless explicitly complete return Math.min(score, 90); } /** * Extract modified files from output * @param {string} output * @returns {string[]} */ extractModifiedFiles(output) { const files = new Set(); // Git-style patterns const patterns = [ /modified:\s+(\S+)/g, /created:\s+(\S+)/g, /Writing.*?(\S+\.(?:ts|js|mjs|md|json))/g, /Edit.*?(\S+\.(?:ts|js|mjs|md|json))/g, ]; for (const pattern of patterns) { let match; while ((match = pattern.exec(output)) !== null) { files.add(match[1]); } } return Array.from(files); } /** * Analyze session output * @param {AnalysisOptions} options * @returns {Promise<AnalysisResult>} */ async analyze(options) { const useClaude = options.useClaude !== false; if (useClaude) { const claudeResult = this.analyzeWithClaude(options); if (claudeResult) { return claudeResult; } console.log('Claude analysis failed, falling back to pattern matching'); } return this.analyzeWithPatterns(options); } } export default OutputAnalyzer;