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 { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js'; import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js'; import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js'; // --------------------------------------------------------------------------- // Stub executor — used when no real executor is wired at CLI invocation time. // --------------------------------------------------------------------------- const stubExecutor: TaskExecutor = { async submitTask(task) { console.log(` [forge] stage submitted: ${task.id} (${task.title})`); }, async waitForCompletion(taskId, _timeoutMs) { console.log(` [forge] stage complete: ${taskId}`); return { task_id: taskId, status: 'completed' as const, completed_at: new Date().toISOString(), exit_code: 0, gate_results: [], }; }, async getTaskStatus(_taskId) { return 'completed' as const; }, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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 { console.log(`\nRun ID : ${manifest.runId}`); console.log(`Status : ${manifest.status}`); 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(14) + 'Duration'); console.log('-'.repeat(50)); 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(14); 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(14) + 'Brief'); console.log('-'.repeat(70)); for (const runId of entries) { const runDir = path.join(runsDir, runId); try { const manifest = loadManifest(runDir); const status = manifest.status.padEnd(14); const brief = path.basename(manifest.brief); console.log(`${runId.padEnd(22)}${status}${brief}`); } catch { console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`); } } console.log(''); } // --------------------------------------------------------------------------- // 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) .action( async (opts: { brief: string; runId?: string; resume: boolean; config?: string; codebase: string; dryRun: 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; if (opts.resume) { const runId = opts.runId ?? generateRunId(); const runDir = resolveRunDir(runId, projectRoot); console.log(`[forge] resuming run: ${runId}`); const { resumePipeline } = await import('./pipeline-runner.js'); const result = await resumePipeline(runDir, stubExecutor); console.log(`[forge] pipeline complete: ${result.runId}`); return; } const pipelineOptions: PipelineOptions = { briefClass, codebase: projectRoot, dryRun: opts.dryRun, executor: stubExecutor, }; 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}`); try { const result = await runPipeline(briefPath, projectRoot, pipelineOptions); console.log(`[forge] pipeline complete: ${result.runId}`); console.log(`[forge] run directory: ${result.runDir}`); } catch (err) { console.error( `[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`, ); process.exitCode = 1; } }, ); // ── 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()) .action(async (runId: string, opts: { project: string }) => { 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}`); try { const { resumePipeline } = await import('./pipeline-runner.js'); const result = await resumePipeline(runDir, stubExecutor); console.log(`[forge] pipeline complete: ${result.runId}`); console.log(`[forge] run directory: ${result.runDir}`); } catch (err) { console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`); process.exitCode = 1; } }); // ── 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(''); }); }