#!/usr/bin/env node // profile-loader.mjs — Load and validate ultraplan-local profile YAML files. // // Profiles describe which agents, skills, and review regime ultraplan-local // should use. They live in two locations: // - Built-in: plugins/ultraplan-local/profiles/*.yaml // - User (M3): .claude/ultraplan-profiles/ and ~/.claude/ultraplan-profiles/ // // M0 ships with built-in discovery only. // // The YAML parser here is intentionally a *limited* subset — enough for the // profile schema, no more. Supports: // - Top-level scalars (string, number, bool, null) // - Nested mappings via 2-space indent // - Block-style lists ('- item') // - Inline lists ('[a, b, c]') // - Quoted strings ("..." or '...') // - Line comments (# at line start, or after whitespace) // NOT supported: anchors/aliases, multi-line strings, flow mappings, // tagged values. If you need those, the schema has drifted — fix the schema. // // Pure Node stdlib. No npm dependencies. // // CLI: // node scripts/profile-loader.mjs list // node scripts/profile-loader.mjs load import { readFile, readdir, stat } from 'node:fs/promises'; import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { argv, exit, stdout, stderr } from 'node:process'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..'); const PROFILES_DIR = join(PLUGIN_ROOT, 'profiles'); const AGENTS_DIR = join(PLUGIN_ROOT, 'agents'); // === Required profile fields (validated by validateProfile) === export const REQUIRED_FIELDS = ['name', 'description', 'version', 'agents']; export const REQUIRED_AGENT_KEYS = ['exploration', 'review']; // ===================================================================== // YAML parser (limited subset) // ===================================================================== /** * Strip a line-trailing comment, respecting quoted strings. * Returns the line with any '# ...' suffix removed (when '#' is preceded by * whitespace or starts the line) and trailing whitespace trimmed. */ export function stripLineComment(line) { let inQuote = null; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuote) { if (ch === '\\') { i++; continue; } if (ch === inQuote) inQuote = null; } else if (ch === '"' || ch === "'") { inQuote = ch; } else if (ch === '#') { const prev = i === 0 ? ' ' : line[i - 1]; if (prev === ' ' || prev === '\t' || i === 0) { return line.slice(0, i).replace(/\s+$/, ''); } } } return line.replace(/\s+$/, ''); } /** * Pre-process YAML text into an array of {indent, content} entries. * Strips comments and blank lines. Preserves indent for structural parsing. */ function preprocessLines(text) { const out = []; for (const raw of text.split('\n')) { const stripped = stripLineComment(raw); if (stripped.trim() === '') continue; const indent = stripped.length - stripped.trimStart().length; out.push({ indent, content: stripped.trim() }); } return out; } /** * Parse a scalar (right-hand side of `key: value` or list item). * Recognises quoted strings, bools, null, numbers, inline lists, otherwise * returns the raw string. */ export function parseScalar(text) { const t = text.trim(); if (t === '') return null; if (t === 'null' || t === '~') return null; if (t === 'true') return true; if (t === 'false') return false; if (t.startsWith('[') && t.endsWith(']')) return parseInlineList(t); if (t.startsWith('"') && t.endsWith('"')) return unescapeString(t.slice(1, -1)); if (t.startsWith("'") && t.endsWith("'")) return t.slice(1, -1); // Number? (integer or simple float, no scientific notation needed) if (/^-?\d+$/.test(t)) return parseInt(t, 10); if (/^-?\d+\.\d+$/.test(t)) return parseFloat(t); return t; } function unescapeString(s) { return s .replace(/\\n/g, '\n') .replace(/\\t/g, '\t') .replace(/\\"/g, '"') .replace(/\\\\/g, '\\'); } /** * Parse an inline list like `[a, b, "c d", 1, 2]`. * Naive split on commas — does not support nested lists/mappings (not needed). */ export function parseInlineList(text) { const inner = text.slice(1, -1).trim(); if (inner === '') return []; const items = []; let depth = 0; let inQuote = null; let buf = ''; for (let i = 0; i < inner.length; i++) { const ch = inner[i]; if (inQuote) { buf += ch; if (ch === '\\') { buf += inner[++i] ?? ''; continue; } if (ch === inQuote) inQuote = null; continue; } if (ch === '"' || ch === "'") { inQuote = ch; buf += ch; continue; } if (ch === '[') depth++; if (ch === ']') depth--; if (ch === ',' && depth === 0) { items.push(parseScalar(buf)); buf = ''; continue; } buf += ch; } if (buf.trim() !== '') items.push(parseScalar(buf)); return items; } /** * Parse a block-style list at the given indent. Returns [items, nextIdx]. * List items are simple scalars only (sufficient for profile schema). */ function parseList(lines, idx, indent) { const items = []; while (idx < lines.length && lines[idx].indent === indent && lines[idx].content.startsWith('- ')) { const itemText = lines[idx].content.slice(2).trim(); items.push(parseScalar(itemText)); idx++; } return [items, idx]; } /** * Parse a mapping at the given indent. Returns [obj, nextIdx]. * A mapping key may be followed by a scalar (same line), a nested mapping, * or a block-list (indented more on subsequent lines). */ function parseMapping(lines, idx, indent) { const result = {}; while (idx < lines.length && lines[idx].indent === indent) { const { content } = lines[idx]; if (content.startsWith('- ')) break; // not a mapping line const colonIdx = findUnquotedColon(content); if (colonIdx === -1) { throw new Error(`Expected 'key: value' at indent ${indent}, got: ${content}`); } const key = content.slice(0, colonIdx).trim(); const rhs = content.slice(colonIdx + 1).trim(); idx++; if (rhs === '') { // Look for nested mapping or list at deeper indent if (idx < lines.length && lines[idx].indent > indent) { const childIndent = lines[idx].indent; if (lines[idx].content.startsWith('- ')) { const [list, nextIdx] = parseList(lines, idx, childIndent); result[key] = list; idx = nextIdx; } else { const [obj, nextIdx] = parseMapping(lines, idx, childIndent); result[key] = obj; idx = nextIdx; } } else { result[key] = null; } } else { result[key] = parseScalar(rhs); } } return [result, idx]; } function findUnquotedColon(s) { let inQuote = null; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (inQuote) { if (ch === '\\') { i++; continue; } if (ch === inQuote) inQuote = null; } else if (ch === '"' || ch === "'") { inQuote = ch; } else if (ch === ':') { return i; } } return -1; } /** * Public: parse a YAML document into a plain JS object. * Throws on malformed input. */ export function parseYaml(text) { const lines = preprocessLines(text); if (lines.length === 0) return {}; const baseIndent = lines[0].indent; if (lines[0].content.startsWith('- ')) { const [list] = parseList(lines, 0, baseIndent); return list; } const [obj] = parseMapping(lines, 0, baseIndent); return obj; } // ===================================================================== // Profile loader // ===================================================================== /** * List all built-in profiles by name (filename minus .yaml). * Sorted alphabetically. */ export async function listProfiles({ profilesDir = PROFILES_DIR } = {}) { let entries; try { entries = await readdir(profilesDir); } catch (err) { if (err.code === 'ENOENT') return []; throw err; } const names = entries .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')) .map((f) => f.replace(/\.ya?ml$/, '')) .sort(); return names; } /** * Load and validate a profile by name. * Throws if the file is missing, malformed, or fails validation. */ export async function loadProfile(name, opts = {}) { const profilesDir = opts.profilesDir ?? PROFILES_DIR; const agentsDir = opts.agentsDir ?? AGENTS_DIR; const path = await resolveProfilePath(name, profilesDir); if (!path) { const available = await listProfiles({ profilesDir }); throw new Error( `Profile '${name}' not found in ${profilesDir}. Available: ${available.join(', ') || '(none)'}.` ); } const text = await readFile(path, 'utf8'); let parsed; try { parsed = parseYaml(text); } catch (err) { throw new Error(`Failed to parse profile '${name}' (${path}): ${err.message}`); } await validateProfile(parsed, { agentsDir, sourcePath: path }); return parsed; } async function resolveProfilePath(name, profilesDir) { const candidates = [join(profilesDir, `${name}.yaml`), join(profilesDir, `${name}.yml`)]; for (const c of candidates) { try { const s = await stat(c); if (s.isFile()) return c; } catch { // skip } } return null; } /** * Validate a parsed profile object against the v1 schema. * Throws an Error with all validation issues bundled. */ export async function validateProfile(profile, opts = {}) { const agentsDir = opts.agentsDir ?? AGENTS_DIR; const issues = []; if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { throw new Error('Profile must be a YAML mapping at the top level.'); } for (const field of REQUIRED_FIELDS) { if (!(field in profile)) issues.push(`Missing required field: ${field}`); } if ('version' in profile && profile.version !== 1) { issues.push(`Unsupported profile version ${profile.version} (expected 1).`); } if ('agents' in profile) { const agents = profile.agents; if (!agents || typeof agents !== 'object' || Array.isArray(agents)) { issues.push('agents must be a mapping with exploration and review keys.'); } else { for (const k of REQUIRED_AGENT_KEYS) { if (!(k in agents)) issues.push(`agents.${k} is required.`); else if (!Array.isArray(agents[k])) issues.push(`agents.${k} must be a list of agent names.`); } // Cross-check: every agent name must exist in agents/.md const allAgents = [ ...(Array.isArray(agents.exploration) ? agents.exploration : []), ...(Array.isArray(agents.review) ? agents.review : []), ]; const missing = await missingAgents(allAgents, agentsDir); for (const m of missing) issues.push(`Unknown agent referenced: ${m} (no ${agentsDir}/${m}.md).`); } } if ('axes' in profile && profile.axes !== null) { if (typeof profile.axes !== 'object' || Array.isArray(profile.axes)) { issues.push('axes must be a mapping (or omitted).'); } } if ('triggers' in profile && profile.triggers !== null) { if (typeof profile.triggers !== 'object' || Array.isArray(profile.triggers)) { issues.push('triggers must be a mapping (or omitted).'); } else { for (const k of ['keywords', 'nfr_signals']) { if (k in profile.triggers && !Array.isArray(profile.triggers[k])) { issues.push(`triggers.${k} must be a list.`); } } } } if ('skills' in profile && profile.skills !== null) { if (typeof profile.skills !== 'object' || Array.isArray(profile.skills)) { issues.push('skills must be a mapping (or omitted).'); } else { for (const k of ['catalog_filter', 'prefer_layer']) { if (k in profile.skills && !Array.isArray(profile.skills[k])) { issues.push(`skills.${k} must be a list.`); } } } } if ('adversarial' in profile && profile.adversarial !== null) { const adv = profile.adversarial; if (typeof adv !== 'object' || Array.isArray(adv)) { issues.push('adversarial must be a mapping (or omitted).'); } else { if ('depth' in adv && !['light', 'standard', 'deep'].includes(adv.depth)) { issues.push(`adversarial.depth must be one of light|standard|deep, got: ${adv.depth}`); } if ('iterations' in adv && (typeof adv.iterations !== 'number' || adv.iterations < 1)) { issues.push('adversarial.iterations must be a positive number.'); } if ('blockers_only' in adv && typeof adv.blockers_only !== 'boolean') { issues.push('adversarial.blockers_only must be a boolean.'); } } } if (issues.length > 0) { const src = opts.sourcePath ? ` (${opts.sourcePath})` : ''; throw new Error(`Profile validation failed${src}:\n - ${issues.join('\n - ')}`); } return true; } async function missingAgents(names, agentsDir) { const missing = []; for (const n of names) { if (typeof n !== 'string') { missing.push(String(n)); continue; } try { const s = await stat(join(agentsDir, `${n}.md`)); if (!s.isFile()) missing.push(n); } catch { missing.push(n); } } return missing; } // ===================================================================== // Recommendation helper // ===================================================================== /** * Recommendation threshold used by ultrabrief-local Step 4h. The * profile-recommender agent's top-ranked profile must reach this score to * be presented as a recommendation; below it, ultrabrief falls back to * `default` with an explicit message. */ export const RECOMMENDATION_THRESHOLD = 0.7; /** * Decide what to do with a `profile-recommender` agent's ranked output. * Returns `{ profile, match, rationale, source }` where: * - `source` is `recommended` (top ≥ threshold), `fallback` (top < threshold * or empty input), or `default-only` (only `default` available). * - `profile` is the chosen profile name. * - `match` is one of `exact | partial | fallback | default-only`. * - `rationale` is a one-sentence explanation suitable for the brief * frontmatter. * * Rules: * - If `availableProfiles` only contains `default`, return `default-only`. * - If `ranked` is empty/malformed, fall back to `default` with a fallback * rationale. * - Otherwise pick the highest-scoring entry; recommend it only when * `score >= RECOMMENDATION_THRESHOLD`. Below threshold, recommend * `default` with `match: fallback` and the top entry's rationale. */ export function selectRecommendation(ranked, opts = {}) { const threshold = opts.threshold ?? RECOMMENDATION_THRESHOLD; const available = opts.availableProfiles ?? null; if (Array.isArray(available) && available.length === 1 && available[0] === 'default') { return { profile: 'default', match: 'default-only', rationale: 'Only the default profile is available; recommendation skipped.', source: 'default-only', }; } if (!Array.isArray(ranked) || ranked.length === 0) { return { profile: 'default', match: 'fallback', rationale: 'profile-recommender returned no ranked profiles; using default.', source: 'fallback', }; } // Find highest-scoring entry. Treat missing/non-numeric scores as 0. let top = null; for (const entry of ranked) { if (!entry || typeof entry.name !== 'string') continue; const score = typeof entry.score === 'number' ? entry.score : 0; if (top === null || score > (typeof top.score === 'number' ? top.score : 0)) { top = { ...entry, score }; } } if (top === null) { return { profile: 'default', match: 'fallback', rationale: 'profile-recommender output had no usable entries; using default.', source: 'fallback', }; } if (top.score >= threshold) { return { profile: top.name, match: typeof top.match_quality === 'string' ? top.match_quality : 'partial', rationale: typeof top.rationale === 'string' && top.rationale.trim() !== '' ? top.rationale : `Top-ranked profile (score ${top.score}).`, source: 'recommended', }; } return { profile: 'default', match: 'fallback', rationale: typeof top.rationale === 'string' && top.rationale.trim() !== '' ? `Top score ${top.score} below ${threshold}; ${top.rationale}` : `Top score ${top.score} below recommendation threshold ${threshold}.`, source: 'fallback', }; } // ===================================================================== // CLI // ===================================================================== const USAGE = `Usage: node scripts/profile-loader.mjs list node scripts/profile-loader.mjs load node scripts/profile-loader.mjs validate `; async function main() { const args = argv.slice(2); if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { stdout.write(USAGE); return; } const cmd = args[0]; if (cmd === 'list') { const names = await listProfiles(); for (const n of names) stdout.write(n + '\n'); return; } if (cmd === 'load') { const name = args[1]; if (!name) { stderr.write('load requires \n'); exit(2); } const profile = await loadProfile(name); stdout.write(JSON.stringify(profile, null, 2) + '\n'); return; } if (cmd === 'validate') { const path = args[1]; if (!path) { stderr.write('validate requires \n'); exit(2); } const text = await readFile(path, 'utf8'); const parsed = parseYaml(text); await validateProfile(parsed, { sourcePath: path }); stdout.write(`OK: ${basename(path)} validates against profile schema v1\n`); return; } stderr.write(USAGE); exit(2); } // Run CLI only if invoked directly, not when imported. if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { stderr.write(`Error: ${err.message}\n`); exit(1); }); }