import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { defaultPiStateDirectory } from "./workspace-paths.js"; export const BUILTIN_WORKER_ROLES = [ "scout", "worker", "reviewer", "oracle", "researcher", ] as const; export type WorkerDifficulty = "easy" | "medium" | "hard"; export type WorkerRolePresetSource = "builtin" | "user" | "project"; export interface WorkerRolePreset { readonly name: string; readonly tier: string; readonly difficulty: WorkerDifficulty; readonly capabilities: readonly string[]; readonly charter: string; readonly source: WorkerRolePresetSource; readonly fingerprint: string; } export interface WorkerRoleContract { readonly name: string; readonly tier: string; readonly charter: string; readonly presetSource: WorkerRolePresetSource; readonly presetFingerprint: string; } export interface WorkerRolePresetCatalogOptions { readonly projectDirectory: string; readonly projectTrusted: boolean; readonly builtinDirectory?: string; readonly userDirectory?: string; readonly projectRolesDirectory?: string; } export class WorkerRolePresetCatalog { readonly #presets: ReadonlyMap; private constructor(presets: ReadonlyMap) { this.#presets = presets; } static async load(options: WorkerRolePresetCatalogOptions): Promise { const builtinDirectory = options.builtinDirectory ?? defaultBuiltinRoleDirectory(); const userDirectory = options.userDirectory ?? join(defaultPiStateDirectory(), "pi-amq", "roles"); const projectDirectory = options.projectRolesDirectory ?? join(options.projectDirectory, ".pi", "pi-amq", "roles"); const layered = new Map(); installLayer(layered, await readRoleDirectory(builtinDirectory, "builtin", true)); for (const required of BUILTIN_WORKER_ROLES) { if (!layered.has(required)) { throw new Error(`Installed role preset directory is missing ${required}.md`); } } installLayer(layered, await readRoleDirectory(userDirectory, "user", false)); if (options.projectTrusted) { installLayer(layered, await readRoleDirectory(projectDirectory, "project", false)); } return new WorkerRolePresetCatalog(layered); } resolve(requestedRole: string | undefined): WorkerRolePreset { const normalized = normalizeRoleName(requestedRole ?? "worker"); const preset = this.#presets.get(normalized); if (!preset) { throw new Error( `Unknown worker role ${JSON.stringify(requestedRole)}; available roles: ${this.names().join(", ")}`, ); } return preset; } names(): readonly string[] { return [...this.#presets.keys()].sort(); } presets(): readonly WorkerRolePreset[] { return this.names().map((name) => this.#presets.get(name)!); } } export function parseWorkerRolePreset( markdown: string, source: WorkerRolePresetSource, sourceName: string, ): WorkerRolePreset { const normalized = markdown.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n"); const lines = normalized.split("\n"); if (lines[0] !== "---") throw new TypeError(`${sourceName}: role preset must start with ---`); const closing = lines.indexOf("---", 1); if (closing < 0) throw new TypeError(`${sourceName}: role preset frontmatter is not closed`); const frontmatter = lines.slice(1, closing); const charter = lines.slice(closing + 1).join("\n").trim(); if (charter.length === 0) throw new TypeError(`${sourceName}: role charter must not be empty`); const values = new Map(); let activeList: string | undefined; for (let index = 0; index < frontmatter.length; index += 1) { const raw = frontmatter[index]!; if (raw.trim().length === 0) continue; const listItem = raw.match(/^\s+-\s+(.+?)\s*$/); if (listItem) { if (!activeList) throw new TypeError(`${sourceName}:${index + 2}: list item has no field`); const current = values.get(activeList); if (!Array.isArray(current)) throw new TypeError(`${sourceName}:${index + 2}: invalid list`); current.push(unquote(listItem[1]!)); continue; } const scalar = raw.match(/^([a-z_]+):(?:\s*(.*))?$/); if (!scalar) throw new TypeError(`${sourceName}:${index + 2}: unsupported frontmatter syntax`); const key = scalar[1]!; if (!ROLE_FIELDS.has(key)) throw new TypeError(`${sourceName}: unknown role field ${key}`); if (values.has(key)) throw new TypeError(`${sourceName}: duplicate role field ${key}`); const value = scalar[2]?.trim() ?? ""; if (key === "capabilities") { if (value.length > 0) throw new TypeError(`${sourceName}: capabilities must be a YAML list`); values.set(key, []); activeList = key; } else { if (value.length === 0) throw new TypeError(`${sourceName}: ${key} must not be empty`); values.set(key, unquote(value)); activeList = undefined; } } for (const field of ROLE_FIELDS) { if (!values.has(field)) throw new TypeError(`${sourceName}: missing role field ${field}`); } const name = requiredScalar(values, "name", sourceName); if (!/^[a-z][a-z0-9-]*$/.test(name)) { throw new TypeError(`${sourceName}: role name must match [a-z][a-z0-9-]*`); } const tier = requiredScalar(values, "tier", sourceName); const difficulty = requiredScalar(values, "difficulty", sourceName); if (difficulty !== "easy" && difficulty !== "medium" && difficulty !== "hard") { throw new TypeError(`${sourceName}: difficulty must be easy, medium, or hard`); } const capabilities = values.get("capabilities"); if (!Array.isArray(capabilities) || capabilities.length === 0) { throw new TypeError(`${sourceName}: capabilities must contain at least one grant`); } const uniqueCapabilities = [...new Set(capabilities.map((value) => requireText(value, sourceName)))]; return { name, tier, difficulty, capabilities: uniqueCapabilities, charter, source, fingerprint: createHash("sha256").update(normalized).digest("hex"), }; } export function workerRoleContract(preset: WorkerRolePreset): WorkerRoleContract { return { name: preset.name, tier: preset.tier, charter: preset.charter, presetSource: preset.source, presetFingerprint: preset.fingerprint, }; } export function normalizeRoleName(role: string): string { const normalized = role.trim().toLowerCase(); if (normalized === "implementer") return "worker"; if (normalized === "auditor") return "reviewer"; return normalized; } async function readRoleDirectory( directory: string, source: WorkerRolePresetSource, required: boolean, ): Promise { let entries: string[]; try { entries = await readdir(directory); } catch (error) { if (!required && isMissing(error)) return []; throw error; } const presets: WorkerRolePreset[] = []; const names = new Set(); for (const entry of entries.filter((name) => name.endsWith(".md")).sort()) { const path = join(directory, entry); const preset = parseWorkerRolePreset(await readFile(path, "utf8"), source, path); if (names.has(preset.name)) throw new TypeError(`${directory}: duplicate role ${preset.name}`); names.add(preset.name); presets.push(preset); } return presets; } function installLayer( target: Map, presets: readonly WorkerRolePreset[], ): void { for (const preset of presets) target.set(preset.name, preset); } function defaultBuiltinRoleDirectory(): string { const adjacent = fileURLToPath(new URL("../roles/", import.meta.url)); return existsSync(adjacent) ? adjacent : fileURLToPath(new URL("../../roles/", import.meta.url)); } function requiredScalar(values: ReadonlyMap, key: string, source: string): string { const value = values.get(key); if (typeof value !== "string") throw new TypeError(`${source}: ${key} must be a scalar`); return requireText(value, source); } function requireText(value: string, source: string): string { const trimmed = value.trim(); if (trimmed.length === 0) throw new TypeError(`${source}: empty role field value`); return trimmed; } function unquote(value: string): string { const quoted = value.match(/^(?:"([\s\S]*)"|'([\s\S]*)')$/); return quoted ? (quoted[1] ?? quoted[2] ?? "") : value; } function isMissing(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { readonly code?: unknown }).code === "ENOENT"; } const ROLE_FIELDS = new Set(["name", "tier", "difficulty", "capabilities"]);