// lib/profiles/resolver.mjs // Profile resolution layer (v4.1 SC #5-#9; v5.1.1 brief-signal extension). // // 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. // // resolvePhaseModel(phase, briefPath, argv, env) // → {model, source} (v5.1.1 ADDITIVE) // - Highest-priority lookup step: brief.phase_signals[phase].model (source='brief-signal'). // - Falls through via resolveProfile (flag → env → default) when brief signal absent. // - Never throws; ENOENT/parse failures degrade silently to the next step. // - phase ∈ {brief, continue} (outside PHASE_SIGNAL_PHASES) always falls through. // - Controls ORCHESTRATOR model only. Sub-agents read agents/*.md frontmatter // directly; commands must inject {resolved model} into Agent-tool spawn sites // for sub-agents to honor brief signals. See feedback_voyage_opus_always.md. // // 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'; import { resolvePhaseSignal } from './phase-signal-resolver.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); } /** * Resolve the model for a specific pipeline phase, applying brief-signal override * as the new highest-priority lookup step (v5.1.1). * * @param {string} phase One of brief|research|plan|execute|review|continue * @param {string|null} briefPath Absolute or repo-relative path to brief.md, or null * @param {string[]|object} argv Full process.argv array OR parsed flags object * @param {object} [env] Environment-variable record (defaults to process.env) * @returns {{model: string, source: 'brief-signal'|'flag'|'env'|'default'}} * * Error handling contract: * - Never throws. Any failure (ENOENT on briefPath, malformed YAML, missing * phase_signals entry, missing model field) silently falls through. * - Treats unreadable briefPath as equivalent to briefPath=null. * - Returns the fallthrough resolution when phase ∈ {brief, continue} (not in * PHASE_SIGNAL_PHASES). * * Controls ORCHESTRATOR model only. Sub-agents read agents/*.md frontmatter * directly; commands must inject {resolved model} at Agent-tool spawn sites. */ export function resolvePhaseModel(phase, briefPath, argv, env = process.env) { // Step 1: brief-signal lookup if (typeof briefPath === 'string' && briefPath.length > 0 && existsSync(briefPath)) { let fm = null; try { const text = readFileSync(briefPath, 'utf-8'); const doc = parseDocument(text); if (doc && doc.valid) fm = doc.parsed && doc.parsed.frontmatter; } catch { // swallow — degrade gracefully to fallthrough } if (fm) { const signal = resolvePhaseSignal(fm, phase); if (signal && typeof signal.model === 'string' && signal.model.length > 0) { return { model: signal.model, source: 'brief-signal' }; } } } // Step 2+3+4: fall through via existing resolveProfile chain // (flag → env → default), then index into profile.phase_models[phase]. // Normalize argv: resolveProfile expects flags-object form. When called with // a raw process.argv-style array, extract --profile here. let normalizedFlags = argv; if (Array.isArray(argv)) { const idx = argv.indexOf('--profile'); normalizedFlags = (idx >= 0 && idx + 1 < argv.length) ? { '--profile': argv[idx + 1] } : {}; } const { profile, profile_source } = resolveProfile(normalizedFlags, env); let phaseModels = {}; try { const p = loadProfile(profile); phaseModels = p.phase_models || {}; } catch { // Unknown profile → fall back to premium try { const p = loadProfile('premium'); phaseModels = p.phase_models || {}; } catch { phaseModels = {}; } } const model = phaseModels[phase] || 'opus'; return { model, source: profile_source }; } // CLI shim — invoked by commands/trek*.md via Bash. // Usage: node lib/profiles/resolver.mjs --resolve-phase-model --phase plan --brief-path foo.md --json if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); if (args.includes('--resolve-phase-model')) { const getArg = (name) => { const i = args.indexOf(name); return i >= 0 && i + 1 < args.length ? args[i + 1] : null; }; const phase = getArg('--phase'); const briefPath = getArg('--brief-path'); if (!phase) { process.stderr.write('Usage: resolver.mjs --resolve-phase-model --phase [--brief-path ] [--json]\n'); process.exit(2); } const r = resolvePhaseModel(phase, briefPath || null, args, process.env); if (args.includes('--json')) { process.stdout.write(JSON.stringify(r) + '\n'); } else { process.stdout.write(`model=${r.model} source=${r.source}\n`); } process.exit(0); } // Otherwise no-op (the module is primarily imported, not run directly). }