import fs from 'node:fs'; import path from 'node:path'; import { PIPELINE_DIR } from './constants.js'; import type { BoardPersona, ForgeConfig } from './types.js'; /** Board agents directory within the pipeline assets. */ const BOARD_AGENTS_DIR = path.join(PIPELINE_DIR, 'agents', 'board'); /** * Convert a string to a URL-safe slug. */ export function slugify(value: string): string { const slug = value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return slug || 'persona'; } /** * Extract persona name from the first heading line in markdown. * Strips trailing em-dash or hyphen-separated subtitle. */ export function personaNameFromMarkdown(markdown: string, fallback: string): string { const firstLine = markdown.trim().split('\n')[0] ?? fallback; let heading = firstLine.replace(/^#+\s*/, '').trim(); if (heading.includes('—')) { heading = heading.split('—')[0]!.trim(); } else if (heading.includes('-')) { heading = heading.split('-')[0]!.trim(); } return heading || fallback; } /** * Load board personas from the pipeline assets directory. * Returns sorted list of persona definitions. */ export function loadBoardPersonas(boardDir: string = BOARD_AGENTS_DIR): BoardPersona[] { if (!fs.existsSync(boardDir)) return []; const files = fs .readdirSync(boardDir) .filter((f) => f.endsWith('.md')) .sort(); return files.map((file) => { const filePath = path.join(boardDir, file); const content = fs.readFileSync(filePath, 'utf-8').trim(); const stem = path.basename(file, '.md'); return { name: personaNameFromMarkdown(content, stem.toUpperCase()), slug: slugify(stem), description: content, path: path.relative(PIPELINE_DIR, filePath), }; }); } /** * Load project-level persona overrides from {projectRoot}/.forge/personas/. * Returns a map of slug → override content. */ export function loadPersonaOverrides(projectRoot: string): Record { const overridesDir = path.join(projectRoot, '.forge', 'personas'); if (!fs.existsSync(overridesDir)) return {}; const result: Record = {}; const files = fs.readdirSync(overridesDir).filter((f) => f.endsWith('.md')); for (const file of files) { const slug = slugify(path.basename(file, '.md')); result[slug] = fs.readFileSync(path.join(overridesDir, file), 'utf-8').trim(); } return result; } /** * Load project-level Forge config from {projectRoot}/.forge/config.yaml. * Parses simple YAML key-value pairs via regex (no YAML dependency). */ export function loadForgeConfig(projectRoot: string): ForgeConfig { const configPath = path.join(projectRoot, '.forge', 'config.yaml'); if (!fs.existsSync(configPath)) return {}; const text = fs.readFileSync(configPath, 'utf-8'); const config: ForgeConfig = {}; // Parse simple list values under board: and specialists: sections const boardAdditional = parseYamlList(text, 'additionalMembers'); const boardSkip = parseYamlList(text, 'skipMembers'); const specialistsInclude = parseYamlList(text, 'alwaysInclude'); if (boardAdditional.length > 0 || boardSkip.length > 0) { config.board = {}; if (boardAdditional.length > 0) config.board.additionalMembers = boardAdditional; if (boardSkip.length > 0) config.board.skipMembers = boardSkip; } if (specialistsInclude.length > 0) { config.specialists = { alwaysInclude: specialistsInclude }; } return config; } /** * Parse a simple YAML list under a given key name. */ function parseYamlList(text: string, key: string): string[] { const pattern = new RegExp(`${key}:\\s*\\n((?:\\s+-\\s+.+\\n?)*)`, 'm'); const match = text.match(pattern); if (!match?.[1]) return []; return match[1] .split('\n') .map((line) => line.trim().replace(/^-\s+/, '').trim()) .filter(Boolean); } /** * Get effective board personas after applying project overrides and config. * * - Base personas loaded from pipeline/agents/board/ * - Project overrides from {projectRoot}/.forge/personas/ APPENDED to base * - Config skipMembers removes personas; additionalMembers adds custom paths */ export function getEffectivePersonas(projectRoot: string, boardDir?: string): BoardPersona[] { let personas = loadBoardPersonas(boardDir); const overrides = loadPersonaOverrides(projectRoot); const config = loadForgeConfig(projectRoot); // Apply overrides — append project content to base persona description personas = personas.map((p) => { const override = overrides[p.slug]; if (override) { return { ...p, description: `${p.description}\n\n${override}` }; } return p; }); // Apply config: skip members if (config.board?.skipMembers?.length) { const skip = new Set(config.board.skipMembers.map((s) => slugify(s))); personas = personas.filter((p) => !skip.has(p.slug)); } return personas; }