// lib/profiles/resolver.mjs // Profile resolution layer (v4.1 SC #5-#9). // // Locked interface contract (per brief Preferences): // loadProfile(name) → ProfileObject // - Reads lib/profiles/.yaml or custom voyage-profiles/.yaml. // - Throws Error (cause: PROFILE_NOT_FOUND) when not found. // - Returns parsed object with phase_models flattened to {brief: 'sonnet', ...} // (object form for downstream JSON-stats; conversion from YAML list-of-dicts). // // resolveProfile(argv, env) → {profile, profile_source} // - Resolution order: --profile flag > VOYAGE_PROFILE env > 'premium' default. // - profile_source: 'flag' | 'env' | 'default'. // // resolveTrekcontinueProfile(planPath, argv) → {profile, profile_source} // - --profile flag in argv wins with 'flag'. // - Otherwise reads plan.md frontmatter via parseDocument; returns // plan-frontmatter `profile` field with 'inheritance'. // - If flag overrides inheritance, console.error emits an advisory. // - For v4.0-style plans without `profile:` field, returns 'default' premium. // // validateProfileFile(path) → Result // - Thin wrapper around validateProfile from profile-validator.mjs. // // Custom.yaml lookup order: /voyage-profiles/.yaml > ~/.claude/voyage-profiles/.yaml // Both attempted paths included in error message on miss (HIGH-risk-mitigering). import { readFileSync, existsSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { parseDocument } from '../util/frontmatter.mjs'; import { validateProfile } from '../validators/profile-validator.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const BUILTIN_PROFILES_DIR = __dirname; // lib/profiles/ const BUILTIN_NAMES = new Set(['economy', 'balanced', 'premium']); /** * Resolve the path to a profile file. * Built-in profiles: lib/profiles/.yaml * Custom profiles: /voyage-profiles/.yaml > ~/.claude/voyage-profiles/.yaml * * @returns {{path: string|null, attempted: string[]}} */ export function findProfilePath(name, opts = {}) { const cwd = opts.cwd || process.cwd(); const home = opts.home || homedir(); const attempted = []; if (BUILTIN_NAMES.has(name)) { const builtinPath = join(BUILTIN_PROFILES_DIR, `${name}.yaml`); attempted.push(builtinPath); if (existsSync(builtinPath)) return { path: builtinPath, attempted }; } // Custom: repo-root first, then home const repoCustom = join(cwd, 'voyage-profiles', `${name}.yaml`); attempted.push(repoCustom); if (existsSync(repoCustom)) return { path: repoCustom, attempted }; const homeCustom = join(home, '.claude', 'voyage-profiles', `${name}.yaml`); attempted.push(homeCustom); if (existsSync(homeCustom)) return { path: homeCustom, attempted }; return { path: null, attempted }; } /** * Flatten phase_models list-of-dicts to object form: {brief: 'sonnet', research: 'opus', ...} */ function flattenPhaseModels(list) { const out = {}; if (!Array.isArray(list)) return out; for (const entry of list) { if (entry && typeof entry === 'object' && typeof entry.phase === 'string' && typeof entry.model === 'string') { out[entry.phase] = entry.model; } } return out; } /** * Load and parse a profile file by name. * @param {string} name * @param {{cwd?: string, home?: string}} [opts] * @returns {{name: string, profile_version: string, phase_models: object, parallel_agents_min: number, parallel_agents_max: number, external_research_enabled: boolean, brief_reviewer_iter_cap: number, _path: string}} * @throws {Error} cause: PROFILE_NOT_FOUND | PROFILE_PARSE_ERROR */ export function loadProfile(name, opts = {}) { const { path, attempted } = findProfilePath(name, opts); if (!path) { const err = new Error( `Profile "${name}" not found. Attempted paths:\n - ${attempted.join('\n - ')}`, ); err.cause = 'PROFILE_NOT_FOUND'; err.attempted = attempted; throw err; } let text; try { text = readFileSync(path, 'utf-8'); } catch (e) { const err = new Error(`Cannot read profile "${name}" at ${path}: ${e.message}`); err.cause = 'PROFILE_READ_ERROR'; throw err; } const doc = parseDocument(text); if (!doc.valid) { const err = new Error(`Profile "${name}" parse error: ${doc.errors[0]?.message || 'unknown'}`); err.cause = 'PROFILE_PARSE_ERROR'; throw err; } const fm = doc.parsed.frontmatter || {}; return { name: fm.name, profile_version: fm.profile_version, phase_models: flattenPhaseModels(fm.phase_models), parallel_agents_min: fm.parallel_agents_min, parallel_agents_max: fm.parallel_agents_max, external_research_enabled: fm.external_research_enabled, brief_reviewer_iter_cap: fm.brief_reviewer_iter_cap, _path: path, }; } /** * Resolve profile name from argv + env + default. * Order: --profile flag > VOYAGE_PROFILE env > 'premium'. * * @param {{flags?: object} | string[]} argv Either parsed argv or a flags-object. * @param {object} [env] * @returns {{profile: string, profile_source: 'flag'|'env'|'default'}} */ export function resolveProfile(argv, env = process.env) { const flags = (argv && typeof argv === 'object' && argv.flags) ? argv.flags : (argv || {}); if (typeof flags['--profile'] === 'string' && flags['--profile'].length > 0) { return { profile: flags['--profile'], profile_source: 'flag' }; } if (typeof env.VOYAGE_PROFILE === 'string' && env.VOYAGE_PROFILE.length > 0) { return { profile: env.VOYAGE_PROFILE, profile_source: 'env' }; } return { profile: 'premium', profile_source: 'default' }; } /** * Resolve profile for /trekcontinue: prefers explicit flag, falls back to plan * frontmatter (inheritance), then 'premium' default if plan has no profile field. * * @param {string} planPath Path to plan.md * @param {{flags?: object} | string[]} argv * @param {{env?: object, console?: Console}} [opts] * @returns {{profile: string, profile_source: 'flag'|'inheritance'|'default'}} */ export function resolveTrekcontinueProfile(planPath, argv, opts = {}) { const env = opts.env || process.env; const con = opts.console || console; const flags = (argv && typeof argv === 'object' && argv.flags) ? argv.flags : (argv || {}); const flagProfile = (typeof flags['--profile'] === 'string' && flags['--profile'].length > 0) ? flags['--profile'] : null; // Read plan-frontmatter to detect inheritance let planProfile = null; if (planPath && existsSync(planPath)) { try { const text = readFileSync(planPath, 'utf-8'); const doc = parseDocument(text); if (doc.valid) { const fm = doc.parsed.frontmatter || {}; if (typeof fm.profile === 'string' && fm.profile.length > 0) { planProfile = fm.profile; } } } catch { // swallow — degrades gracefully to default } } if (flagProfile) { if (planProfile && planProfile !== flagProfile) { con.error(`[voyage] profile inheritance overridden by --profile flag: ${planProfile} → ${flagProfile}`); } return { profile: flagProfile, profile_source: 'flag' }; } if (planProfile) { return { profile: planProfile, profile_source: 'inheritance' }; } // v4.0-style plan without profile: default to premium return { profile: 'premium', profile_source: 'default' }; } /** * Validate a profile YAML file. * Thin wrapper for locked-interface compatibility. */ export function validateProfileFile(path, opts = {}) { return validateProfile(path, opts); }