From ce162e6c4119fe219c2d4ebaf8d3534df40b68ad Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 14 May 2026 21:38:51 +0200 Subject: [PATCH] feat(voyage): add resolvePhaseModel for brief-signal orchestrator override (closes #9 part A) --- plugins/voyage/lib/profiles/resolver.mjs | 107 ++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/plugins/voyage/lib/profiles/resolver.mjs b/plugins/voyage/lib/profiles/resolver.mjs index 45354e0..3d17bc1 100644 --- a/plugins/voyage/lib/profiles/resolver.mjs +++ b/plugins/voyage/lib/profiles/resolver.mjs @@ -1,5 +1,5 @@ // lib/profiles/resolver.mjs -// Profile resolution layer (v4.1 SC #5-#9). +// Profile resolution layer (v4.1 SC #5-#9; v5.1.1 brief-signal extension). // // Locked interface contract (per brief Preferences): // loadProfile(name) → ProfileObject @@ -22,6 +22,16 @@ // 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). @@ -31,6 +41,7 @@ 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/ @@ -201,3 +212,97 @@ export function resolveTrekcontinueProfile(planPath, argv, opts = {}) { 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). +}