import fs from 'node:fs'; import path from 'node:path'; import type { Command } from 'commander'; import { classifyBrief } from './brief-classifier.js'; import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js'; import { ForgeCapabilityError } from './errors.js'; import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js'; import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js'; import { createSimulatedExecutor } from './simulated-executor.js'; import type { PipelineOptions, RunManifest, RunMode } from './types.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Resolve a run's effective mode, defaulting legacy manifests to normal. */ function runModeOf(manifest: RunManifest): RunMode { return manifest.mode ?? 'normal'; } /** Print a loud banner so a simulated run can never be misread as verified. */ function printSimulatedBanner(): void { console.log(''); console.log('[forge] ==============================================================='); console.log('[forge] MODE: SIMULATED — no stage or gate was really executed.'); console.log('[forge] All results are synthetic and MUST NOT be read as verified'); console.log('[forge] success. Wire a real executor/providers and re-run to verify.'); console.log('[forge] ==============================================================='); } /** Print a typed error line for fail-closed capability errors. */ function printCapabilityError(err: ForgeCapabilityError): void { console.error(`[forge] error ${err.code}: ${err.message}`); console.error(`[forge] missing capability: ${err.capability}`); } /** Handle a pipeline error uniformly: typed capability errors get their code. */ function handlePipelineError(err: unknown): void { if (err instanceof ForgeCapabilityError) { printCapabilityError(err); } else { console.error(`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`); } process.exitCode = 1; } function formatDuration(startedAt?: string, completedAt?: string): string { if (!startedAt || !completedAt) return '-'; const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime(); const secs = Math.round(ms / 1000); return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${secs % 60}s`; } function printManifestTable(manifest: RunManifest): void { const mode = runModeOf(manifest); console.log(`\nRun ID : ${manifest.runId}`); console.log(`Status : ${manifest.status}`); console.log(`Mode : ${mode}`); if (mode === 'simulated') { console.log('WARNING: SIMULATED RUN — results are synthetic, not verified success.'); } console.log(`Brief : ${manifest.brief}`); console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`); console.log(`Updated: ${manifest.updatedAt}`); console.log(''); console.log('Stage'.padEnd(22) + 'Status'.padEnd(24) + 'Duration'); console.log('-'.repeat(60)); for (const stage of STAGE_SEQUENCE) { const s = manifest.stages[stage]; if (!s) continue; const label = (STAGE_LABELS[stage] ?? stage).padEnd(22); const status = s.status.padEnd(24); const dur = formatDuration(s.startedAt, s.completedAt); console.log(`${label}${status}${dur}`); } console.log(''); } function resolveRunDir(runId: string, projectRoot?: string): string { const root = projectRoot ?? process.cwd(); return path.join(root, '.forge', 'runs', runId); } function listRecentRuns(projectRoot?: string): void { const root = projectRoot ?? process.cwd(); const runsDir = path.join(root, '.forge', 'runs'); if (!fs.existsSync(runsDir)) { console.log('No runs found. Run `mosaic forge run` to start a pipeline.'); return; } const entries = fs .readdirSync(runsDir) .filter((name) => fs.statSync(path.join(runsDir, name)).isDirectory()) .sort() .reverse() .slice(0, 10); if (entries.length === 0) { console.log('No runs found.'); return; } console.log('\nRecent runs:'); console.log('Run ID'.padEnd(22) + 'Status'.padEnd(24) + 'Mode'.padEnd(12) + 'Brief'); console.log('-'.repeat(80)); for (const runId of entries) { const runDir = path.join(runsDir, runId); try { const manifest = loadManifest(runDir); const status = manifest.status.padEnd(24); const mode = runModeOf(manifest).padEnd(12); const brief = path.basename(manifest.brief); console.log(`${runId.padEnd(22)}${status}${mode}${brief}`); } catch { console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(24)}`); } } console.log(''); } /** * Apply the exit-code policy for a finished pipeline run (SDLC-D-035): * * - exit 0 only for a verified `completed` normal run, or for an overall * `simulated` run when the caller explicitly passed --simulate; * - anything else exits nonzero so it can never be read as success. */ function applyRunExitPolicy(result: { manifest: RunManifest; runDir: string }, simulate: boolean) { const { manifest } = result; if (runModeOf(manifest) === 'simulated') { if (!simulate || manifest.status !== 'simulated') { console.error( '[forge] error FORGE_MODE_MISMATCH: run reports simulated results without an explicit, ' + 'consistent --simulate request; refusing to report success.', ); process.exitCode = 1; return; } printSimulatedBanner(); console.log(`[forge] run directory: ${result.runDir}`); return; // exit 0 — the caller explicitly opted into simulation } if (manifest.status !== 'completed') { console.error(`[forge] run did not complete: terminal status '${manifest.status}'`); process.exitCode = 1; return; } console.log(`[forge] pipeline complete (mode: normal): ${manifest.runId}`); console.log(`[forge] run directory: ${result.runDir}`); } // --------------------------------------------------------------------------- // Register function // --------------------------------------------------------------------------- /** * Register forge subcommands on an existing Commander program. * Mirrors the pattern used by registerQualityRails in @mosaicstack/quality-rails. */ export function registerForgeCommand(parent: Command): void { const forge = parent.command('forge').description('Run and manage Forge pipelines'); // ── forge run ──────────────────────────────────────────────────────────── forge .command('run') .description('Run a Forge pipeline from a brief markdown file') .requiredOption('--brief ', 'Path to the brief markdown file') .option('--run-id ', 'Override the auto-generated run ID') .option('--resume', 'Resume an existing run instead of starting a new one', false) .option('--config ', 'Path to forge config file (.forge/config.yaml)') .option('--codebase ', 'Codebase root to pass to the pipeline', process.cwd()) .option('--dry-run', 'Print planned stages without executing', false) .option( '--simulate', 'Simulate execution without real providers (every result is typed simulated, never verified)', false, ) .action( async (opts: { brief: string; runId?: string; resume: boolean; config?: string; codebase: string; dryRun: boolean; simulate: boolean; }) => { const briefPath = path.resolve(opts.brief); if (!fs.existsSync(briefPath)) { console.error(`[forge] brief not found: ${briefPath}`); process.exitCode = 1; return; } const briefContent = fs.readFileSync(briefPath, 'utf-8'); const briefClass = classifyBrief(briefContent); const projectRoot = opts.codebase; // A real executor is never wired at CLI invocation time today, so the // only executor we may construct is the explicitly-requested simulated // one. Normal mode fails closed with FORGE_NO_EXECUTOR. const executor = opts.simulate ? createSimulatedExecutor() : undefined; if (opts.resume) { const runId = opts.runId ?? generateRunId(); const runDir = resolveRunDir(runId, projectRoot); console.log(`[forge] resuming run: ${runId}`); try { const { resumePipeline } = await import('./pipeline-runner.js'); const result = await resumePipeline(runDir, executor, { simulate: opts.simulate }); applyRunExitPolicy(result, opts.simulate); } catch (err) { handlePipelineError(err); } return; } const pipelineOptions: PipelineOptions = { briefClass, codebase: projectRoot, dryRun: opts.dryRun, executor, simulate: opts.simulate, }; if (opts.dryRun) { const { stagesForClass } = await import('./brief-classifier.js'); const stages = stagesForClass(briefClass); console.log(`[forge] dry-run — brief class: ${briefClass}`); console.log('[forge] planned stages:'); for (const stage of stages) { console.log(` - ${stage} (${STAGE_LABELS[stage] ?? stage})`); } return; } console.log(`[forge] starting pipeline for brief: ${briefPath}`); console.log(`[forge] classified as: ${briefClass}`); if (opts.simulate) { console.log('[forge] mode: SIMULATED (explicit --simulate)'); } try { const result = await runPipeline(briefPath, projectRoot, pipelineOptions); applyRunExitPolicy(result, opts.simulate); } catch (err) { handlePipelineError(err); } }, ); // ── forge status ───────────────────────────────────────────────────────── forge .command('status [runId]') .description('Show the status of a pipeline run (omit runId to list recent runs)') .option('--project ', 'Project root (defaults to cwd)', process.cwd()) .action(async (runId: string | undefined, opts: { project: string }) => { if (!runId) { listRecentRuns(opts.project); return; } const runDir = resolveRunDir(runId, opts.project); try { const manifest = getPipelineStatus(runDir); printManifestTable(manifest); } catch (err) { console.error( `[forge] could not load run "${runId}": ${err instanceof Error ? err.message : String(err)}`, ); process.exitCode = 1; } }); // ── forge resume ───────────────────────────────────────────────────────── forge .command('resume ') .description('Resume a stopped or failed pipeline run') .option('--project ', 'Project root (defaults to cwd)', process.cwd()) .option( '--simulate', 'Simulate execution without real providers (every result is typed simulated, never verified)', false, ) .action(async (runId: string, opts: { project: string; simulate: boolean }) => { const runDir = resolveRunDir(runId, opts.project); if (!fs.existsSync(runDir)) { console.error(`[forge] run not found: ${runDir}`); process.exitCode = 1; return; } console.log(`[forge] resuming run: ${runId}`); if (opts.simulate) { console.log('[forge] mode: SIMULATED (explicit --simulate)'); } // No real executor is wired at CLI invocation time; only the explicitly // requested simulated executor may be constructed (fail closed otherwise). const executor = opts.simulate ? createSimulatedExecutor() : undefined; try { const { resumePipeline } = await import('./pipeline-runner.js'); const result = await resumePipeline(runDir, executor, { simulate: opts.simulate }); applyRunExitPolicy(result, opts.simulate); } catch (err) { handlePipelineError(err); } }); // ── forge personas ──────────────────────────────────────────────────────── const personas = forge.command('personas').description('Manage Forge board personas'); personas .command('list') .description('List configured board personas') .option( '--project ', 'Project root for persona overrides (defaults to cwd)', process.cwd(), ) .option('--board-dir ', 'Override the board agents directory') .action((opts: { project: string; boardDir?: string }) => { const effectivePersonas = opts.boardDir ? loadBoardPersonas(opts.boardDir) : getEffectivePersonas(opts.project); if (effectivePersonas.length === 0) { console.log('[forge] no board personas configured.'); return; } console.log(`\nBoard personas (${effectivePersonas.length}):\n`); console.log('Slug'.padEnd(24) + 'Name'); console.log('-'.repeat(50)); for (const p of effectivePersonas) { console.log(`${p.slug.padEnd(24)}${p.name}`); } console.log(''); }); }