feat(voyage): add resolvePhaseModel for brief-signal orchestrator override (closes #9 part A)
This commit is contained in:
parent
48e092d2bc
commit
ce162e6c41
1 changed files with 106 additions and 1 deletions
|
|
@ -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: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.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 <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).
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue