#!/usr/bin/env node /** * Unified Step Executor for BMAD-SPEC-KIT * * Automates the complete pipeline: validate → render → update * Eliminates manual tool invocation errors and provides unified error handling * * Usage: * node .claude/tools/orchestrator/execute-step.mjs \ * --config \ * --context * * Features: * - Automatic schema validation with auto-fix * - Artifact rendering (JSON → Markdown) * - Session context updates (transactional) * - Unified error handling with recovery options * - Execution trace logging * - Quality metrics tracking * * @version 2.0.0 * @date 2025-11-13 */ import fs from 'fs/promises'; import path from 'path'; import { execSync } from 'child_process'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PROJECT_ROOT = path.resolve(__dirname, '../../..'); // ============================================================================ // Configuration & Constants // ============================================================================ const CONFIG = { TOOLS: { GATE: path.join(PROJECT_ROOT, '.claude/tools/gates/gate.mjs'), RENDER: path.join(PROJECT_ROOT, '.claude/tools/renderers/bmad-render.mjs'), UPDATE_SESSION: path.join(PROJECT_ROOT, '.claude/tools/context/update-session.mjs') }, PATHS: { CONTEXT: path.join(PROJECT_ROOT, '.claude/context'), ARTIFACTS: path.join(PROJECT_ROOT, '.claude/context/artifacts'), GATES: path.join(PROJECT_ROOT, '.claude/context/history/gates'), TRACES: path.join(PROJECT_ROOT, '.claude/context/history/traces'), SCHEMAS: path.join(PROJECT_ROOT, '.claude/schemas') }, RETRY: { MAX_ATTEMPTS: 2, BACKOFF_MS: 1000, BACKOFF_MULTIPLIER: 2 } }; // ============================================================================ // Utility Functions // ============================================================================ /** * Parse command-line arguments */ function parseArgs() { const args = process.argv.slice(2); const parsed = {}; for (let i = 0; i < args.length; i += 2) { const key = args[i].replace(/^--/, ''); const value = args[i + 1]; parsed[key] = value; } return parsed; } /** * Load JSON file */ async function loadJSON(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); return JSON.parse(content); } catch (error) { throw new Error(`Failed to load JSON from ${filePath}: ${error.message}`); } } /** * Save JSON file */ async function saveJSON(filePath, data) { try { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); } catch (error) { throw new Error(`Failed to save JSON to ${filePath}: ${error.message}`); } } /** * Execute shell command with error handling */ function executeCommand(command, options = {}) { try { const result = execSync(command, { encoding: 'utf-8', stdio: options.silent ? 'pipe' : 'inherit', ...options }); return { success: true, output: result }; } catch (error) { return { success: false, error: error.message, output: error.stdout || '', stderr: error.stderr || '' }; } } /** * Sleep for specified milliseconds */ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Generate timestamp */ function timestamp() { return new Date().toISOString(); } // ============================================================================ // Core Pipeline Functions // ============================================================================ /** * Step 1: Validate Output with Schema and Auto-Fix */ async function validateOutput(stepConfig, agentOutput, sessionContext) { console.log('\n[1/4] Validating output...'); const { schema, agent, step } = stepConfig; const { workflow_name } = sessionContext.project_metadata; // Determine gate file path const gateFile = path.join( CONFIG.PATHS.GATES, workflow_name, `${String(step).padStart(2, '0')}-${agent}.json` ); // Ensure gate directory exists await fs.mkdir(path.dirname(gateFile), { recursive: true }); // Build gate command const schemaPath = path.join(CONFIG.PATHS.SCHEMAS, schema); const inputPath = path.join(CONFIG.PATHS.ARTIFACTS, `${agent}-output-temp.json`); // Save temporary input await saveJSON(inputPath, agentOutput); // Execute gate validation const gateCommand = `node ${CONFIG.TOOLS.GATE} --schema ${schemaPath} --input ${inputPath} --gate ${gateFile} --autofix 1`; console.log(` Schema: ${schema}`); console.log(` Auto-fix: enabled`); const result = executeCommand(gateCommand, { silent: true }); // Load gate result let gateResult; try { gateResult = await loadJSON(gateFile); } catch (error) { gateResult = { passed: false, errors: ['Failed to read gate result'], auto_fixed: false }; } // Clean up temp file try { await fs.unlink(inputPath); } catch (error) { // Ignore cleanup errors } if (!gateResult.passed) { console.error(' ✗ Validation failed'); console.error(` Errors: ${JSON.stringify(gateResult.errors, null, 2)}`); throw new ValidationError('Schema validation failed', gateResult); } console.log(` ✓ Validation passed${gateResult.auto_fixed ? ' (with auto-fix)' : ''}`); return { passed: true, auto_fixed: gateResult.auto_fixed, gate_file: gateFile, gate_result: gateResult }; } /** * Step 2: Render Artifact (JSON → Markdown) */ async function renderArtifact(stepConfig, agentOutput, sessionContext) { console.log('\n[2/4] Rendering artifact...'); const { render } = stepConfig; if (!render) { console.log(' ⊘ No rendering required'); return { skipped: true }; } const { renderer, from: fromPath, to: toPath } = render; // Save JSON artifact const jsonPath = path.join(PROJECT_ROOT, fromPath); await saveJSON(jsonPath, agentOutput); // Execute renderer const mdPath = path.join(PROJECT_ROOT, toPath); const renderCommand = `node ${CONFIG.TOOLS.RENDER} ${renderer} ${jsonPath} > ${mdPath}`; console.log(` Renderer: ${renderer}`); console.log(` Output: ${toPath}`); const result = executeCommand(renderCommand, { silent: false }); if (!result.success) { console.error(' ✗ Rendering failed'); throw new RenderError('Artifact rendering failed', result.error); } console.log(' ✓ Rendering complete'); return { success: true, json_path: fromPath, markdown_path: toPath }; } /** * Step 3: Update Session Context (Transactional) */ async function updateSessionContext(stepConfig, agentOutput, validationResult, renderResult, sessionContext) { console.log('\n[3/4] Updating session context...'); const { agent, step, creates } = stepConfig; // Create context update payload const contextUpdate = { timestamp: timestamp(), step_id: step, agent: agent, status: 'completed', outputs: { [path.basename(creates, '.json')]: { file_reference: renderResult.markdown_path || creates, structured_data: agentOutput, validation_passed: validationResult.passed, auto_fixed: validationResult.auto_fixed, quality_metrics: agentOutput.quality_metrics || {} } }, validation_results: [ { type: 'schema', passed: validationResult.passed, auto_fixed: validationResult.auto_fixed, gate_file: validationResult.gate_file } ] }; // Update agent context if (!sessionContext.agent_contexts[agent]) { sessionContext.agent_contexts[agent] = { status: 'pending', outputs: {} }; } Object.assign(sessionContext.agent_contexts[agent], { status: 'completed', execution_end: timestamp(), ...contextUpdate }); // Update workflow state if (!sessionContext.workflow_state.completed_steps.includes(step)) { sessionContext.workflow_state.completed_steps.push(step); } sessionContext.workflow_state.current_step = step + 1; // Update artifacts registry if (!sessionContext.artifacts) { sessionContext.artifacts = { generated: [], schemas_used: [], context_files: [] }; } if (renderResult.json_path && !sessionContext.artifacts.generated.includes(renderResult.json_path)) { sessionContext.artifacts.generated.push(renderResult.json_path); } if (renderResult.markdown_path && !sessionContext.artifacts.generated.includes(renderResult.markdown_path)) { sessionContext.artifacts.generated.push(renderResult.markdown_path); } const schemaName = path.basename(stepConfig.schema); if (!sessionContext.artifacts.schemas_used.includes(schemaName)) { sessionContext.artifacts.schemas_used.push(schemaName); } // Save updated session context const sessionPath = path.join(CONFIG.PATHS.CONTEXT, 'session.json'); await saveJSON(sessionPath, sessionContext); console.log(' ✓ Context updated'); return { success: true, session_path: sessionPath }; } /** * Step 4: Log Execution Trace */ async function logExecutionTrace(stepConfig, executionResult, sessionContext) { console.log('\n[4/4] Logging execution trace...'); const { agent, step } = stepConfig; const { session_id, project_metadata } = sessionContext; // Load or create execution trace const traceFile = path.join(CONFIG.PATHS.TRACES, `${session_id}.json`); let trace; try { trace = await loadJSON(traceFile); } catch (error) { // Initialize new trace trace = { session_id: session_id, workflow_name: project_metadata.workflow_type, workflow_version: project_metadata.workflow_version || '1.0.0', started_at: project_metadata.created_at, status: 'in_progress', execution_mode: sessionContext.workflow_state.execution_mode || 'sequential', execution_log: [] }; } // Add execution log entry const logEntry = { timestamp: timestamp(), step_id: step, agent: agent, action: 'completed', status: 'completed', duration_ms: executionResult.duration_ms, quality_score: executionResult.quality_score, artifacts_created: executionResult.artifacts_created, validation_results: { schema_validation: { passed: executionResult.validation_result.passed, auto_fixed: executionResult.validation_result.auto_fixed } } }; trace.execution_log.push(logEntry); // Update trace metadata trace.completed_at = timestamp(); trace.total_duration_ms = Date.now() - new Date(trace.started_at).getTime(); // Check if workflow is complete const totalSteps = Object.keys(sessionContext.agent_contexts).length; const completedSteps = sessionContext.workflow_state.completed_steps.length; if (completedSteps >= totalSteps) { trace.status = 'completed'; } // Save trace await saveJSON(traceFile, trace); console.log(' ✓ Trace logged'); return { success: true, trace_file: traceFile }; } // ============================================================================ // Error Handling // ============================================================================ class ValidationError extends Error { constructor(message, details) { super(message); this.name = 'ValidationError'; this.details = details; } } class RenderError extends Error { constructor(message, details) { super(message); this.name = 'RenderError'; this.details = details; } } /** * Handle step errors with recovery options */ async function handleStepError(error, stepConfig, sessionContext, attemptNumber = 1) { console.error(`\n✗ Step execution failed (attempt ${attemptNumber}/${CONFIG.RETRY.MAX_ATTEMPTS + 1})`); console.error(` Error: ${error.message}`); // Check if we should retry if (attemptNumber <= CONFIG.RETRY.MAX_ATTEMPTS && stepConfig.execution?.retry_on_failure) { const backoffMs = CONFIG.RETRY.BACKOFF_MS * Math.pow(CONFIG.RETRY.BACKOFF_MULTIPLIER, attemptNumber - 1); console.log(` Retrying in ${backoffMs}ms...`); await sleep(backoffMs); return { retry: true, attempt: attemptNumber + 1 }; } // Check if we should escalate if (stepConfig.execution?.escalate_to) { console.log(` Escalating to: ${stepConfig.execution.escalate_to}`); return { escalate: true, escalate_to: stepConfig.execution.escalate_to, original_error: error }; } // No recovery options - fail return { failed: true, error: error, recoverable: false }; } // ============================================================================ // Main Execution Function // ============================================================================ /** * Execute a complete workflow step */ async function executeStep(stepConfig, agentOutput, sessionContext) { const startTime = Date.now(); console.log(`\n${'='.repeat(80)}`); console.log(`Executing Step ${stepConfig.step}: ${stepConfig.agent}`); console.log(`${'='.repeat(80)}`); try { // Pipeline Step 1: Validate const validationResult = await validateOutput(stepConfig, agentOutput, sessionContext); // Pipeline Step 2: Render const renderResult = await renderArtifact(stepConfig, agentOutput, sessionContext); // Pipeline Step 3: Update Context const updateResult = await updateSessionContext( stepConfig, agentOutput, validationResult, renderResult, sessionContext ); // Calculate execution metrics const duration_ms = Date.now() - startTime; const quality_score = agentOutput.quality_metrics?.overall_score || 0; const artifacts_created = [renderResult.json_path, renderResult.markdown_path].filter(Boolean); const executionResult = { success: true, duration_ms, quality_score, artifacts_created, validation_result: validationResult, render_result: renderResult, update_result: updateResult }; // Pipeline Step 4: Log Trace await logExecutionTrace(stepConfig, executionResult, sessionContext); console.log(`\n${'='.repeat(80)}`); console.log(`✓ Step ${stepConfig.step} completed successfully in ${duration_ms}ms`); console.log(`${'='.repeat(80)}\n`); return executionResult; } catch (error) { const duration_ms = Date.now() - startTime; console.error(`\n${'='.repeat(80)}`); console.error(`✗ Step ${stepConfig.step} failed after ${duration_ms}ms`); console.error(`${'='.repeat(80)}\n`); throw error; } } // ============================================================================ // CLI Entry Point // ============================================================================ async function main() { try { // Parse arguments const args = parseArgs(); if (!args.config || !args.agentOutput || !args.context) { console.error('Usage: node execute-step.mjs --config --agentOutput --context '); process.exit(1); } // Load inputs const stepConfig = await loadJSON(args.config); const agentOutput = await loadJSON(args.agentOutput); const sessionContext = await loadJSON(args.context); // Execute step const result = await executeStep(stepConfig, agentOutput, sessionContext); // Output result console.log('\nExecution Result:'); console.log(JSON.stringify(result, null, 2)); process.exit(0); } catch (error) { console.error('\nFatal Error:', error.message); if (error.stack) { console.error('\nStack Trace:'); console.error(error.stack); } process.exit(1); } } // Run if called directly if (import.meta.url === `file://${process.argv[1]}`) { main(); } // Export for use as module export { executeStep, validateOutput, renderArtifact, updateSessionContext, logExecutionTrace, handleStepError };