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.
154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
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<string, string> {
|
|
const overridesDir = path.join(projectRoot, '.forge', 'personas');
|
|
if (!fs.existsSync(overridesDir)) return {};
|
|
|
|
const result: Record<string, string> = {};
|
|
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;
|
|
}
|