feat(voyage): add lib/profiles/resolver.mjs — locked interface SC #5-#9
Step 6 av v4.1-execute (Wave 2, Session 2).
Implementer locked interface contract fra brief Preferences:
- loadProfile(name, opts) → ProfileObject
Leser lib/profiles/<name>.yaml (built-in) eller custom fra
<cwd>/voyage-profiles/ > ~/.claude/voyage-profiles/. Throws Error med
cause: PROFILE_NOT_FOUND. Returnerer parsed object med phase_models
flattened til {brief: 'sonnet', research: 'opus', ...} (object form
for downstream JSON-stats).
- resolveProfile(argv, env) → {profile, profile_source}
Ordre: --profile flag > VOYAGE_PROFILE env > 'premium' default.
- resolveTrekcontinueProfile(planPath, argv, opts) → {profile, profile_source}
--profile flag wins ('flag'); ellers leser plan.md frontmatter
('inheritance'); v4.0-stil plan uten profile-felt → 'default' premium
(backward-compat). Flag overstyrer arv → console.error advisory.
- validateProfileFile(path) → Result
Tynn re-eksport av validateProfile fra profile-validator.mjs.
- findProfilePath(name, opts) → {path, attempted}
Lookup-helper. attempted-array brukes i error-melding for HIGH-risk-
mitigering (ENOENT-diagnose).
Tester (13 nye, baseline 387 → 400):
- SC #5 x4 (loadProfile economy/balanced/premium + PROFILE_NOT_FOUND)
- SC #6 (flag > env > default ordre)
- SC #7 (performance: 1000-iter < 50ms gjennomsnitt; faktisk ~0.055ms)
- SC #8 x2 (cwd > home precedence + error-msg attempted-paths)
- SC #9 x2 (inheritance + flag-override-advisory)
- Backward-compat x2 (v4.0 plan + non-existent plan)
- validateProfileFile re-export sanity
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
be9ad6ec07
commit
f419121682
4 changed files with 474 additions and 0 deletions
203
plugins/voyage/lib/profiles/resolver.mjs
Normal file
203
plugins/voyage/lib/profiles/resolver.mjs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// lib/profiles/resolver.mjs
|
||||
// Profile resolution layer (v4.1 SC #5-#9).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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';
|
||||
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue