Files
stack/packages/forge/src/persona-loader.ts
Mos (Agent) 10689a30d2
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
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.
2026-03-30 19:43:24 +00:00

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