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); }); });