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:
Kjell Tore Guttormsen 2026-05-09 09:29:01 +02:00
commit f419121682
4 changed files with 474 additions and 0 deletions

View 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);
}

View file

@ -0,0 +1,26 @@
---
plan_version: "1.7"
profile: balanced
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
---
# Test plan (with profile)
This fixture has explicit profile + phase_models in frontmatter.
## Implementation Plan
### Step 1: Stub
- Files: src/stub.mjs

View file

@ -0,0 +1,15 @@
---
plan_version: "1.6"
---
# Test plan (v4.0-style, no profile field)
This fixture is a v4.0-style plan WITHOUT the v4.1 profile/phase_models fields.
Used by tests/lib/profile-application.test.mjs to verify backward-compat
edge-case: resolveTrekcontinueProfile returns {profile: 'premium', profile_source: 'default'}
without throwing when the plan has no profile concept.
## Implementation Plan
### Step 1: Stub
- Files: src/stub.mjs

View file

@ -0,0 +1,230 @@
// tests/lib/profile-application.test.mjs
// SC #5-#9 + backward-compat edge-case for lib/profiles/resolver.mjs.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
loadProfile,
resolveProfile,
resolveTrekcontinueProfile,
validateProfileFile,
findProfilePath,
} from '../../lib/profiles/resolver.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..', '..');
// SC #5: loadProfile returns matrix-match for all 6 phase_models
test('SC #5: loadProfile("economy") returns flattened phase_models with all 6 phases', () => {
const p = loadProfile('economy');
assert.equal(p.name, 'economy');
assert.equal(p.phase_models.brief, 'sonnet');
assert.equal(p.phase_models.research, 'sonnet');
assert.equal(p.phase_models.plan, 'sonnet');
assert.equal(p.phase_models.execute, 'sonnet');
assert.equal(p.phase_models.review, 'sonnet');
assert.equal(p.phase_models.continue, 'sonnet');
assert.equal(p.parallel_agents_min, 2);
assert.equal(p.parallel_agents_max, 3);
assert.equal(p.external_research_enabled, false);
assert.equal(p.brief_reviewer_iter_cap, 1);
});
test('SC #5: loadProfile("balanced") returns mixed phase_models', () => {
const p = loadProfile('balanced');
assert.equal(p.phase_models.plan, 'opus');
assert.equal(p.phase_models.review, 'opus');
assert.equal(p.phase_models.brief, 'sonnet');
assert.equal(p.phase_models.execute, 'sonnet');
});
test('SC #5: loadProfile("premium") returns all-opus', () => {
const p = loadProfile('premium');
for (const phase of ['brief', 'research', 'plan', 'execute', 'review', 'continue']) {
assert.equal(p.phase_models[phase], 'opus', `premium ${phase} should be opus`);
}
});
test('SC #5: loadProfile throws PROFILE_NOT_FOUND for unknown profile', () => {
try {
loadProfile('does-not-exist-xyz');
assert.fail('expected throw');
} catch (e) {
assert.equal(e.cause, 'PROFILE_NOT_FOUND');
assert.match(e.message, /not found/);
assert.ok(Array.isArray(e.attempted), 'should expose attempted paths');
}
});
// SC #6: env-var fallback flag > env > default
test('SC #6: resolveProfile flag > env > default', () => {
// flag wins
const r1 = resolveProfile({ flags: { '--profile': 'balanced' } }, { VOYAGE_PROFILE: 'economy' });
assert.equal(r1.profile, 'balanced');
assert.equal(r1.profile_source, 'flag');
// env wins when no flag
const r2 = resolveProfile({ flags: {} }, { VOYAGE_PROFILE: 'economy' });
assert.equal(r2.profile, 'economy');
assert.equal(r2.profile_source, 'env');
// default when neither
const r3 = resolveProfile({ flags: {} }, {});
assert.equal(r3.profile, 'premium');
assert.equal(r3.profile_source, 'default');
});
// SC #7: performance — loadProfile 1000 iter < 50ms average (allowing some headroom)
test('SC #7: loadProfile 1000-iter performance < 50ms average', () => {
const iterations = 1000;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
loadProfile('economy');
}
const elapsed = performance.now() - start;
const avgMs = elapsed / iterations;
assert.ok(avgMs < 50, `loadProfile too slow: ${avgMs.toFixed(3)}ms average over ${iterations} iter`);
});
// SC #8: custom.yaml from repo-root trumps ~/.claude/
test('SC #8: custom profile from <cwd>/voyage-profiles/<name>.yaml takes precedence over ~/.claude/', () => {
const tmpRepo = mkdtempSync(join(tmpdir(), 'voyage-resolver-repo-'));
const tmpHome = mkdtempSync(join(tmpdir(), 'voyage-resolver-home-'));
try {
// Place custom profile in repo and home — repo should win
mkdirSync(join(tmpRepo, 'voyage-profiles'), { recursive: true });
mkdirSync(join(tmpHome, '.claude', 'voyage-profiles'), { recursive: true });
writeFileSync(join(tmpRepo, 'voyage-profiles', 'mycustom.yaml'),
`---
profile_version: "1.0"
name: mycustom-repo
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: sonnet
- phase: execute
model: sonnet
- phase: review
model: sonnet
- phase: continue
model: sonnet
parallel_agents_min: 1
parallel_agents_max: 2
external_research_enabled: false
brief_reviewer_iter_cap: 1
---
`);
writeFileSync(join(tmpHome, '.claude', 'voyage-profiles', 'mycustom.yaml'),
`---
profile_version: "1.0"
name: mycustom-home
phase_models:
- phase: brief
model: opus
- phase: research
model: opus
- phase: plan
model: opus
- phase: execute
model: opus
- phase: review
model: opus
- phase: continue
model: opus
parallel_agents_min: 1
parallel_agents_max: 2
external_research_enabled: true
brief_reviewer_iter_cap: 3
---
`);
const found = findProfilePath('mycustom', { cwd: tmpRepo, home: tmpHome });
assert.ok(found.path, `expected to find mycustom; attempted: ${found.attempted.join(', ')}`);
assert.ok(found.path.startsWith(tmpRepo),
`expected repo-rot win (path under ${tmpRepo}), got: ${found.path}`);
const p = loadProfile('mycustom', { cwd: tmpRepo, home: tmpHome });
assert.equal(p.name, 'mycustom-repo', 'repo profile should win');
assert.equal(p.phase_models.brief, 'sonnet');
} finally {
rmSync(tmpRepo, { recursive: true, force: true });
rmSync(tmpHome, { recursive: true, force: true });
}
});
test('SC #8: missing profile error message includes both attempted paths', () => {
const tmpRepo = mkdtempSync(join(tmpdir(), 'voyage-resolver-empty-'));
const tmpHome = mkdtempSync(join(tmpdir(), 'voyage-resolver-emptyhome-'));
try {
try {
loadProfile('not-a-real-profile', { cwd: tmpRepo, home: tmpHome });
assert.fail('expected throw');
} catch (e) {
assert.equal(e.cause, 'PROFILE_NOT_FOUND');
// Both attempted paths should be in the error message for diagnostic clarity
const msg = e.message;
assert.match(msg, /voyage-profiles\/not-a-real-profile\.yaml/);
assert.match(msg, /\.claude\/voyage-profiles\/not-a-real-profile\.yaml/);
}
} finally {
rmSync(tmpRepo, { recursive: true, force: true });
rmSync(tmpHome, { recursive: true, force: true });
}
});
// SC #9: resolveTrekcontinueProfile inheritance from plan-frontmatter
test('SC #9: resolveTrekcontinueProfile inherits from plan-frontmatter (profile: balanced)', () => {
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-with-profile.md');
const r = resolveTrekcontinueProfile(planPath, { flags: {} });
assert.equal(r.profile, 'balanced');
assert.equal(r.profile_source, 'inheritance');
});
test('SC #9: resolveTrekcontinueProfile flag overrides plan-frontmatter (advisory)', () => {
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-with-profile.md');
const advisories = [];
const fakeConsole = { error: (m) => advisories.push(m) };
const r = resolveTrekcontinueProfile(planPath,
{ flags: { '--profile': 'economy' } },
{ console: fakeConsole });
assert.equal(r.profile, 'economy');
assert.equal(r.profile_source, 'flag');
assert.equal(advisories.length, 1, 'expected one advisory message');
assert.match(advisories[0], /balanced.*economy/);
assert.match(advisories[0], /\[voyage\]/);
});
// Backward-compat edge-case: v4.0-style plan WITHOUT profile field
test('Backward-compat: resolveTrekcontinueProfile on v4.0 plan without profile field returns default premium', () => {
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-without-profile.md');
const r = resolveTrekcontinueProfile(planPath, { flags: {} });
assert.equal(r.profile, 'premium');
assert.equal(r.profile_source, 'default');
});
test('Backward-compat: resolveTrekcontinueProfile with non-existent plan path returns default premium', () => {
const r = resolveTrekcontinueProfile('/tmp/does-not-exist-plan-xyz.md', { flags: {} });
assert.equal(r.profile, 'premium');
assert.equal(r.profile_source, 'default');
});
// validateProfileFile re-export sanity
test('validateProfileFile re-exports validateProfile (locked-interface compat)', () => {
const r = validateProfileFile(join(REPO_ROOT, 'lib', 'profiles', 'economy.yaml'));
assert.equal(r.valid, true);
});