ktg-plugin-marketplace/plugins/voyage/lib/profiles/resolver.mjs

308 lines
12 KiB
JavaScript

// 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/<name>.yaml or custom voyage-profiles/<name>.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: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.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/<name>.yaml
* Custom profiles: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.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 <name> [--brief-path <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).
}