feat: monorepo consolidation — forge pipeline, MACP protocol, framework plugin, profiles/guides/skills
Work packages completed: - WP1: packages/forge — pipeline runner, stage adapter, board tasks, brief classifier, persona loader with project-level overrides. 89 tests, 95.62% coverage. - WP2: packages/macp — credential resolver, gate runner, event emitter, protocol types. 65 tests, 96.24% coverage. Full Python-to-TS port preserving all behavior. - WP3: plugins/mosaic-framework — OC rails injection plugin (before_agent_start + subagent_spawning hooks for Mosaic contract enforcement). - WP4: profiles/ (domains, tech-stacks, workflows), guides/ (17 docs), skills/ (5 universal skills), forge pipeline assets (48 markdown files). Board deliberation: docs/reviews/consolidation-board-memo.md Brief: briefs/monorepo-consolidation.md Consolidates mosaic/stack (forge, MACP, bootstrap framework) into mosaic/mosaic-stack. 154 new tests total. Zero Python — all TypeScript/ESM.
This commit is contained in:
348
packages/forge/src/pipeline-runner.ts
Normal file
348
packages/forge/src/pipeline-runner.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { STAGE_SEQUENCE } from './constants.js';
|
||||
import { determineBriefClass, stagesForClass } from './brief-classifier.js';
|
||||
import { mapStageToTask } from './stage-adapter.js';
|
||||
import type {
|
||||
ForgeTask,
|
||||
PipelineOptions,
|
||||
PipelineResult,
|
||||
RunManifest,
|
||||
StageStatus,
|
||||
TaskExecutor,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the Forge pipeline.
|
||||
*
|
||||
* 1. Classify the brief
|
||||
* 2. Generate a run ID and create run directory
|
||||
* 3. Map stages to tasks and submit to TaskExecutor
|
||||
* 4. Track manifest with stage statuses
|
||||
* 5. Return pipeline result
|
||||
*/
|
||||
export async function runPipeline(
|
||||
briefPath: string,
|
||||
projectRoot: string,
|
||||
options: PipelineOptions,
|
||||
): Promise<PipelineResult> {
|
||||
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,
|
||||
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
|
||||
const { executor } = options;
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i]!;
|
||||
const stageName = selectedStages[i]!;
|
||||
|
||||
// Update manifest: stage in progress
|
||||
manifest.currentStage = stageName;
|
||||
manifest.stages[stageName] = {
|
||||
status: 'in_progress',
|
||||
startedAt: nowISO(),
|
||||
};
|
||||
saveManifest(runDir, manifest);
|
||||
|
||||
try {
|
||||
await executor.submitTask(task);
|
||||
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
|
||||
|
||||
// Update manifest: stage completed or failed
|
||||
const stageStatus: StageStatus = {
|
||||
status: result.status === 'completed' ? 'passed' : 'failed',
|
||||
startedAt: manifest.stages[stageName]!.startedAt,
|
||||
completedAt: nowISO(),
|
||||
};
|
||||
manifest.stages[stageName] = stageStatus;
|
||||
|
||||
if (result.status !== 'completed') {
|
||||
manifest.status = 'failed';
|
||||
saveManifest(runDir, manifest);
|
||||
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
|
||||
}
|
||||
|
||||
saveManifest(runDir, manifest);
|
||||
} catch (error) {
|
||||
if (!manifest.stages[stageName]?.completedAt) {
|
||||
manifest.stages[stageName] = {
|
||||
status: 'failed',
|
||||
startedAt: manifest.stages[stageName]?.startedAt,
|
||||
completedAt: nowISO(),
|
||||
};
|
||||
}
|
||||
manifest.status = 'failed';
|
||||
saveManifest(runDir, manifest);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// All stages passed
|
||||
manifest.status = '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 incomplete stage.
|
||||
*/
|
||||
export async function resumePipeline(
|
||||
runDir: string,
|
||||
executor: TaskExecutor,
|
||||
): Promise<PipelineResult> {
|
||||
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);
|
||||
|
||||
// Find first non-passed stage
|
||||
const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed');
|
||||
if (!resumeFrom) {
|
||||
manifest.status = '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);
|
||||
}
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i]!;
|
||||
const stageName = remainingStages[i]!;
|
||||
|
||||
manifest.currentStage = stageName;
|
||||
manifest.stages[stageName] = {
|
||||
status: 'in_progress',
|
||||
startedAt: nowISO(),
|
||||
};
|
||||
saveManifest(runDir, manifest);
|
||||
|
||||
try {
|
||||
await executor.submitTask(task);
|
||||
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
|
||||
|
||||
manifest.stages[stageName] = {
|
||||
status: result.status === 'completed' ? 'passed' : 'failed',
|
||||
startedAt: manifest.stages[stageName]!.startedAt,
|
||||
completedAt: nowISO(),
|
||||
};
|
||||
|
||||
if (result.status !== 'completed') {
|
||||
manifest.status = 'failed';
|
||||
saveManifest(runDir, manifest);
|
||||
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
|
||||
}
|
||||
|
||||
saveManifest(runDir, manifest);
|
||||
} catch (error) {
|
||||
if (!manifest.stages[stageName]?.completedAt) {
|
||||
manifest.stages[stageName] = {
|
||||
status: 'failed',
|
||||
startedAt: manifest.stages[stageName]?.startedAt,
|
||||
completedAt: nowISO(),
|
||||
};
|
||||
}
|
||||
manifest.status = 'failed';
|
||||
saveManifest(runDir, manifest);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
manifest.status = '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);
|
||||
}
|
||||
Reference in New Issue
Block a user