chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { generateBoardTasks } from '../src/board-tasks.js';
|
||||
import { STAGE_SPECS } from '../src/constants.js';
|
||||
import { ForgeCapabilityError } from '../src/errors.js';
|
||||
import {
|
||||
evaluateStageGates,
|
||||
gateLabel,
|
||||
isCommandGate,
|
||||
isSatisfyingOutcome,
|
||||
} from '../src/outcomes.js';
|
||||
import { loadManifest, runPipeline } from '../src/pipeline-runner.js';
|
||||
import type { ForgeTask, ForgeTaskResult, TaskExecutor } from '../src/types.js';
|
||||
|
||||
/**
|
||||
* Mock real executor that returns typed results.
|
||||
*
|
||||
* Command gates are "verified" by the mock so normal-mode runs can pass
|
||||
* mechanically gated stages; authority/provider gates are never reported
|
||||
* because they have no mechanical implementation.
|
||||
*/
|
||||
function createTypedExecutor(options?: {
|
||||
failStage?: string;
|
||||
gateOutcomes?: Record<string, 'passed' | 'failed' | 'simulated' | 'error' | 'blocked'>;
|
||||
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
|
||||
const submittedTasks: ForgeTask[] = [];
|
||||
return {
|
||||
submittedTasks,
|
||||
async submitTask(task: ForgeTask) {
|
||||
submittedTasks.push(task);
|
||||
},
|
||||
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
|
||||
const task = submittedTasks.find((t) => t.id === taskId);
|
||||
const stageName = task?.metadata?.['stageName'] as string | undefined;
|
||||
|
||||
if (options?.failStage && stageName === options.failStage) {
|
||||
return {
|
||||
task_id: taskId,
|
||||
outcome: 'failed',
|
||||
reason: 'mock task failure',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 1,
|
||||
gate_results: [],
|
||||
};
|
||||
}
|
||||
|
||||
const gateResults = (task?.qualityGates ?? [])
|
||||
.filter((gate) => isCommandGate(gate))
|
||||
.map((gate) => {
|
||||
const label = gateLabel(gate);
|
||||
const outcome = options?.gateOutcomes?.[label] ?? 'passed';
|
||||
return {
|
||||
gate: label,
|
||||
outcome,
|
||||
reason: outcome === 'passed' ? 'mock verified' : `mock gate outcome: ${outcome}`,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
task_id: taskId,
|
||||
outcome: 'passed',
|
||||
reason: 'mock verified',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 0,
|
||||
gate_results: gateResults,
|
||||
};
|
||||
},
|
||||
async getTaskStatus() {
|
||||
return 'completed' as const;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('fail-closed: no executor wired', () => {
|
||||
let tmpDir: string;
|
||||
let briefPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-failclosed-'));
|
||||
briefPath = path.join(tmpDir, 'brief.md');
|
||||
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws a typed FORGE_NO_EXECUTOR capability error without --simulate', async () => {
|
||||
await expect(
|
||||
runPipeline(briefPath, tmpDir, {
|
||||
// no executor, no simulate — must fail closed, never run with a stub
|
||||
stages: ['00-intake'],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'ForgeCapabilityError',
|
||||
code: 'FORGE_NO_EXECUTOR',
|
||||
capability: 'task-executor',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create a run directory when failing closed on a missing executor', async () => {
|
||||
try {
|
||||
await runPipeline(briefPath, tmpDir, { stages: ['00-intake'] });
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false);
|
||||
});
|
||||
|
||||
it('completes with every result typed simulated when simulate is set', async () => {
|
||||
const result = await runPipeline(briefPath, tmpDir, {
|
||||
simulate: true,
|
||||
stages: ['00-intake', '00b-discovery', '02-planning-1', '06-review'],
|
||||
});
|
||||
|
||||
expect(result.manifest.mode).toBe('simulated');
|
||||
expect(result.manifest.status).toBe('simulated');
|
||||
|
||||
for (const stage of result.stages) {
|
||||
const stageStatus = result.manifest.stages[stage];
|
||||
expect(stageStatus?.status, `stage ${stage}`).toBe('simulated');
|
||||
expect(stageStatus?.status, `stage ${stage}`).not.toBe('passed');
|
||||
expect(stageStatus?.reason, `stage ${stage}`).toBeTruthy();
|
||||
for (const gateResult of stageStatus?.gateResults ?? []) {
|
||||
expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).toBe('simulated');
|
||||
expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).not.toBe('passed');
|
||||
}
|
||||
}
|
||||
|
||||
// The persisted manifest agrees.
|
||||
const persisted = loadManifest(result.runDir);
|
||||
expect(persisted.mode).toBe('simulated');
|
||||
expect(persisted.status).toBe('simulated');
|
||||
expect(persisted.stages['02-planning-1']?.status).toBe('simulated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-closed: typed outcome model', () => {
|
||||
it('only passed satisfies the gate/dependency predicate', () => {
|
||||
expect(isSatisfyingOutcome('passed')).toBe(true);
|
||||
expect(isSatisfyingOutcome('failed')).toBe(false);
|
||||
expect(isSatisfyingOutcome('blocked')).toBe(false);
|
||||
expect(isSatisfyingOutcome('error')).toBe(false);
|
||||
expect(isSatisfyingOutcome('waiting-for-authority')).toBe(false);
|
||||
expect(isSatisfyingOutcome('simulated')).toBe(false);
|
||||
expect(isSatisfyingOutcome('not-applicable')).toBe(false);
|
||||
});
|
||||
|
||||
it('a simulated gate result cannot satisfy the stage gate evaluation', () => {
|
||||
const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, {
|
||||
task_id: 'FORGE-x-05',
|
||||
outcome: 'passed',
|
||||
reason: 'executor claims success',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 0,
|
||||
gate_results: [{ gate: 'pnpm lint', outcome: 'simulated', reason: 'simulated gate' }],
|
||||
});
|
||||
expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false);
|
||||
expect(evaluation.outcome).toBe('error');
|
||||
});
|
||||
|
||||
it('a simulated task outcome cannot satisfy evaluation in normal mode', () => {
|
||||
const evaluation = evaluateStageGates('00-intake', [], {
|
||||
task_id: 'FORGE-x-00',
|
||||
outcome: 'simulated',
|
||||
reason: 'executor reported simulated',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 0,
|
||||
gate_results: [],
|
||||
});
|
||||
expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false);
|
||||
});
|
||||
|
||||
it('a missing gate result blocks the stage instead of passing vacuously', () => {
|
||||
const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, {
|
||||
task_id: 'FORGE-x-05',
|
||||
outcome: 'passed',
|
||||
reason: 'executor claims success',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 0,
|
||||
gate_results: [],
|
||||
});
|
||||
expect(evaluation.outcome).toBe('blocked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-closed: authority and provider gates', () => {
|
||||
let tmpDir: string;
|
||||
let briefPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-authority-'));
|
||||
briefPath = path.join(tmpDir, 'brief.md');
|
||||
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(['02-planning-1', '03-planning-2', '04-planning-3', '07-remediate'])(
|
||||
'planning/remediation stage %s yields waiting-for-authority (not passed) in normal mode',
|
||||
async (stage) => {
|
||||
const executor = createTypedExecutor();
|
||||
let runDir: string | undefined;
|
||||
|
||||
try {
|
||||
await runPipeline(briefPath, tmpDir, {
|
||||
executor,
|
||||
stages: [stage as string],
|
||||
});
|
||||
expect.unreachable('runPipeline should have failed closed');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ForgeCapabilityError);
|
||||
expect((err as ForgeCapabilityError).code).toBe('FORGE_AUTHORITY_REQUIRED');
|
||||
runDir = path.join(tmpDir, '.forge', 'runs');
|
||||
}
|
||||
|
||||
const runIds = fs.readdirSync(runDir!);
|
||||
expect(runIds).toHaveLength(1);
|
||||
const manifest = loadManifest(path.join(runDir!, runIds[0]!));
|
||||
expect(manifest.stages[stage]?.status).toBe('waiting-for-authority');
|
||||
expect(manifest.stages[stage]?.status).not.toBe('passed');
|
||||
expect(manifest.status).toBe('waiting-for-authority');
|
||||
},
|
||||
);
|
||||
|
||||
it('review stage fails closed with a typed FORGE_NO_REVIEWER error in normal mode', async () => {
|
||||
const executor = createTypedExecutor();
|
||||
|
||||
try {
|
||||
await runPipeline(briefPath, tmpDir, {
|
||||
executor,
|
||||
stages: ['06-review'],
|
||||
});
|
||||
expect.unreachable('runPipeline should have failed closed');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ForgeCapabilityError);
|
||||
expect((err as ForgeCapabilityError).code).toBe('FORGE_NO_REVIEWER');
|
||||
expect((err as ForgeCapabilityError).capability).toBe('reviewer');
|
||||
}
|
||||
|
||||
const runsDir = path.join(tmpDir, '.forge', 'runs');
|
||||
const runIds = fs.readdirSync(runsDir);
|
||||
const manifest = loadManifest(path.join(runsDir, runIds[0]!));
|
||||
expect(manifest.stages['06-review']?.status).toBe('blocked');
|
||||
expect(manifest.stages['06-review']?.status).not.toBe('passed');
|
||||
expect(manifest.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('review stage produces simulated results under --simulate', async () => {
|
||||
const result = await runPipeline(briefPath, tmpDir, {
|
||||
simulate: true,
|
||||
stages: ['06-review'],
|
||||
});
|
||||
|
||||
expect(result.manifest.mode).toBe('simulated');
|
||||
expect(result.manifest.stages['06-review']?.status).toBe('simulated');
|
||||
for (const gateResult of result.manifest.stages['06-review']?.gateResults ?? []) {
|
||||
expect(gateResult.outcome).toBe('simulated');
|
||||
}
|
||||
});
|
||||
|
||||
it('deploy stage fails closed without a wired ci-pipeline provider in normal mode', async () => {
|
||||
const executor = createTypedExecutor();
|
||||
|
||||
await expect(
|
||||
runPipeline(briefPath, tmpDir, {
|
||||
executor,
|
||||
stages: ['09-deploy'],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'ForgeCapabilityError',
|
||||
code: 'FORGE_NO_CI_PIPELINE',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-closed: no vacuous gate commands remain', () => {
|
||||
it('stage constants contain no echo/synthetic-approval, vacuous true, or empty gate commands', () => {
|
||||
for (const [stageName, spec] of Object.entries(STAGE_SPECS)) {
|
||||
for (const gate of spec.qualityGates) {
|
||||
const serialized = JSON.stringify(gate);
|
||||
// The echo-review synthetic approval must be gone.
|
||||
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toContain('echo');
|
||||
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(/"verdict"\s*:/);
|
||||
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(
|
||||
/"summary"\s*:\s*"review-pass"/,
|
||||
);
|
||||
// No vacuous literal `true` gate.
|
||||
expect(gate, `stage ${stageName}`).not.toBe('true');
|
||||
// Command gates must carry a real, non-empty command.
|
||||
if (isCommandGate(gate)) {
|
||||
const command = typeof gate === 'string' ? gate : gate.command;
|
||||
expect(command.trim().length, `stage ${stageName} gate ${serialized}`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('board tasks contain no vacuous true gates', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-board-gates-'));
|
||||
try {
|
||||
const tasks = generateBoardTasks('# Brief', [], tmpDir, 'BOARD-TEST');
|
||||
for (const task of tasks) {
|
||||
for (const gate of task.qualityGates) {
|
||||
expect(gate, `task ${task.id}`).not.toBe('true');
|
||||
const serialized = JSON.stringify(gate);
|
||||
expect(serialized, `task ${task.id} gate ${serialized}`).not.toContain('echo');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
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, ForgeTaskResult, RunManifest, TaskExecutor } from '../src/types.js';
|
||||
import { gateLabel, isCommandGate } from '../src/outcomes.js';
|
||||
|
||||
/** Mock TaskExecutor that records submitted tasks and returns typed results. */
|
||||
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<ForgeTaskResult> {
|
||||
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,
|
||||
outcome: 'failed',
|
||||
reason: 'mock task failure',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 1,
|
||||
gate_results: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
task_id: taskId,
|
||||
outcome: 'passed',
|
||||
reason: 'mock verified',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 0,
|
||||
gate_results: (task?.qualityGates ?? [])
|
||||
.filter((gate) => isCommandGate(gate))
|
||||
.map((gate) => ({
|
||||
gate: gateLabel(gate),
|
||||
outcome: 'passed' as const,
|
||||
reason: 'mock verified',
|
||||
})),
|
||||
};
|
||||
},
|
||||
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', '05-coding'],
|
||||
});
|
||||
|
||||
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
|
||||
expect(result.stages).toEqual(['00-intake', '05-coding']);
|
||||
expect(result.manifest.status).toBe('completed');
|
||||
expect(result.manifest.mode).toBe('normal');
|
||||
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', '05-coding'],
|
||||
});
|
||||
|
||||
const manifest = loadManifest(result.runDir);
|
||||
expect(manifest.stages['00-intake']?.status).toBe('passed');
|
||||
expect(manifest.stages['05-coding']?.status).toBe('passed');
|
||||
expect(manifest.stages['05-coding']?.gateResults?.map((g) => g.outcome)).toEqual([
|
||||
'passed',
|
||||
'passed',
|
||||
'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', '05-coding', '08-test'],
|
||||
});
|
||||
|
||||
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: '05-coding' });
|
||||
|
||||
await expect(
|
||||
runPipeline(briefPath, tmpDir, {
|
||||
executor,
|
||||
stages: ['00-intake', '05-coding'],
|
||||
}),
|
||||
).rejects.toThrow('Stage 05-coding 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 and fails closed at the next provider gate', async () => {
|
||||
// Simulate a run whose authority stages were approved out-of-band
|
||||
// (recorded as passed) and whose coding stage failed mechanically.
|
||||
const runId = '20260101-000000';
|
||||
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
|
||||
saveManifest(runDir, {
|
||||
runId,
|
||||
brief: briefPath,
|
||||
codebase: tmpDir,
|
||||
briefClass: 'hotfix',
|
||||
classSource: 'frontmatter',
|
||||
forceBoard: false,
|
||||
mode: 'normal',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
currentStage: '05-coding',
|
||||
status: 'failed',
|
||||
stages: {
|
||||
'00-intake': passed,
|
||||
'00b-discovery': passed,
|
||||
'02-planning-1': passed,
|
||||
'03-planning-2': passed,
|
||||
'04-planning-3': passed,
|
||||
'05-coding': { status: 'failed', reason: 'gate failed' },
|
||||
},
|
||||
});
|
||||
|
||||
// Resume re-runs 05-coding (the first non-passed stage), then fails
|
||||
// closed at 06-review because no reviewer provider is wired.
|
||||
const executor = createMockExecutor();
|
||||
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
|
||||
name: 'ForgeCapabilityError',
|
||||
code: 'FORGE_NO_REVIEWER',
|
||||
});
|
||||
|
||||
const manifest = loadManifest(runDir);
|
||||
expect(manifest.stages['05-coding']?.status).toBe('passed');
|
||||
expect(manifest.stages['06-review']?.status).toBe('blocked');
|
||||
expect(manifest.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('resumes to completion as simulated under explicit simulate', async () => {
|
||||
const runId = '20260101-000003';
|
||||
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
|
||||
saveManifest(runDir, {
|
||||
runId,
|
||||
brief: briefPath,
|
||||
codebase: tmpDir,
|
||||
briefClass: 'hotfix',
|
||||
classSource: 'frontmatter',
|
||||
forceBoard: false,
|
||||
mode: 'normal',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
currentStage: '05-coding',
|
||||
status: 'failed',
|
||||
stages: {
|
||||
'00-intake': passed,
|
||||
'00b-discovery': passed,
|
||||
'02-planning-1': passed,
|
||||
'03-planning-2': passed,
|
||||
'04-planning-3': passed,
|
||||
'05-coding': { status: 'failed', reason: 'gate failed' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resumePipeline(runDir, undefined, { simulate: true });
|
||||
|
||||
expect(result.manifest.status).toBe('simulated');
|
||||
expect(result.manifest.mode).toBe('simulated');
|
||||
expect(result.stages[0]).toBe('05-coding');
|
||||
for (const stage of result.stages) {
|
||||
expect(result.manifest.stages[stage]?.status).toBe('simulated');
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on resume when the next stage needs authority sign-off', async () => {
|
||||
const runId = '20260101-000001';
|
||||
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
saveManifest(runDir, {
|
||||
runId,
|
||||
brief: briefPath,
|
||||
codebase: tmpDir,
|
||||
briefClass: 'hotfix',
|
||||
classSource: 'frontmatter',
|
||||
forceBoard: false,
|
||||
mode: 'normal',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
currentStage: '00-intake',
|
||||
status: 'in_progress',
|
||||
stages: {
|
||||
'00-intake': { status: 'passed' },
|
||||
},
|
||||
});
|
||||
|
||||
const executor = createMockExecutor();
|
||||
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
|
||||
name: 'ForgeCapabilityError',
|
||||
code: 'FORGE_AUTHORITY_REQUIRED',
|
||||
});
|
||||
|
||||
const manifest = loadManifest(runDir);
|
||||
expect(manifest.stages['00b-discovery']?.status).toBe('waiting-for-authority');
|
||||
expect(manifest.status).toBe('waiting-for-authority');
|
||||
});
|
||||
|
||||
it('fails closed on resume without an executor or --simulate', async () => {
|
||||
const runId = '20260101-000002';
|
||||
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
saveManifest(runDir, {
|
||||
runId,
|
||||
brief: briefPath,
|
||||
codebase: tmpDir,
|
||||
briefClass: 'hotfix',
|
||||
classSource: 'frontmatter',
|
||||
forceBoard: false,
|
||||
mode: 'normal',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
currentStage: '00-intake',
|
||||
status: 'in_progress',
|
||||
stages: {
|
||||
'00-intake': { status: 'passed' },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(resumePipeline(runDir)).rejects.toMatchObject({
|
||||
name: 'ForgeCapabilityError',
|
||||
code: 'FORGE_NO_EXECUTOR',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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