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:
199
packages/forge/__tests__/board-tasks.test.ts
Normal file
199
packages/forge/__tests__/board-tasks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
131
packages/forge/__tests__/brief-classifier.test.ts
Normal file
131
packages/forge/__tests__/brief-classifier.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
196
packages/forge/__tests__/persona-loader.test.ts
Normal file
196
packages/forge/__tests__/persona-loader.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
331
packages/forge/__tests__/pipeline-runner.test.ts
Normal file
331
packages/forge/__tests__/pipeline-runner.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
172
packages/forge/__tests__/stage-adapter.test.ts
Normal file
172
packages/forge/__tests__/stage-adapter.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user