From a00325da0ed22fbbe6dde9ce94c8fc9664d23c0b Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 5 Apr 2026 00:45:25 -0500 Subject: [PATCH] feat(forge): add registerForgeCommand for mosaic forge CLI surface Adds mosaic forge run|status|resume|personas list subcommands to @mosaicstack/forge, wires registerForgeCommand into the root mosaic CLI, and ships a smoke test asserting command structure. Ref CU-05-01 cli-unification-20260404. Co-Authored-By: Claude Sonnet 4.6 --- packages/forge/package.json | 3 +- packages/forge/src/cli.spec.ts | 57 +++++++ packages/forge/src/cli.ts | 280 +++++++++++++++++++++++++++++++++ packages/forge/src/index.ts | 3 + packages/mosaic/src/cli.ts | 5 + pnpm-lock.yaml | 54 ++++++- 6 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 packages/forge/src/cli.spec.ts create mode 100644 packages/forge/src/cli.ts diff --git a/packages/forge/package.json b/packages/forge/package.json index 1ab372d..ee64970 100644 --- a/packages/forge/package.json +++ b/packages/forge/package.json @@ -26,7 +26,8 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { - "@mosaicstack/macp": "workspace:*" + "@mosaicstack/macp": "workspace:*", + "commander": "^13.0.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/forge/src/cli.spec.ts b/packages/forge/src/cli.spec.ts new file mode 100644 index 0000000..d2fe881 --- /dev/null +++ b/packages/forge/src/cli.spec.ts @@ -0,0 +1,57 @@ +import { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; + +import { registerForgeCommand } from './cli.js'; + +describe('registerForgeCommand', () => { + it('registers a "forge" command on the parent program', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + expect(forgeCmd).toBeDefined(); + }); + + it('registers the four required subcommands under forge', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + expect(forgeCmd).toBeDefined(); + + const subNames = forgeCmd!.commands.map((c) => c.name()); + + expect(subNames).toContain('run'); + expect(subNames).toContain('status'); + expect(subNames).toContain('resume'); + expect(subNames).toContain('personas'); + }); + + it('registers "personas list" as a subcommand of "forge personas"', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + const personasCmd = forgeCmd!.commands.find((c) => c.name() === 'personas'); + expect(personasCmd).toBeDefined(); + + const personasSubNames = personasCmd!.commands.map((c) => c.name()); + expect(personasSubNames).toContain('list'); + }); + + it('does not modify the parent program name or description', () => { + const program = new Command('mosaic'); + program.description('Mosaic Stack CLI'); + registerForgeCommand(program); + + expect(program.name()).toBe('mosaic'); + expect(program.description()).toBe('Mosaic Stack CLI'); + }); + + it('can be called multiple times without throwing', () => { + const program = new Command(); + expect(() => { + registerForgeCommand(program); + }).not.toThrow(); + }); +}); diff --git a/packages/forge/src/cli.ts b/packages/forge/src/cli.ts new file mode 100644 index 0000000..618150a --- /dev/null +++ b/packages/forge/src/cli.ts @@ -0,0 +1,280 @@ +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(''); + }); +} diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 0f939d2..62c765a 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -80,3 +80,6 @@ export { resumePipeline, getPipelineStatus, } from './pipeline-runner.js'; + +// CLI +export { registerForgeCommand } from './cli.js'; diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 378a93d..2a0a579 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -3,6 +3,7 @@ import { createRequire } from 'module'; import { Command } from 'commander'; import { registerBrainCommand } from '@mosaicstack/brain'; +import { registerForgeCommand } from '@mosaicstack/forge'; import { registerLogCommand } from '@mosaicstack/log'; import { registerMemoryCommand } from '@mosaicstack/memory'; import { registerQualityRails } from '@mosaicstack/quality-rails'; @@ -347,6 +348,10 @@ registerMissionCommand(program); registerBrainCommand(program); +// ─── forge ─────────────────────────────────────────────────────────────── + +registerForgeCommand(program); + // ─── quality-rails ────────────────────────────────────────────────────── registerQualityRails(program); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 007b954..053413d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -385,6 +385,9 @@ importers: '@mosaicstack/macp': specifier: workspace:* version: link:../macp + commander: + specifier: ^13.0.0 + version: 13.1.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -664,10 +667,10 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) '@mariozechner/pi-ai': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.48 @@ -7042,6 +7045,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.73.0(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -8383,6 +8392,18 @@ snapshots: - ws - zod + '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8431,6 +8452,30 @@ snapshots: - ws - zod + '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1008.0 + '@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@mistralai/mistralai': 1.14.1 + '@sinclair/typebox': 0.34.48 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + chalk: 5.6.2 + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.24.3 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -12806,6 +12851,11 @@ snapshots: dependencies: mimic-function: 5.0.1 + openai@6.26.0(ws@8.20.0)(zod@3.25.76): + optionalDependencies: + ws: 8.20.0 + zod: 3.25.76 + openai@6.26.0(ws@8.20.0)(zod@4.3.6): optionalDependencies: ws: 8.20.0