UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

1,263 lines (1,076 loc) 42.7 kB
#!/usr/bin/env node /** * AIOS QA Loop Orchestrator * * Story: 6.5 - QA Loop Orchestrator * Epic: Epic 6 - QA Evolution * * Orchestrates the automated QA review → fix → re-review cycle. * Implements the qa-loop.yaml workflow with full state management. * * Features: * - AC1: Loop automático: review → fix → re-review * - AC2: Máximo 5 iterações (configurável via autoClaude.qaLoop.maxIterations) * - AC3: Após 5 iterações: escalate para humano com full context * - AC4: Track iteração atual em qa/loop-status.json * - AC5: Pode ser interrompido manualmente via *stop-qa-loop * - AC6: Summary ao final com histórico de iterações * - AC7: Integra com status.json para dashboard * * @author @architect (Aria) * @version 1.0.0 */ const fs = require('fs'); const fsPromises = require('fs').promises; const path = require('path'); const yaml = require('js-yaml'); // ═══════════════════════════════════════════════════════════════════════════════════ // CONFIGURATION // ═══════════════════════════════════════════════════════════════════════════════════ const CONFIG = { // AC2: Default max iterations defaultMaxIterations: 5, configPath: 'autoClaude.qaLoop.maxIterations', // AC4: Status file location statusFileName: 'loop-status.json', statusDir: 'qa', // AC7: Dashboard integration paths dashboardStatusPath: '.aios/dashboard/status.json', legacyStatusPath: '.aios/status.json', // Workflow definition workflowPath: '.aios-core/development/workflows/qa-loop.yaml', // Timeouts (milliseconds) reviewTimeout: 1800000, // 30 minutes fixTimeout: 3600000, // 60 minutes // Retry configuration maxRetries: 2, retryDelay: 5000, // Session persistence (AC4 enhancement) abandonedThreshold: 3600000, // 1 hour - consider loop abandoned if no update persistenceIndexPath: '.aios/qa-loops-index.json', // Track all active loops }; // ═══════════════════════════════════════════════════════════════════════════════════ // STATUS ENUM // ═══════════════════════════════════════════════════════════════════════════════════ const LoopStatus = { PENDING: 'pending', IN_PROGRESS: 'in_progress', COMPLETED: 'completed', STOPPED: 'stopped', ESCALATED: 'escalated', }; const Verdict = { APPROVE: 'APPROVE', REJECT: 'REJECT', BLOCKED: 'BLOCKED', }; const StatusEmoji = { [LoopStatus.PENDING]: '⏳', [LoopStatus.IN_PROGRESS]: '🔄', [LoopStatus.COMPLETED]: '✅', [LoopStatus.STOPPED]: '⏹️', [LoopStatus.ESCALATED]: '🚨', }; const VerdictEmoji = { [Verdict.APPROVE]: '✅', [Verdict.REJECT]: '❌', [Verdict.BLOCKED]: '🚫', }; // ═══════════════════════════════════════════════════════════════════════════════════ // QA LOOP ORCHESTRATOR CLASS // ═══════════════════════════════════════════════════════════════════════════════════ class QALoopOrchestrator { /** * Create a new QALoopOrchestrator instance * * @param {string} storyId - Story ID (e.g., 'STORY-42') * @param {Object} options - Configuration options * @param {number} [options.maxIterations] - Maximum loop iterations (AC2) * @param {string} [options.rootPath] - Project root path (defaults to cwd) * @param {boolean} [options.verbose] - Enable verbose logging */ constructor(storyId, options = {}) { this.storyId = storyId; this.rootPath = options.rootPath || process.cwd(); this.maxIterations = options.maxIterations || this._loadMaxIterations(); this.verbose = options.verbose !== false; this.status = null; this._initPaths(); } /** * Initialize file paths * @private */ _initPaths() { // AC4: Status file path this.statusPath = path.join( this.rootPath, CONFIG.statusDir, this.storyId, CONFIG.statusFileName ); // Dashboard paths (AC7) this.dashboardPath = path.join(this.rootPath, CONFIG.dashboardStatusPath); this.legacyStatusPath = path.join(this.rootPath, CONFIG.legacyStatusPath); // Workflow path this.workflowPath = path.join(this.rootPath, CONFIG.workflowPath); } /** * Load max iterations from config (AC2) * @private * @returns {number} Max iterations */ _loadMaxIterations() { // Try to load from project config const configPaths = [ path.join(this.rootPath, '.aios/config.yaml'), path.join(this.rootPath, '.aios/config.yml'), path.join(this.rootPath, 'aios.config.js'), ]; for (const configPath of configPaths) { if (fs.existsSync(configPath)) { try { let config; if (configPath.endsWith('.js')) { config = require(configPath); } else { config = yaml.load(fs.readFileSync(configPath, 'utf-8')); } // Navigate to autoClaude.qaLoop.maxIterations const value = config?.autoClaude?.qaLoop?.maxIterations; if (typeof value === 'number' && value > 0) { return value; } } catch { // Continue to next config } } } return CONFIG.defaultMaxIterations; } // ═══════════════════════════════════════════════════════════════════════════════════ // STATUS MANAGEMENT (AC4) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Load loop status from file * @returns {Object|null} Status object or null if not found */ loadStatus() { if (fs.existsSync(this.statusPath)) { try { this.status = JSON.parse(fs.readFileSync(this.statusPath, 'utf-8')); return this.status; } catch (error) { console.error(`Error loading status: ${error.message}`); return null; } } return null; } /** * Check if loop was abandoned (no update for CONFIG.abandonedThreshold) * @returns {boolean} True if abandoned */ isAbandoned() { if (!this.status) { this.loadStatus(); } if (!this.status || this.status.status !== LoopStatus.IN_PROGRESS) { return false; } const lastUpdate = new Date(this.status.updatedAt).getTime(); const now = Date.now(); return now - lastUpdate > CONFIG.abandonedThreshold; } /** * Recover from abandoned state * @returns {Object} Updated status */ recoverFromAbandoned() { if (!this.status) { throw new Error('No status to recover'); } this._log(`\n⚠️ Detected abandoned QA loop for ${this.storyId}`); this._log(` Last update: ${this.status.updatedAt}`); this._log(` Recovering...`); // Mark as interrupted and save this.status.wasAbandoned = true; this.status.recoveredAt = new Date().toISOString(); // Add recovery note to history if (this.status.history.length > 0) { const lastEntry = this.status.history[this.status.history.length - 1]; if (!lastEntry.fixedAt) { lastEntry.interruptedAt = this.status.recoveredAt; lastEntry.interruptReason = 'Session ended unexpectedly'; } } this.saveStatus(); this._updateLoopsIndex(); return this.status; } /** * Update the global loops index for cross-session tracking * @private */ _updateLoopsIndex() { const indexPath = path.join(this.rootPath, CONFIG.persistenceIndexPath); let index = { version: '1.0', loops: {}, updatedAt: null }; // Load existing index if (fs.existsSync(indexPath)) { try { index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); } catch { // Reset if corrupted index = { version: '1.0', loops: {}, updatedAt: null }; } } // Update this loop's entry index.loops[this.storyId] = { status: this.status.status, currentIteration: this.status.currentIteration, maxIterations: this.status.maxIterations, statusPath: this.statusPath, updatedAt: this.status.updatedAt, wasAbandoned: this.status.wasAbandoned || false, }; // Clean up completed/old loops (keep last 50) const loopEntries = Object.entries(index.loops); if (loopEntries.length > 50) { const sorted = loopEntries.sort( (a, b) => new Date(b[1].updatedAt) - new Date(a[1].updatedAt) ); index.loops = Object.fromEntries(sorted.slice(0, 50)); } index.updatedAt = new Date().toISOString(); // Ensure directory exists const dir = path.dirname(indexPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf-8'); } /** * Save loop status to file * @returns {string} Path to saved file */ saveStatus() { if (!this.status) { throw new Error('No status to save'); } this.status.updatedAt = new Date().toISOString(); // Ensure directory exists const dir = path.dirname(this.statusPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(this.statusPath, JSON.stringify(this.status, null, 2), 'utf-8'); // AC7: Update dashboard this.updateStatusJson(); return this.statusPath; } /** * Initialize new loop status * @private * @returns {Object} Initial status */ _initStatus() { this.status = { storyId: this.storyId, currentIteration: 0, maxIterations: this.maxIterations, status: LoopStatus.PENDING, startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), history: [], }; return this.status; } /** * Increment iteration counter * @returns {number} New iteration number */ incrementIteration() { if (!this.status) { throw new Error('Status not initialized'); } this.status.currentIteration++; this.saveStatus(); return this.status.currentIteration; } /** * Add entry to history * @param {Object} entry - History entry */ addHistoryEntry(entry) { if (!this.status) { throw new Error('Status not initialized'); } this.status.history.push({ iteration: this.status.currentIteration, reviewedAt: null, verdict: null, issuesFound: 0, fixedAt: null, issuesFixed: null, duration: null, ...entry, }); this.saveStatus(); } /** * Update last history entry * @param {Object} updates - Fields to update */ updateLastHistoryEntry(updates) { if (!this.status || this.status.history.length === 0) { throw new Error('No history to update'); } const lastEntry = this.status.history[this.status.history.length - 1]; Object.assign(lastEntry, updates); this.saveStatus(); } // ═══════════════════════════════════════════════════════════════════════════════════ // CORE LOOP (AC1) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Run the complete QA loop * @returns {Promise<Object>} Loop result */ async runLoop() { this._log(''); this._log('╔══════════════════════════════════════════════════════════════╗'); this._log('║ QA Loop Orchestrator ║'); this._log('╚══════════════════════════════════════════════════════════════╝'); this._log(''); this._log(`Story: ${this.storyId}`); this._log(`Max Iterations: ${this.maxIterations}`); this._log(''); // Initialize or load status with abandoned detection const existingStatus = this.loadStatus(); if (existingStatus && existingStatus.status === LoopStatus.IN_PROGRESS) { // Check if abandoned (AC4 enhancement - session persistence) if (this.isAbandoned()) { this.recoverFromAbandoned(); this._log('⚠️ Recovered from abandoned session, resuming...'); } else { this._log('⚠️ Resuming existing loop...'); } } else { this._initStatus(); } this.status.status = LoopStatus.IN_PROGRESS; this.saveStatus(); this._updateLoopsIndex(); try { // AC1: Loop until approved, max iterations, or stopped while (this.status.currentIteration < this.maxIterations) { // Check if stopped (AC5) if (this.status.status === LoopStatus.STOPPED) { this._log('\n⏹️ Loop stopped by user'); break; } // Run iteration const result = await this.runIteration(); // Check result if (result.verdict === Verdict.APPROVE) { this.status.status = LoopStatus.COMPLETED; this.saveStatus(); break; } if (result.verdict === Verdict.BLOCKED) { await this.escalateToHuman('Review verdict is BLOCKED'); break; } // Check max iterations (AC2, AC3) if (this.status.currentIteration >= this.maxIterations) { await this.escalateToHuman( `Max iterations (${this.maxIterations}) reached without APPROVE` ); break; } } // AC6: Generate summary const summary = this.generateSummary(); this._log(summary); return { success: this.status.status === LoopStatus.COMPLETED, status: this.status, summary, }; } catch (error) { this._log(`\n❌ Error in QA loop: ${error.message}`); this.status.status = LoopStatus.ESCALATED; this.status.escalationReason = error.message; this.saveStatus(); throw error; } } /** * Run a single iteration of the loop * @returns {Promise<Object>} Iteration result */ async runIteration() { this.incrementIteration(); const iterationStart = Date.now(); this._log(''); this._log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); this._log(` Iteration ${this.status.currentIteration}/${this.maxIterations}`); this._log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); // Initialize history entry this.addHistoryEntry({ iteration: this.status.currentIteration, }); // Phase 1: Execute review const reviewResult = await this.executeReview(); // Update history with review result this.updateLastHistoryEntry({ reviewedAt: new Date().toISOString(), verdict: reviewResult.verdict, issuesFound: reviewResult.issuesFound, }); // Check verdict if (reviewResult.verdict === Verdict.APPROVE) { this._log('\n✅ Review APPROVED - loop complete'); return reviewResult; } if (reviewResult.verdict === Verdict.BLOCKED) { this._log('\n🚫 Review BLOCKED - escalating'); return reviewResult; } // Phase 2: Create fix request const fixRequest = await this.executeFixRequest(reviewResult); // Phase 3: Execute fixes const fixResult = await this.executeFix(fixRequest); // Update history with fix result this.updateLastHistoryEntry({ fixedAt: new Date().toISOString(), issuesFixed: fixResult.issuesFixed, duration: Date.now() - iterationStart, }); return { verdict: reviewResult.verdict, issuesFound: reviewResult.issuesFound, issuesFixed: fixResult.issuesFixed, duration: Date.now() - iterationStart, }; } // ═══════════════════════════════════════════════════════════════════════════════════ // PHASE EXECUTION // ═══════════════════════════════════════════════════════════════════════════════════ /** * Execute the review phase * @returns {Promise<Object>} Review result with verdict and issues */ async executeReview() { this._log('\n📋 Phase 1: QA Review'); this._log(' Agent: @qa'); this._log(' Task: qa-review-story.md'); // TODO: In production, this would invoke the QA agent // For now, we simulate the interface // Simulate review execution // In real implementation: // const result = await this.invokeAgent('qa', 'qa-review-story.md', { // storyId: this.storyId, // iteration: this.status.currentIteration, // }); // Return simulated result structure return { verdict: Verdict.REJECT, // Would come from actual review issuesFound: 0, gateFile: null, }; } /** * Execute the fix request creation phase * @param {Object} reviewResult - Result from review phase * @returns {Promise<Object>} Fix request */ async executeFixRequest(reviewResult) { this._log('\n📝 Phase 2: Create Fix Request'); this._log(' Agent: @qa'); this._log(' Task: qa-create-fix-request.md'); // TODO: In production, this would invoke the QA agent // For now, we return a simulated structure return { prioritizedIssues: [], fixRequestPath: null, }; } /** * Execute the fix phase * @param {Object} fixRequest - Fix request from previous phase * @returns {Promise<Object>} Fix result */ async executeFix(fixRequest) { this._log('\n🔧 Phase 3: Apply Fixes'); this._log(' Agent: @dev'); this._log(' Task: dev-apply-qa-fixes.md'); // TODO: In production, this would invoke the Dev agent // For now, we return a simulated structure return { issuesFixed: 0, fixesApplied: [], }; } // ═══════════════════════════════════════════════════════════════════════════════════ // ESCALATION (AC3) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Escalate to human with full context * @param {string} reason - Escalation reason * @returns {Promise<void>} */ async escalateToHuman(reason) { this._log('\n🚨 ESCALATION TO HUMAN'); this._log(` Reason: ${reason}`); this.status.status = LoopStatus.ESCALATED; this.status.escalationReason = reason; this.status.escalatedAt = new Date().toISOString(); this.saveStatus(); // Generate escalation report const report = this._generateEscalationReport(reason); this._log(report); // Save escalation report const reportPath = path.join(path.dirname(this.statusPath), `escalation-${Date.now()}.md`); fs.writeFileSync(reportPath, report, 'utf-8'); this._log(`\n📄 Escalation report saved to: ${reportPath}`); } /** * Generate escalation report * @private * @param {string} reason - Escalation reason * @returns {string} Markdown report */ _generateEscalationReport(reason) { const lines = []; lines.push('# QA Loop Escalation Report'); lines.push(''); lines.push(`**Story:** ${this.storyId}`); lines.push(`**Escalated At:** ${new Date().toISOString()}`); lines.push(`**Reason:** ${reason}`); lines.push(''); lines.push('## Loop Summary'); lines.push(''); lines.push(`- **Iterations Completed:** ${this.status.currentIteration}/${this.maxIterations}`); lines.push(`- **Status:** ${this.status.status}`); lines.push(''); lines.push('## Iteration History'); lines.push(''); for (const entry of this.status.history) { const emoji = VerdictEmoji[entry.verdict] || '❓'; lines.push(`### Iteration ${entry.iteration}`); lines.push(`- **Verdict:** ${emoji} ${entry.verdict || 'N/A'}`); lines.push(`- **Issues Found:** ${entry.issuesFound}`); lines.push(`- **Issues Fixed:** ${entry.issuesFixed ?? 'N/A'}`); lines.push(`- **Reviewed At:** ${entry.reviewedAt || 'N/A'}`); lines.push(`- **Fixed At:** ${entry.fixedAt || 'N/A'}`); lines.push(''); } lines.push('## Recommended Actions'); lines.push(''); lines.push('1. **Review the QA gate files** in `qa/{storyId}/`'); lines.push('2. **Manually address blocking issues** identified in reviews'); lines.push('3. **Resume loop** with: `*resume-qa-loop ' + this.storyId + '`'); lines.push('4. **Or approve manually** if issues are acceptable'); lines.push(''); return lines.join('\n'); } // ═══════════════════════════════════════════════════════════════════════════════════ // DASHBOARD INTEGRATION (AC7) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Update dashboard status.json * @returns {string} Path to updated file */ updateStatusJson() { // Update dashboard status this._updateStatusFile(this.dashboardPath); // Update legacy status for backwards compatibility this._updateStatusFile(this.legacyStatusPath); return this.dashboardPath; } /** * Update a specific status file * @private * @param {string} statusPath - Path to status file */ _updateStatusFile(statusPath) { let dashboardStatus = {}; if (fs.existsSync(statusPath)) { try { dashboardStatus = JSON.parse(fs.readFileSync(statusPath, 'utf-8')); } catch { dashboardStatus = {}; } } // Initialize structure if needed if (!dashboardStatus.version) dashboardStatus.version = '1.0'; if (!dashboardStatus.qaLoop) dashboardStatus.qaLoop = {}; // Update qaLoop section for this story dashboardStatus.qaLoop[this.storyId] = { status: this.status.status, currentIteration: this.status.currentIteration, maxIterations: this.status.maxIterations, lastVerdict: this.status.history.length > 0 ? this.status.history[this.status.history.length - 1].verdict : null, lastIssuesFound: this.status.history.length > 0 ? this.status.history[this.status.history.length - 1].issuesFound : 0, escalationReason: this.status.escalationReason || null, updatedAt: new Date().toISOString(), }; dashboardStatus.updatedAt = new Date().toISOString(); // Ensure directory exists const dir = path.dirname(statusPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(statusPath, JSON.stringify(dashboardStatus, null, 2), 'utf-8'); } // ═══════════════════════════════════════════════════════════════════════════════════ // CONTROL METHODS (AC5) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Stop the loop * @returns {Object} Current status */ stop() { if (!this.status) { this.loadStatus(); } if (!this.status) { throw new Error(`No active loop found for ${this.storyId}`); } this.status.status = LoopStatus.STOPPED; this.status.stoppedAt = new Date().toISOString(); this.saveStatus(); this._log(`\n⏹️ QA loop stopped for ${this.storyId}`); this._log(` Iteration: ${this.status.currentIteration}/${this.maxIterations}`); return this.status; } /** * Resume a stopped or escalated loop * @returns {Promise<Object>} Loop result */ async resume() { this.loadStatus(); if (!this.status) { throw new Error(`No loop found for ${this.storyId}`); } if (this.status.status !== LoopStatus.STOPPED && this.status.status !== LoopStatus.ESCALATED) { throw new Error( `Cannot resume loop with status '${this.status.status}'. ` + `Must be '${LoopStatus.STOPPED}' or '${LoopStatus.ESCALATED}'.` ); } this._log(`\n▶️ Resuming QA loop for ${this.storyId}`); this._log(` From iteration: ${this.status.currentIteration}/${this.maxIterations}`); this.status.status = LoopStatus.IN_PROGRESS; this.status.resumedAt = new Date().toISOString(); this.saveStatus(); return this.runLoop(); } /** * Reset the loop (start fresh) * @returns {Object} New status */ reset() { // Delete existing status file if (fs.existsSync(this.statusPath)) { fs.unlinkSync(this.statusPath); } this._initStatus(); this.saveStatus(); this._log(`\n🔄 QA loop reset for ${this.storyId}`); return this.status; } // ═══════════════════════════════════════════════════════════════════════════════════ // SUMMARY GENERATION (AC6) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Generate loop summary * @returns {string} Summary report */ generateSummary() { if (!this.status) { return 'No status available'; } const lines = []; const emoji = StatusEmoji[this.status.status] || '❓'; lines.push(''); lines.push('╔══════════════════════════════════════════════════════════════╗'); lines.push('║ QA Loop Summary ║'); lines.push('╚══════════════════════════════════════════════════════════════╝'); lines.push(''); lines.push(`Story: ${this.storyId}`); lines.push(`Status: ${emoji} ${this.status.status.toUpperCase()}`); lines.push(`Iterations: ${this.status.currentIteration}/${this.status.maxIterations}`); if (this.status.history.length > 0) { const lastEntry = this.status.history[this.status.history.length - 1]; const verdictEmoji = VerdictEmoji[lastEntry.verdict] || '❓'; lines.push(`Last Verdict: ${verdictEmoji} ${lastEntry.verdict || 'N/A'}`); } lines.push(''); lines.push('─'.repeat(60)); lines.push(''); lines.push('Iteration History:'); lines.push(''); // Calculate totals let totalIssuesFound = 0; let totalIssuesFixed = 0; let totalDuration = 0; for (const entry of this.status.history) { const emoji = VerdictEmoji[entry.verdict] || '❓'; const fixed = entry.issuesFixed !== null ? entry.issuesFixed : '-'; const duration = entry.duration ? `${Math.round(entry.duration / 1000)}s` : '-'; lines.push( ` ${entry.iteration}. ${emoji} ${(entry.verdict || 'N/A').padEnd(8)} ` + `| Found: ${String(entry.issuesFound).padStart(2)} | ` + `Fixed: ${String(fixed).padStart(2)} | ` + `Duration: ${duration}` ); totalIssuesFound += entry.issuesFound || 0; totalIssuesFixed += entry.issuesFixed || 0; totalDuration += entry.duration || 0; } lines.push(''); lines.push('─'.repeat(60)); lines.push(''); lines.push(`Total Issues Found: ${totalIssuesFound}`); lines.push(`Total Issues Fixed: ${totalIssuesFixed}`); lines.push(`Total Duration: ${Math.round(totalDuration / 1000)}s`); if (this.status.escalationReason) { lines.push(''); lines.push(`⚠️ Escalation Reason: ${this.status.escalationReason}`); } lines.push(''); return lines.join('\n'); } /** * Get current status * @returns {Object} Status object */ getStatus() { if (!this.status) { this.loadStatus(); } return this.status; } // ═══════════════════════════════════════════════════════════════════════════════════ // UTILITY METHODS // ═══════════════════════════════════════════════════════════════════════════════════ /** * Log message if verbose * @private * @param {string} message - Message to log */ _log(message) { if (this.verbose) { console.log(message); } } /** * Convert status to JSON * @returns {Object} Status object */ toJSON() { return this.status; } } // ═══════════════════════════════════════════════════════════════════════════════════ // HELPER FUNCTIONS // ═══════════════════════════════════════════════════════════════════════════════════ /** * Quick helper to get loop status for a story * @param {string} storyId - Story ID * @returns {Object|null} Status or null */ function getLoopStatus(storyId) { const orchestrator = new QALoopOrchestrator(storyId, { verbose: false }); return orchestrator.getStatus(); } /** * Start a new QA loop for a story * @param {string} storyId - Story ID * @param {Object} options - Options * @returns {Promise<Object>} Loop result */ async function startLoop(storyId, options = {}) { const orchestrator = new QALoopOrchestrator(storyId, options); return orchestrator.runLoop(); } /** * Stop an active QA loop * @param {string} storyId - Story ID * @returns {Object} Final status */ function stopLoop(storyId) { const orchestrator = new QALoopOrchestrator(storyId, { verbose: false }); return orchestrator.stop(); } /** * Resume a stopped QA loop * @param {string} storyId - Story ID * @param {Object} options - Options * @returns {Promise<Object>} Loop result */ async function resumeLoop(storyId, options = {}) { const orchestrator = new QALoopOrchestrator(storyId, options); return orchestrator.resume(); } /** * List all tracked QA loops (AC4 enhancement - session persistence) * @param {Object} options - Options * @param {string} [options.rootPath] - Project root path * @param {string} [options.filter] - Filter by status: 'active', 'abandoned', 'all' * @returns {Object} Loops index with filtered results */ function listLoops(options = {}) { const rootPath = options.rootPath || process.cwd(); const filter = options.filter || 'all'; const indexPath = path.join(rootPath, CONFIG.persistenceIndexPath); if (!fs.existsSync(indexPath)) { return { version: '1.0', loops: {}, count: 0, filtered: filter }; } try { const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); let loops = index.loops; // Apply filter if (filter === 'active') { loops = Object.fromEntries( Object.entries(loops).filter(([, v]) => v.status === LoopStatus.IN_PROGRESS) ); } else if (filter === 'abandoned') { const now = Date.now(); loops = Object.fromEntries( Object.entries(loops).filter(([, v]) => { if (v.status !== LoopStatus.IN_PROGRESS) return false; const lastUpdate = new Date(v.updatedAt).getTime(); return now - lastUpdate > CONFIG.abandonedThreshold; }) ); } return { version: index.version, loops, count: Object.keys(loops).length, filtered: filter, updatedAt: index.updatedAt, }; } catch (error) { console.error(`Error loading loops index: ${error.message}`); return { version: '1.0', loops: {}, count: 0, filtered: filter, error: error.message }; } } /** * Check for and report abandoned loops * @param {Object} options - Options * @returns {Array} List of abandoned loop story IDs */ function checkAbandonedLoops(options = {}) { const result = listLoops({ ...options, filter: 'abandoned' }); return Object.keys(result.loops); } // ═══════════════════════════════════════════════════════════════════════════════════ // CLI INTERFACE // ═══════════════════════════════════════════════════════════════════════════════════ function printHelp() { console.log(` 📊 QA Loop Orchestrator - AIOS QA Evolution (Story 6.5) Usage: node qa-loop-orchestrator.js <story-id> [command] [options] *qa-loop <story-id> [command] [options] Commands: start Start QA loop (default) status Show current loop status stop Stop loop (sets status to 'stopped') (AC5) resume Resume from last iteration escalate Force escalation to human (AC3) reset Reset loop and start fresh summary Show iteration summary (AC6) list List all tracked loops (--filter=active|abandoned|all) check-abandoned Check for abandoned loops and report Options: --max-iterations <n> Set max iterations (default: 5) (AC2) --quiet, -q Suppress verbose output --help, -h Show this help message Examples: node qa-loop-orchestrator.js STORY-42 node qa-loop-orchestrator.js STORY-42 start node qa-loop-orchestrator.js STORY-42 status node qa-loop-orchestrator.js STORY-42 stop node qa-loop-orchestrator.js STORY-42 resume node qa-loop-orchestrator.js STORY-42 --max-iterations 3 Acceptance Criteria Coverage: AC1: Loop automático: review → fix → re-review AC2: Máximo 5 iterações (configurável via autoClaude.qaLoop.maxIterations) AC3: Após 5 iterações: escalate para humano com full context AC4: Track iteração atual em qa/loop-status.json AC5: Pode ser interrompido manualmente via *stop-qa-loop AC6: Summary ao final com histórico de iterações AC7: Integra com status.json para dashboard Status File Schema (AC4): { "storyId": "STORY-42", "currentIteration": 2, "maxIterations": 5, "status": "in_progress", "history": [ { "iteration": 1, "reviewedAt": "2026-01-28T10:00:00Z", "verdict": "REJECT", "issuesFound": 3, "fixedAt": "2026-01-28T11:00:00Z", "issuesFixed": 3 } ] } `); } async function main() { const args = process.argv.slice(2); if (args.length < 1 || args.includes('--help') || args.includes('-h')) { printHelp(); process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1); } // Parse arguments let storyId = null; let command = 'start'; let maxIterations = null; let quiet = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--max-iterations' && args[i + 1]) { maxIterations = parseInt(args[++i], 10); } else if (arg === '--quiet' || arg === '-q') { quiet = true; } else if (!arg.startsWith('-')) { if (!storyId) { storyId = arg; } else if (command === 'start') { command = arg; } } } if (!storyId) { console.error('Error: Story ID required'); process.exit(1); } const options = { verbose: !quiet, }; if (maxIterations) { options.maxIterations = maxIterations; } try { const orchestrator = new QALoopOrchestrator(storyId, options); switch (command) { case 'start': await orchestrator.runLoop(); break; case 'status': const status = orchestrator.getStatus(); if (status) { console.log(JSON.stringify(status, null, 2)); } else { console.log(`No active loop found for ${storyId}`); } break; case 'stop': orchestrator.stop(); console.log(`✅ Loop stopped for ${storyId}`); break; case 'resume': await orchestrator.resume(); break; case 'escalate': orchestrator.loadStatus(); if (!orchestrator.status) { orchestrator._initStatus(); } await orchestrator.escalateToHuman('Manual escalation requested'); break; case 'reset': orchestrator.reset(); console.log(`✅ Loop reset for ${storyId}`); break; case 'summary': orchestrator.loadStatus(); console.log(orchestrator.generateSummary()); break; case 'list': { // Parse filter option let filter = 'all'; const filterArg = args.find((a) => a.startsWith('--filter=')); if (filterArg) { filter = filterArg.split('=')[1]; } const listResult = listLoops({ filter }); console.log('\n📋 QA Loops Index'); console.log('─'.repeat(60)); console.log(`Filter: ${listResult.filtered} | Count: ${listResult.count}`); console.log(''); for (const [id, loop] of Object.entries(listResult.loops)) { const emoji = StatusEmoji[loop.status] || '❓'; const abandoned = loop.wasAbandoned ? ' (recovered)' : ''; console.log( ` ${emoji} ${id}: ${loop.status} - ${loop.currentIteration}/${loop.maxIterations}${abandoned}` ); } if (listResult.count === 0) { console.log(' No loops found'); } console.log(''); break; } case 'check-abandoned': { const abandoned = checkAbandonedLoops(); if (abandoned.length === 0) { console.log('✅ No abandoned loops found'); } else { console.log(`\n⚠️ Found ${abandoned.length} abandoned loop(s):`); for (const id of abandoned) { console.log(` - ${id}`); } console.log('\nUse "resume <story-id>" to recover'); } break; } default: console.error(`Unknown command: ${command}`); printHelp(); process.exit(1); } } catch (error) { console.error(`\n❌ Error: ${error.message}`); process.exit(1); } } // ═══════════════════════════════════════════════════════════════════════════════════ // EXPORTS // ═══════════════════════════════════════════════════════════════════════════════════ module.exports = { QALoopOrchestrator, LoopStatus, Verdict, StatusEmoji, VerdictEmoji, // Helper functions getLoopStatus, startLoop, stopLoop, resumeLoop, // Session persistence helpers (AC4 enhancement) listLoops, checkAbandonedLoops, // Config for external use CONFIG, }; // Run CLI if executed directly if (require.main === module) { main(); }