feat: monorepo consolidation — forge pipeline, MACP protocol, framework plugin, profiles/guides/skills
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed

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:
Mos (Agent)
2026-03-30 19:43:24 +00:00
parent 40c068fcbc
commit 10689a30d2
123 changed files with 18166 additions and 11 deletions

View File

@@ -0,0 +1,199 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
buildPersonaBrief,
writePersonaBrief,
personaResultPath,
synthesisResultPath,
generateBoardTasks,
synthesizeReviews,
} from '../src/board-tasks.js';
import type { BoardPersona, PersonaReview } from '../src/types.js';
const testPersonas: BoardPersona[] = [
{ name: 'CEO', slug: 'ceo', description: 'The CEO sets direction.', path: 'agents/board/ceo.md' },
{
name: 'CTO',
slug: 'cto',
description: 'The CTO evaluates feasibility.',
path: 'agents/board/cto.md',
},
];
describe('buildPersonaBrief', () => {
it('includes persona name and description', () => {
const brief = buildPersonaBrief('Build feature X', testPersonas[0]!);
expect(brief).toContain('# Board Evaluation: CEO');
expect(brief).toContain('The CEO sets direction.');
expect(brief).toContain('Build feature X');
expect(brief).toContain('"persona": "CEO"');
});
});
describe('writePersonaBrief', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-board-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('writes brief file to disk', () => {
const briefPath = writePersonaBrief(tmpDir, 'BOARD', testPersonas[0]!, 'Test brief');
expect(fs.existsSync(briefPath)).toBe(true);
const content = fs.readFileSync(briefPath, 'utf-8');
expect(content).toContain('Board Evaluation: CEO');
});
});
describe('personaResultPath', () => {
it('builds correct path', () => {
const p = personaResultPath('/run/abc', 'BOARD-ceo');
expect(p).toContain('01-board/results/BOARD-ceo.board.json');
});
});
describe('synthesisResultPath', () => {
it('builds correct path', () => {
const p = synthesisResultPath('/run/abc', 'BOARD-SYNTHESIS');
expect(p).toContain('01-board/results/BOARD-SYNTHESIS.board.json');
});
});
describe('generateBoardTasks', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-board-tasks-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('generates one task per persona plus synthesis', () => {
const tasks = generateBoardTasks('Test brief', testPersonas, tmpDir);
expect(tasks).toHaveLength(3); // 2 personas + 1 synthesis
});
it('persona tasks have no dependsOn', () => {
const tasks = generateBoardTasks('Test brief', testPersonas, tmpDir);
expect(tasks[0]!.dependsOn).toBeUndefined();
expect(tasks[1]!.dependsOn).toBeUndefined();
});
it('synthesis task depends on all persona tasks', () => {
const tasks = generateBoardTasks('Test brief', testPersonas, tmpDir);
const synthesis = tasks[tasks.length - 1]!;
expect(synthesis.id).toBe('BOARD-SYNTHESIS');
expect(synthesis.dependsOn).toEqual(['BOARD-ceo', 'BOARD-cto']);
expect(synthesis.dependsOnPolicy).toBe('all_terminal');
});
it('persona tasks have correct metadata', () => {
const tasks = generateBoardTasks('Test brief', testPersonas, tmpDir);
expect(tasks[0]!.metadata['personaName']).toBe('CEO');
expect(tasks[0]!.metadata['personaSlug']).toBe('ceo');
});
it('uses custom base task ID', () => {
const tasks = generateBoardTasks('Brief', testPersonas, tmpDir, 'CUSTOM');
expect(tasks[0]!.id).toBe('CUSTOM-ceo');
expect(tasks[tasks.length - 1]!.id).toBe('CUSTOM-SYNTHESIS');
});
it('writes persona brief files to disk', () => {
generateBoardTasks('Test brief', testPersonas, tmpDir);
const briefDir = path.join(tmpDir, '01-board', 'briefs');
expect(fs.existsSync(briefDir)).toBe(true);
const files = fs.readdirSync(briefDir);
expect(files).toHaveLength(2);
});
});
describe('synthesizeReviews', () => {
const makeReview = (
persona: string,
verdict: PersonaReview['verdict'],
confidence: number,
): PersonaReview => ({
persona,
verdict,
confidence,
concerns: [`${persona} concern`],
recommendations: [`${persona} rec`],
keyRisks: [`${persona} risk`],
});
it('returns approve when all approve', () => {
const result = synthesizeReviews([
makeReview('CEO', 'approve', 0.8),
makeReview('CTO', 'approve', 0.9),
]);
expect(result.verdict).toBe('approve');
expect(result.confidence).toBe(0.85);
expect(result.persona).toBe('Board Synthesis');
});
it('returns reject when any reject', () => {
const result = synthesizeReviews([
makeReview('CEO', 'approve', 0.8),
makeReview('CTO', 'reject', 0.7),
]);
expect(result.verdict).toBe('reject');
});
it('returns conditional when any conditional (no reject)', () => {
const result = synthesizeReviews([
makeReview('CEO', 'approve', 0.8),
makeReview('CTO', 'conditional', 0.6),
]);
expect(result.verdict).toBe('conditional');
});
it('merges and deduplicates concerns', () => {
const reviews = [makeReview('CEO', 'approve', 0.8), makeReview('CTO', 'approve', 0.9)];
const result = synthesizeReviews(reviews);
expect(result.concerns).toEqual(['CEO concern', 'CTO concern']);
expect(result.recommendations).toEqual(['CEO rec', 'CTO rec']);
});
it('deduplicates identical items', () => {
const r1: PersonaReview = {
persona: 'CEO',
verdict: 'approve',
confidence: 0.8,
concerns: ['shared concern'],
recommendations: [],
keyRisks: [],
};
const r2: PersonaReview = {
persona: 'CTO',
verdict: 'approve',
confidence: 0.8,
concerns: ['shared concern'],
recommendations: [],
keyRisks: [],
};
const result = synthesizeReviews([r1, r2]);
expect(result.concerns).toEqual(['shared concern']);
});
it('includes original reviews', () => {
const reviews = [makeReview('CEO', 'approve', 0.8)];
const result = synthesizeReviews(reviews);
expect(result.reviews).toEqual(reviews);
});
it('handles empty reviews', () => {
const result = synthesizeReviews([]);
expect(result.verdict).toBe('approve');
expect(result.confidence).toBe(0);
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest';
import {
classifyBrief,
parseBriefFrontmatter,
determineBriefClass,
stagesForClass,
} from '../src/brief-classifier.js';
describe('classifyBrief', () => {
it('returns strategic when strategic keywords dominate', () => {
expect(classifyBrief('We need a new security architecture for compliance')).toBe('strategic');
});
it('returns technical when technical keywords are present and dominate', () => {
expect(classifyBrief('Fix the bugfix for CSS lint cleanup')).toBe('technical');
});
it('returns strategic when no keywords match (default)', () => {
expect(classifyBrief('Implement a new notification system')).toBe('strategic');
});
it('returns strategic when strategic and technical are tied', () => {
// 1 strategic (security) + 1 technical (bug) = strategic wins on > check
expect(classifyBrief('security bug')).toBe('technical');
});
it('returns strategic for empty text', () => {
expect(classifyBrief('')).toBe('strategic');
});
it('is case-insensitive', () => {
expect(classifyBrief('MIGRATION and COMPLIANCE strategy')).toBe('strategic');
});
});
describe('parseBriefFrontmatter', () => {
it('parses simple key-value frontmatter', () => {
const text = '---\nclass: technical\ntitle: My Brief\n---\n\n# Body';
const fm = parseBriefFrontmatter(text);
expect(fm).toEqual({ class: 'technical', title: 'My Brief' });
});
it('strips quotes from values', () => {
const text = '---\nclass: "hotfix"\ntitle: \'Test\'\n---\n\n# Body';
const fm = parseBriefFrontmatter(text);
expect(fm['class']).toBe('hotfix');
expect(fm['title']).toBe('Test');
});
it('returns empty object when no frontmatter', () => {
expect(parseBriefFrontmatter('# Just a heading')).toEqual({});
});
it('returns empty object for malformed frontmatter', () => {
expect(parseBriefFrontmatter('---\n---\n')).toEqual({});
});
});
describe('determineBriefClass', () => {
it('CLI flag takes priority', () => {
const result = determineBriefClass('security migration', 'hotfix');
expect(result).toEqual({ briefClass: 'hotfix', classSource: 'cli' });
});
it('frontmatter takes priority over auto', () => {
const text = '---\nclass: technical\n---\n\nSecurity architecture compliance';
const result = determineBriefClass(text);
expect(result).toEqual({ briefClass: 'technical', classSource: 'frontmatter' });
});
it('falls back to auto-classify', () => {
const result = determineBriefClass('We need a migration plan');
expect(result).toEqual({ briefClass: 'strategic', classSource: 'auto' });
});
it('ignores invalid CLI class', () => {
const result = determineBriefClass('bugfix cleanup', 'invalid');
expect(result).toEqual({ briefClass: 'technical', classSource: 'auto' });
});
it('ignores invalid frontmatter class', () => {
const text = '---\nclass: banana\n---\n\nbugfix';
const result = determineBriefClass(text);
expect(result).toEqual({ briefClass: 'technical', classSource: 'auto' });
});
});
describe('stagesForClass', () => {
it('strategic includes all stages including board', () => {
const stages = stagesForClass('strategic');
expect(stages).toContain('01-board');
expect(stages).toContain('01b-brief-analyzer');
expect(stages).toContain('00-intake');
expect(stages).toContain('09-deploy');
});
it('technical skips board', () => {
const stages = stagesForClass('technical');
expect(stages).not.toContain('01-board');
expect(stages).toContain('01b-brief-analyzer');
});
it('hotfix skips board and brief analyzer', () => {
const stages = stagesForClass('hotfix');
expect(stages).not.toContain('01-board');
expect(stages).not.toContain('01b-brief-analyzer');
expect(stages).toContain('05-coding');
});
it('forceBoard adds board back for technical', () => {
const stages = stagesForClass('technical', true);
expect(stages).toContain('01-board');
expect(stages).toContain('01b-brief-analyzer');
});
it('forceBoard adds board back for hotfix', () => {
const stages = stagesForClass('hotfix', true);
expect(stages).toContain('01-board');
expect(stages).toContain('01b-brief-analyzer');
});
it('stages are in canonical order', () => {
const stages = stagesForClass('strategic');
for (let i = 1; i < stages.length; i++) {
const prevIdx = stages.indexOf(stages[i - 1]!);
const currIdx = stages.indexOf(stages[i]!);
expect(prevIdx).toBeLessThan(currIdx);
}
});
});

View File

@@ -0,0 +1,196 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
slugify,
personaNameFromMarkdown,
loadBoardPersonas,
loadPersonaOverrides,
loadForgeConfig,
getEffectivePersonas,
} from '../src/persona-loader.js';
describe('slugify', () => {
it('converts to lowercase and replaces non-alphanumeric with hyphens', () => {
expect(slugify('Chief Executive Officer')).toBe('chief-executive-officer');
});
it('strips leading and trailing hyphens', () => {
expect(slugify('--hello--')).toBe('hello');
});
it('returns "persona" for empty string', () => {
expect(slugify('')).toBe('persona');
});
it('handles special characters', () => {
expect(slugify('CTO — Technical')).toBe('cto-technical');
});
});
describe('personaNameFromMarkdown', () => {
it('extracts name from heading', () => {
expect(personaNameFromMarkdown('# CEO — Chief Executive Officer', 'FALLBACK')).toBe('CEO');
});
it('strips markdown heading markers', () => {
expect(personaNameFromMarkdown('## CTO - Technical Lead', 'FALLBACK')).toBe('CTO');
});
it('returns fallback for empty content', () => {
expect(personaNameFromMarkdown('', 'FALLBACK')).toBe('FALLBACK');
});
it('returns full heading if no separator', () => {
expect(personaNameFromMarkdown('# SimpleTitle', 'FALLBACK')).toBe('SimpleTitle');
});
});
describe('loadBoardPersonas', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-personas-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns empty array for non-existent directory', () => {
expect(loadBoardPersonas('/nonexistent')).toEqual([]);
});
it('loads personas from markdown files', () => {
fs.writeFileSync(
path.join(tmpDir, 'ceo.md'),
'# CEO — Visionary Leader\n\nThe CEO sets direction.',
);
fs.writeFileSync(
path.join(tmpDir, 'cto.md'),
'# CTO — Technical Realist\n\nThe CTO evaluates feasibility.',
);
const personas = loadBoardPersonas(tmpDir);
expect(personas).toHaveLength(2);
expect(personas[0]!.name).toBe('CEO');
expect(personas[0]!.slug).toBe('ceo');
expect(personas[1]!.name).toBe('CTO');
});
it('sorts by filename', () => {
fs.writeFileSync(path.join(tmpDir, 'z-last.md'), '# Z Last');
fs.writeFileSync(path.join(tmpDir, 'a-first.md'), '# A First');
const personas = loadBoardPersonas(tmpDir);
expect(personas[0]!.slug).toBe('a-first');
expect(personas[1]!.slug).toBe('z-last');
});
it('ignores non-markdown files', () => {
fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'not a persona');
fs.writeFileSync(path.join(tmpDir, 'ceo.md'), '# CEO');
const personas = loadBoardPersonas(tmpDir);
expect(personas).toHaveLength(1);
});
});
describe('loadPersonaOverrides', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-overrides-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns empty object when .forge/personas/ does not exist', () => {
expect(loadPersonaOverrides(tmpDir)).toEqual({});
});
it('loads override files', () => {
const overridesDir = path.join(tmpDir, '.forge', 'personas');
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(path.join(overridesDir, 'ceo.md'), 'Additional CEO context');
const overrides = loadPersonaOverrides(tmpDir);
expect(overrides['ceo']).toBe('Additional CEO context');
});
});
describe('loadForgeConfig', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-config-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns empty config when file does not exist', () => {
expect(loadForgeConfig(tmpDir)).toEqual({});
});
it('parses board skipMembers', () => {
const configDir = path.join(tmpDir, '.forge');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'config.yaml'),
'board:\n skipMembers:\n - cfo\n - coo\n',
);
const config = loadForgeConfig(tmpDir);
expect(config.board?.skipMembers).toEqual(['cfo', 'coo']);
});
});
describe('getEffectivePersonas', () => {
let tmpDir: string;
let boardDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-effective-'));
boardDir = path.join(tmpDir, 'board-agents');
fs.mkdirSync(boardDir, { recursive: true });
fs.writeFileSync(path.join(boardDir, 'ceo.md'), '# CEO — Visionary');
fs.writeFileSync(path.join(boardDir, 'cto.md'), '# CTO — Technical');
fs.writeFileSync(path.join(boardDir, 'cfo.md'), '# CFO — Financial');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns all personas with no overrides or config', () => {
const personas = getEffectivePersonas(tmpDir, boardDir);
expect(personas).toHaveLength(3);
});
it('appends project overrides to base description', () => {
const overridesDir = path.join(tmpDir, '.forge', 'personas');
fs.mkdirSync(overridesDir, { recursive: true });
fs.writeFileSync(path.join(overridesDir, 'ceo.md'), 'Focus on AI strategy');
const personas = getEffectivePersonas(tmpDir, boardDir);
const ceo = personas.find((p) => p.slug === 'ceo')!;
expect(ceo.description).toContain('# CEO — Visionary');
expect(ceo.description).toContain('Focus on AI strategy');
});
it('removes skipped members via config', () => {
const configDir = path.join(tmpDir, '.forge');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.yaml'), 'board:\n skipMembers:\n - cfo\n');
const personas = getEffectivePersonas(tmpDir, boardDir);
expect(personas).toHaveLength(2);
expect(personas.find((p) => p.slug === 'cfo')).toBeUndefined();
});
});

View File

@@ -0,0 +1,331 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
generateRunId,
selectStages,
saveManifest,
loadManifest,
runPipeline,
resumePipeline,
getPipelineStatus,
} from '../src/pipeline-runner.js';
import type { ForgeTask, RunManifest, TaskExecutor } from '../src/types.js';
import type { TaskResult } from '@mosaic/macp';
/** Mock TaskExecutor that records submitted tasks and returns success. */
function createMockExecutor(options?: {
failStage?: string;
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
const submittedTasks: ForgeTask[] = [];
return {
submittedTasks,
async submitTask(task: ForgeTask) {
submittedTasks.push(task);
},
async waitForCompletion(taskId: string): Promise<TaskResult> {
const failStage = options?.failStage;
const task = submittedTasks.find((t) => t.id === taskId);
const stageName = task?.metadata?.['stageName'] as string | undefined;
if (failStage && stageName === failStage) {
return {
task_id: taskId,
status: 'failed',
completed_at: new Date().toISOString(),
exit_code: 1,
gate_results: [],
};
}
return {
task_id: taskId,
status: 'completed',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
};
},
async getTaskStatus() {
return 'completed' as const;
},
};
}
describe('generateRunId', () => {
it('returns a timestamp string', () => {
const id = generateRunId();
expect(id).toMatch(/^\d{8}-\d{6}$/);
});
it('returns unique IDs', () => {
const ids = new Set(Array.from({ length: 10 }, generateRunId));
// Given they run in the same second, they should at least be consistent format
expect(ids.size).toBeGreaterThanOrEqual(1);
});
});
describe('selectStages', () => {
it('returns full sequence when no args', () => {
const stages = selectStages();
expect(stages.length).toBeGreaterThan(0);
expect(stages[0]).toBe('00-intake');
});
it('returns provided stages', () => {
const stages = selectStages(['00-intake', '05-coding']);
expect(stages).toEqual(['00-intake', '05-coding']);
});
it('throws for unknown stages', () => {
expect(() => selectStages(['unknown'])).toThrow('Unknown Forge stages');
});
it('skips to specified stage', () => {
const stages = selectStages(undefined, '05-coding');
expect(stages[0]).toBe('05-coding');
expect(stages).not.toContain('00-intake');
});
it('throws if skipTo not in selected stages', () => {
expect(() => selectStages(['00-intake'], '05-coding')).toThrow(
"skip_to stage '05-coding' is not present",
);
});
});
describe('manifest operations', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-manifest-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('saveManifest and loadManifest roundtrip', () => {
const manifest: RunManifest = {
runId: 'test-123',
brief: '/path/to/brief.md',
codebase: '/project',
briefClass: 'strategic',
classSource: 'auto',
forceBoard: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed', startedAt: '2026-01-01T00:00:00Z' },
},
};
saveManifest(tmpDir, manifest);
const loaded = loadManifest(tmpDir);
expect(loaded.runId).toBe('test-123');
expect(loaded.briefClass).toBe('strategic');
expect(loaded.stages['00-intake']?.status).toBe('passed');
});
it('loadManifest throws for missing file', () => {
expect(() => loadManifest('/nonexistent')).toThrow('manifest.json not found');
});
});
describe('runPipeline', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-pipeline-'));
briefPath = path.join(tmpDir, 'test-brief.md');
fs.writeFileSync(
briefPath,
'---\nclass: hotfix\n---\n\n# Fix CSS bug\n\nFix the bugfix for lint cleanup.',
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('runs pipeline to completion with mock executor', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
});
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
expect(result.stages).toEqual(['00-intake', '00b-discovery']);
expect(result.manifest.status).toBe('completed');
expect(executor.submittedTasks).toHaveLength(2);
});
it('creates run directory under .forge/runs/', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
expect(result.runDir).toContain(path.join('.forge', 'runs'));
expect(fs.existsSync(result.runDir)).toBe(true);
});
it('writes manifest with stage statuses', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
});
const manifest = loadManifest(result.runDir);
expect(manifest.stages['00-intake']?.status).toBe('passed');
expect(manifest.stages['00b-discovery']?.status).toBe('passed');
});
it('respects CLI class override', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
briefClass: 'strategic',
stages: ['00-intake'],
});
expect(result.manifest.briefClass).toBe('strategic');
expect(result.manifest.classSource).toBe('cli');
});
it('uses frontmatter class', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
expect(result.manifest.briefClass).toBe('hotfix');
expect(result.manifest.classSource).toBe('frontmatter');
});
it('builds dependency chain between tasks', async () => {
const executor = createMockExecutor();
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery', '02-planning-1'],
});
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
expect(executor.submittedTasks[1]!.dependsOn).toEqual([executor.submittedTasks[0]!.id]);
expect(executor.submittedTasks[2]!.dependsOn).toEqual([executor.submittedTasks[1]!.id]);
});
it('handles stage failure', async () => {
const executor = createMockExecutor({ failStage: '00b-discovery' });
await expect(
runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
}),
).rejects.toThrow('Stage 00b-discovery failed');
});
it('marks manifest as failed on stage failure', async () => {
const executor = createMockExecutor({ failStage: '00-intake' });
try {
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
} catch {
// expected
}
// Find the run dir (we don't have it from the failed result)
const runsDir = path.join(tmpDir, '.forge', 'runs');
const runDirs = fs.readdirSync(runsDir);
expect(runDirs).toHaveLength(1);
const manifest = loadManifest(path.join(runsDir, runDirs[0]!));
expect(manifest.status).toBe('failed');
expect(manifest.stages['00-intake']?.status).toBe('failed');
});
});
describe('resumePipeline', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-resume-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '---\nclass: hotfix\n---\n\n# Fix bug');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('resumes from first incomplete stage', async () => {
// First run fails on discovery
const executor1 = createMockExecutor({ failStage: '00b-discovery' });
let runDir: string;
try {
await runPipeline(briefPath, tmpDir, {
executor: executor1,
stages: ['00-intake', '00b-discovery', '02-planning-1'],
});
} catch {
// expected
}
const runsDir = path.join(tmpDir, '.forge', 'runs');
runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!);
// Resume should pick up from 00b-discovery
const executor2 = createMockExecutor();
const result = await resumePipeline(runDir, executor2);
expect(result.manifest.status).toBe('completed');
// Should have re-run from 00b-discovery onward
expect(result.stages[0]).toBe('00b-discovery');
});
});
describe('getPipelineStatus', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-status-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns manifest', () => {
const manifest: RunManifest = {
runId: 'test',
brief: '/brief.md',
codebase: '',
briefClass: 'strategic',
classSource: 'auto',
forceBoard: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {},
};
saveManifest(tmpDir, manifest);
const status = getPipelineStatus(tmpDir);
expect(status.runId).toBe('test');
expect(status.status).toBe('in_progress');
});
});

View File

@@ -0,0 +1,172 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
stageTaskId,
stageDir,
stageBriefPath,
stageResultPath,
buildStageBrief,
mapStageToTask,
} from '../src/stage-adapter.js';
import { STAGE_SEQUENCE, STAGE_SPECS } from '../src/constants.js';
describe('stageTaskId', () => {
it('generates correct task ID', () => {
expect(stageTaskId('20260330-120000', '00-intake')).toBe('FORGE-20260330-120000-00');
expect(stageTaskId('20260330-120000', '05-coding')).toBe('FORGE-20260330-120000-05');
});
it('throws for unknown stage', () => {
expect(() => stageTaskId('run1', 'unknown-stage')).toThrow('Unknown Forge stage');
});
});
describe('stageDir', () => {
it('returns correct directory path', () => {
expect(stageDir('/runs/abc', '00-intake')).toBe('/runs/abc/00-intake');
});
});
describe('stageBriefPath', () => {
it('returns brief.md inside stage directory', () => {
expect(stageBriefPath('/runs/abc', '00-intake')).toBe('/runs/abc/00-intake/brief.md');
});
});
describe('stageResultPath', () => {
it('returns result.json inside stage directory', () => {
expect(stageResultPath('/runs/abc', '05-coding')).toBe('/runs/abc/05-coding/result.json');
});
});
describe('buildStageBrief', () => {
it('includes all sections', () => {
const brief = buildStageBrief({
stageName: '00-intake',
stagePrompt: 'Parse the brief into structured data.',
briefContent: '# My Brief\n\nImplement feature X.',
projectRoot: '/project',
runId: 'abc',
runDir: '/runs/abc',
});
expect(brief).toContain('# Forge Pipeline Stage: 00-intake');
expect(brief).toContain('Run ID: abc');
expect(brief).toContain('Project Root: /project');
expect(brief).toContain('# My Brief');
expect(brief).toContain('Implement feature X.');
expect(brief).toContain('Parse the brief into structured data.');
expect(brief).toContain('/runs/abc/');
});
});
describe('mapStageToTask', () => {
let tmpDir: string;
let runDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-stage-adapter-'));
runDir = path.join(tmpDir, 'runs', 'test-run');
fs.mkdirSync(runDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('maps intake stage correctly', () => {
const task = mapStageToTask({
stageName: '00-intake',
briefContent: '# Test Brief',
projectRoot: tmpDir,
runId: 'test-run',
runDir,
});
expect(task.id).toBe('FORGE-test-run-00');
expect(task.title).toBe('Forge Intake');
expect(task.status).toBe('pending');
expect(task.dispatch).toBe('exec');
expect(task.type).toBe('research');
expect(task.timeoutSeconds).toBe(120);
expect(task.qualityGates).toEqual([]);
expect(task.dependsOn).toBeUndefined(); // First stage has no deps
expect(task.worktree).toBe(path.resolve(tmpDir));
});
it('writes brief to disk', () => {
mapStageToTask({
stageName: '00-intake',
briefContent: '# Test Brief',
projectRoot: tmpDir,
runId: 'test-run',
runDir,
});
const briefPath = path.join(runDir, '00-intake', 'brief.md');
expect(fs.existsSync(briefPath)).toBe(true);
const content = fs.readFileSync(briefPath, 'utf-8');
expect(content).toContain('# Test Brief');
});
it('sets depends_on for non-first stages', () => {
const task = mapStageToTask({
stageName: '00b-discovery',
briefContent: '# Test',
projectRoot: tmpDir,
runId: 'test-run',
runDir,
});
expect(task.dependsOn).toEqual(['FORGE-test-run-00']);
});
it('includes metadata with stage info', () => {
const task = mapStageToTask({
stageName: '05-coding',
briefContent: '# Test',
projectRoot: tmpDir,
runId: 'test-run',
runDir,
});
expect(task.metadata['stageName']).toBe('05-coding');
expect(task.metadata['stageNumber']).toBe('05');
expect(task.metadata['gate']).toBe('lint-build-test');
expect(task.metadata['runId']).toBe('test-run');
});
it('yolo dispatch does not set worktree', () => {
const task = mapStageToTask({
stageName: '05-coding',
briefContent: '# Test',
projectRoot: tmpDir,
runId: 'test-run',
runDir,
});
expect(task.dispatch).toBe('yolo');
expect(task.worktree).toBeUndefined();
});
it('throws for unknown stage', () => {
expect(() =>
mapStageToTask({
stageName: 'unknown',
briefContent: 'test',
projectRoot: tmpDir,
runId: 'r1',
runDir,
}),
).toThrow('Unknown Forge stage');
});
it('all stages in STAGE_SEQUENCE have specs', () => {
for (const stage of STAGE_SEQUENCE) {
expect(STAGE_SPECS[stage]).toBeDefined();
}
});
});