import fs from 'node:fs'; import path from 'node:path'; import { STAGE_SEQUENCE, STAGE_SPECS } from './constants.js'; import { determineBriefClass, stagesForClass } from './brief-classifier.js'; import { ForgeCapabilityError, providerErrorCode } from './errors.js'; import { blockedGateResults, evaluateStageGates, isCapabilityGate, simulatedGateResults, waitingGateResults, } from './outcomes.js'; import { mapStageToTask } from './stage-adapter.js'; import { createSimulatedExecutor } from './simulated-executor.js'; import type { ForgeTask, ForgeTaskResult, PipelineOptions, PipelineResult, RunManifest, RunMode, StageStatus, TaskExecutor, } from './types.js'; /** Reason stamped on stages that complete under explicit simulation. */ const SIMULATED_STAGE_REASON = 'simulated execution (--simulate): stage was not executed by a real executor'; /** * Generate a timestamp-based run ID. */ export function generateRunId(): string { const now = new Date(); const pad = (n: number, w = 2) => String(n).padStart(w, '0'); return [ now.getUTCFullYear(), pad(now.getUTCMonth() + 1), pad(now.getUTCDate()), '-', pad(now.getUTCHours()), pad(now.getUTCMinutes()), pad(now.getUTCSeconds()), ].join(''); } /** * Get the ISO timestamp for now. */ function nowISO(): string { return new Date().toISOString(); } /** * Create and persist a run manifest. */ function createManifest(opts: { runId: string; briefPath: string; codebase: string; briefClass: RunManifest['briefClass']; classSource: RunManifest['classSource']; forceBoard: boolean; mode: RunMode; runDir: string; }): RunManifest { const ts = nowISO(); const manifest: RunManifest = { runId: opts.runId, brief: opts.briefPath, codebase: opts.codebase, briefClass: opts.briefClass, classSource: opts.classSource, forceBoard: opts.forceBoard, mode: opts.mode, createdAt: ts, updatedAt: ts, currentStage: '', status: 'in_progress', stages: {}, }; saveManifest(opts.runDir, manifest); return manifest; } /** * Save a manifest to disk. */ export function saveManifest(runDir: string, manifest: RunManifest): void { manifest.updatedAt = nowISO(); const manifestPath = path.join(runDir, 'manifest.json'); fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); } /** * Load a manifest from disk. */ export function loadManifest(runDir: string): RunManifest { const manifestPath = path.join(runDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { throw new Error(`manifest.json not found: ${manifestPath}`); } return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as RunManifest; } /** * Select and validate stages, optionally skipping to a specific stage. */ export function selectStages(stages?: string[], skipTo?: string): string[] { const selected = stages ?? [...STAGE_SEQUENCE]; const unknown = selected.filter((s) => !STAGE_SEQUENCE.includes(s)); if (unknown.length > 0) { throw new Error(`Unknown Forge stages requested: ${unknown.join(', ')}`); } if (!skipTo) return selected; if (!selected.includes(skipTo)) { throw new Error(`skip_to stage '${skipTo}' is not present in the selected stage list`); } const skipIndex = selected.indexOf(skipTo); return selected.slice(skipIndex); } /** * Fail closed when the required executor capability is missing (SDLC-D-035). */ function requireExecutor(executor: TaskExecutor | undefined, simulate: boolean): TaskExecutor { if (executor) return executor; if (simulate) return createSimulatedExecutor({ log: false }); throw new ForgeCapabilityError( 'FORGE_NO_EXECUTOR', 'task-executor', 'no task executor is wired; refusing to run the pipeline with a stub executor (fail closed). ' + 'Pass --simulate to opt into explicitly simulated execution.', ); } /** * Pre-flight a stage's gates in normal mode (fail closed, SDLC-D-035). * * - authority gates: record a typed `waiting-for-authority` stage result and * raise FORGE_AUTHORITY_REQUIRED — approval-based gates never pass vacuously. * - provider gates: record a typed `blocked` stage result and raise the typed * capability error for the missing provider. * * Returns the stage status to record when the pre-flight blocks, or undefined * when the stage may proceed. */ function preflightStageGates( stageName: string, manifest: RunManifest, ): { status: StageStatus; error: ForgeCapabilityError } | undefined { const spec = STAGE_SPECS[stageName]; if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`); for (const gate of spec.qualityGates) { if (!isCapabilityGate(gate)) continue; const startedAt = manifest.stages[stageName]?.startedAt; const completedAt = nowISO(); if (gate.kind === 'authority') { const reason = `gate '${gate.capability}' requires authority sign-off; no mechanical implementation exists (${gate.reason})`; return { status: { status: 'waiting-for-authority', reason, startedAt, completedAt, gateResults: waitingGateResults(spec.qualityGates, reason), }, error: new ForgeCapabilityError( 'FORGE_AUTHORITY_REQUIRED', gate.capability, `stage '${stageName}' is blocked on authority gate '${gate.capability}': ${gate.reason}. ` + 'The pipeline fails closed instead of passing vacuously. Record the approval out-of-band ' + 'or run with --simulate for explicitly simulated execution.', ), }; } const reason = `gate '${gate.capability}' requires provider '${gate.capability}' and none is wired (${gate.reason})`; return { status: { status: 'blocked', reason, startedAt, completedAt, gateResults: blockedGateResults(spec.qualityGates, reason), }, error: new ForgeCapabilityError( providerErrorCode(gate.capability), gate.capability, `stage '${stageName}' requires provider '${gate.capability}' which is not wired: ${gate.reason}. ` + 'The pipeline fails closed instead of passing vacuously.', ), }; } return undefined; } /** * Execute the given stage tasks sequentially, updating the manifest. * * Normal mode requires a real executor and evaluates every declared command * gate through the typed outcome model; any non-verified result fails closed. * Simulate mode types every stage and gate result as `simulated`. */ async function executeStages(opts: { manifest: RunManifest; runDir: string; tasks: ForgeTask[]; stageNames: string[]; executor: TaskExecutor; simulate: boolean; }): Promise { const { manifest, runDir, tasks, stageNames, executor, simulate } = opts; for (let i = 0; i < tasks.length; i++) { const task = tasks[i]!; const stageName = stageNames[i]!; const spec = STAGE_SPECS[stageName]; if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`); // Update manifest: stage in progress manifest.currentStage = stageName; manifest.stages[stageName] = { status: 'in_progress', startedAt: nowISO(), }; saveManifest(runDir, manifest); // Fail-closed pre-flight (normal mode only): authority/provider gates have // no mechanical implementation and must never pass vacuously. if (!simulate) { const blocked = preflightStageGates(stageName, manifest); if (blocked) { manifest.stages[stageName] = blocked.status; manifest.status = blocked.status.status === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed'; saveManifest(runDir, manifest); throw blocked.error; } } let result: ForgeTaskResult; try { await executor.submitTask(task); result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000); } catch (error) { // Process errors (including timeouts) map to the fail-closed `error` outcome. const reason = error instanceof Error ? error.message : String(error); manifest.stages[stageName] = { status: 'error', reason: `executor error: ${reason}`, startedAt: manifest.stages[stageName]?.startedAt, completedAt: nowISO(), gateResults: [], }; manifest.status = 'failed'; saveManifest(runDir, manifest); throw error instanceof Error ? error : new Error(reason); } if (simulate) { manifest.stages[stageName] = { status: 'simulated', reason: SIMULATED_STAGE_REASON, startedAt: manifest.stages[stageName]?.startedAt, completedAt: nowISO(), gateResults: simulatedGateResults(spec.qualityGates), }; saveManifest(runDir, manifest); continue; } const evaluation = evaluateStageGates(stageName, spec.qualityGates, result); manifest.stages[stageName] = { status: evaluation.outcome, reason: evaluation.reason, startedAt: manifest.stages[stageName]?.startedAt, completedAt: nowISO(), gateResults: evaluation.gateResults, }; if (evaluation.outcome !== 'passed') { manifest.status = evaluation.outcome === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed'; saveManifest(runDir, manifest); throw new Error(`Stage ${stageName} ${evaluation.outcome}: ${evaluation.reason}`); } saveManifest(runDir, manifest); } } /** * Run the Forge pipeline. * * 1. Fail closed unless a real executor is wired or simulation is explicit * 2. Classify the brief * 3. Generate a run ID and create run directory * 4. Map stages to tasks and submit to TaskExecutor * 5. Track manifest with typed stage outcomes * 6. Return pipeline result */ export async function runPipeline( briefPath: string, projectRoot: string, options: PipelineOptions, ): Promise { const simulate = options.simulate ?? false; const executor = requireExecutor(options.executor, simulate); const mode: RunMode = simulate ? 'simulated' : 'normal'; const resolvedRoot = path.resolve(projectRoot); const resolvedBrief = path.resolve(briefPath); const briefContent = fs.readFileSync(resolvedBrief, 'utf-8'); // Classify brief const { briefClass, classSource } = determineBriefClass(briefContent, options.briefClass); // Determine stages const classStages = options.stages ?? stagesForClass(briefClass, options.forceBoard); const selectedStages = selectStages(classStages, options.skipTo); // Create run directory const runId = generateRunId(); const runDir = path.join(resolvedRoot, '.forge', 'runs', runId); fs.mkdirSync(runDir, { recursive: true }); // Create manifest const manifest = createManifest({ runId, briefPath: resolvedBrief, codebase: options.codebase ?? '', briefClass, classSource, forceBoard: options.forceBoard ?? false, mode, runDir, }); // Map stages to tasks const tasks: ForgeTask[] = []; for (let i = 0; i < selectedStages.length; i++) { const stageName = selectedStages[i]!; const task = mapStageToTask({ stageName, briefContent, projectRoot: resolvedRoot, runId, runDir, }); // Override dependency chain for selected (possibly filtered) stages if (i > 0) { task.dependsOn = [tasks[i - 1]!.id]; } else { delete task.dependsOn; } tasks.push(task); } // Execute stages await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate }); // All stages reached a terminal state for this mode manifest.status = simulate ? 'simulated' : 'completed'; saveManifest(runDir, manifest); return { runId, briefPath: resolvedBrief, projectRoot: resolvedRoot, runDir, taskIds: tasks.map((t) => t.id), stages: selectedStages, manifest, }; } /** * Resume a pipeline from the last non-passed stage. */ export async function resumePipeline( runDir: string, executor?: TaskExecutor, options?: { simulate?: boolean }, ): Promise { const simulate = options?.simulate ?? false; const wiredExecutor = requireExecutor(executor, simulate); const mode: RunMode = simulate ? 'simulated' : 'normal'; const manifest = loadManifest(runDir); const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root const briefContent = fs.readFileSync(manifest.brief, 'utf-8'); const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard); manifest.mode = mode; // Find first non-satisfying stage (only a verified `passed` counts as done; // simulated and waiting-for-authority stages are re-run). const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed'); if (!resumeFrom) { manifest.status = mode === 'simulated' ? 'simulated' : 'completed'; saveManifest(runDir, manifest); return { runId: manifest.runId, briefPath: manifest.brief, projectRoot: resolvedRoot, runDir, taskIds: [], stages: allStages, manifest, }; } const remainingStages = selectStages(allStages, resumeFrom); manifest.status = 'in_progress'; const tasks: ForgeTask[] = []; for (let i = 0; i < remainingStages.length; i++) { const stageName = remainingStages[i]!; const task = mapStageToTask({ stageName, briefContent, projectRoot: resolvedRoot, runId: manifest.runId, runDir, }); if (i > 0) { task.dependsOn = [tasks[i - 1]!.id]; } else { delete task.dependsOn; } tasks.push(task); } await executeStages({ manifest, runDir, tasks, stageNames: remainingStages, executor: wiredExecutor, simulate, }); manifest.status = simulate ? 'simulated' : 'completed'; saveManifest(runDir, manifest); return { runId: manifest.runId, briefPath: manifest.brief, projectRoot: resolvedRoot, runDir, taskIds: tasks.map((t) => t.id), stages: remainingStages, manifest, }; } /** * Get the status of a pipeline run. */ export function getPipelineStatus(runDir: string): RunManifest { return loadManifest(runDir); }