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:
153
packages/forge/src/persona-loader.ts
Normal file
153
packages/forge/src/persona-loader.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user