diff --git a/plugins/voyage/lib/profiles/resolver.mjs b/plugins/voyage/lib/profiles/resolver.mjs new file mode 100644 index 0000000..45354e0 --- /dev/null +++ b/plugins/voyage/lib/profiles/resolver.mjs @@ -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/.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); +} diff --git a/plugins/voyage/tests/fixtures/plan-with-profile.md b/plugins/voyage/tests/fixtures/plan-with-profile.md new file mode 100644 index 0000000..c476ddb --- /dev/null +++ b/plugins/voyage/tests/fixtures/plan-with-profile.md @@ -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 diff --git a/plugins/voyage/tests/fixtures/plan-without-profile.md b/plugins/voyage/tests/fixtures/plan-without-profile.md new file mode 100644 index 0000000..1478e50 --- /dev/null +++ b/plugins/voyage/tests/fixtures/plan-without-profile.md @@ -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 diff --git a/plugins/voyage/tests/lib/profile-application.test.mjs b/plugins/voyage/tests/lib/profile-application.test.mjs new file mode 100644 index 0000000..6a36513 --- /dev/null +++ b/plugins/voyage/tests/lib/profile-application.test.mjs @@ -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 /voyage-profiles/.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); +});