From 3ed2d84caafc87d437b92cf90ac5248dc7549f0a Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 14 May 2026 21:34:14 +0200 Subject: [PATCH] feat(voyage): add phase-signal-resolver helper for v5.1.1 wiring --- .../lib/profiles/phase-signal-resolver.mjs | 86 +++++++++++++++++++ .../tests/lib/phase-signal-resolver.test.mjs | 77 +++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 plugins/voyage/lib/profiles/phase-signal-resolver.mjs create mode 100644 plugins/voyage/tests/lib/phase-signal-resolver.test.mjs diff --git a/plugins/voyage/lib/profiles/phase-signal-resolver.mjs b/plugins/voyage/lib/profiles/phase-signal-resolver.mjs new file mode 100644 index 0000000..d8588bd --- /dev/null +++ b/plugins/voyage/lib/profiles/phase-signal-resolver.mjs @@ -0,0 +1,86 @@ +// lib/profiles/phase-signal-resolver.mjs +// v5.1.1 — extract per-phase signal from a brief's frontmatter. +// +// Decision A wiring: commands invoke this helper via Bash CLI shim +// (`node lib/profiles/phase-signal-resolver.mjs --brief --phase --json`) +// to obtain the {effort, model} pair for a specific pipeline phase. +// +// Sole source of truth for PHASE_SIGNAL_PHASES + EFFORT_LEVELS is +// lib/validators/brief-validator.mjs — re-imported here so the helper +// cannot drift from the schema validator. + +import { readFileSync, existsSync } from 'node:fs'; +import { parseDocument } from '../util/frontmatter.mjs'; +import { PHASE_SIGNAL_PHASES, EFFORT_LEVELS } from '../validators/brief-validator.mjs'; + +/** + * Resolve a brief's phase_signal entry for one phase. + * + * @param {object|null} briefFrontmatter Parsed YAML frontmatter dict (or null/undefined). + * @param {string} phase One of PHASE_SIGNAL_PHASES; anything else returns null. + * @returns {{effort?: string, model?: string} | null} + * + * Never throws. Returns null on: + * - Falsy / non-object frontmatter + * - phase not in PHASE_SIGNAL_PHASES (e.g. 'brief' or 'continue') + * - Missing phase_signals array + * - No entry for the requested phase + * + * Returns partial `{effort}` (with `model: undefined`) when the signal omits model. + */ +export function resolvePhaseSignal(briefFrontmatter, phase) { + if (!briefFrontmatter || typeof briefFrontmatter !== 'object') return null; + if (typeof phase !== 'string' || !PHASE_SIGNAL_PHASES.includes(phase)) return null; + const signals = briefFrontmatter.phase_signals; + if (!Array.isArray(signals)) return null; + for (const entry of signals) { + if (entry && typeof entry === 'object' && entry.phase === phase) { + const out = {}; + if ('effort' in entry && EFFORT_LEVELS.includes(entry.effort)) out.effort = entry.effort; + if ('model' in entry) out.model = entry.model; + return out; + } + } + return null; +} + +/** + * Convenience wrapper: read a brief file, parse, and resolve a single phase. + * Returns null on any read or parse failure (graceful degradation). + */ +export function resolvePhaseSignalFromFile(briefPath, phase) { + if (typeof briefPath !== 'string' || briefPath.length === 0) return null; + if (!existsSync(briefPath)) return null; + let text; + try { text = readFileSync(briefPath, 'utf-8'); } catch { return null; } + const doc = parseDocument(text); + if (!doc || !doc.valid) return null; + const fm = doc.parsed && doc.parsed.frontmatter; + return resolvePhaseSignal(fm, phase); +} + +// CLI shim — mirrors lib/validators/brief-validator.mjs:168 pattern. +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + const getArg = (name) => { + const i = args.indexOf(name); + return i >= 0 && i + 1 < args.length ? args[i + 1] : null; + }; + const briefPath = getArg('--brief'); + const phase = getArg('--phase'); + if (!briefPath || !phase) { + process.stderr.write('Usage: phase-signal-resolver.mjs --brief --phase [--json]\n'); + process.exit(2); + } + const result = resolvePhaseSignalFromFile(briefPath, phase); + if (args.includes('--json')) { + process.stdout.write(JSON.stringify(result) + '\n'); + } else if (result === null) { + process.stdout.write('null\n'); + } else { + const effort = 'effort' in result ? result.effort : ''; + const model = 'model' in result ? result.model : ''; + process.stdout.write(`effort=${effort} model=${model}\n`); + } + process.exit(0); +} diff --git a/plugins/voyage/tests/lib/phase-signal-resolver.test.mjs b/plugins/voyage/tests/lib/phase-signal-resolver.test.mjs new file mode 100644 index 0000000..5461740 --- /dev/null +++ b/plugins/voyage/tests/lib/phase-signal-resolver.test.mjs @@ -0,0 +1,77 @@ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolvePhaseSignal, resolvePhaseSignalFromFile } from '../../lib/profiles/phase-signal-resolver.mjs'; + +const FULL_SIGNALS_FM = { + phase_signals: [ + { phase: 'research', effort: 'low', model: 'sonnet' }, + { phase: 'plan', effort: 'standard' }, + { phase: 'execute', effort: 'high', model: 'opus' }, + { phase: 'review', effort: 'standard', model: 'sonnet' }, + ], +}; + +test('resolvePhaseSignal — returns {effort, model} for all 4 phases on full-signals brief', () => { + for (const phase of ['research', 'plan', 'execute', 'review']) { + const r = resolvePhaseSignal(FULL_SIGNALS_FM, phase); + assert.ok(r && typeof r === 'object', `phase=${phase} should resolve non-null`); + assert.ok(typeof r.effort === 'string', `phase=${phase} should have effort`); + } +}); + +test('resolvePhaseSignal — returns null when brief has no phase_signals', () => { + const r = resolvePhaseSignal({ task: 'x' }, 'plan'); + assert.equal(r, null); +}); + +test('resolvePhaseSignal — returns partial {effort} with model undefined when signal omits model', () => { + const r = resolvePhaseSignal(FULL_SIGNALS_FM, 'plan'); + assert.equal(r.effort, 'standard'); + assert.equal(r.model, undefined); + assert.ok(!('model' in r), 'model key should be absent when not in signal'); +}); + +test('resolvePhaseSignal — returns null when phase is not in PHASE_SIGNAL_PHASES', () => { + assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'brief'), null); + assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'continue'), null); + assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'nonsense'), null); +}); + +test('resolvePhaseSignal — defensive: null/non-object input returns null', () => { + assert.equal(resolvePhaseSignal(null, 'plan'), null); + assert.equal(resolvePhaseSignal(undefined, 'plan'), null); + assert.equal(resolvePhaseSignal('string', 'plan'), null); + assert.equal(resolvePhaseSignal({ phase_signals: 'not-array' }, 'plan'), null); +}); + +test('resolvePhaseSignalFromFile + CLI shim — writes JSON to stdout, exit 0', () => { + const fixture = join(tmpdir(), `phase-signal-test-${process.pid}.md`); + writeFileSync(fixture, `--- +type: trekbrief +brief_version: "2.1" +phase_signals: + - phase: plan + effort: high + model: opus +--- +# x +`); + try { + // Programmatic invocation + const r = resolvePhaseSignalFromFile(fixture, 'plan'); + assert.deepEqual(r, { effort: 'high', model: 'opus' }); + // CLI shim + const helperPath = new URL('../../lib/profiles/phase-signal-resolver.mjs', import.meta.url).pathname; + const out = execFileSync('node', [helperPath, '--brief', fixture, '--phase', 'plan', '--json'], { + encoding: 'utf-8', + }); + const parsed = JSON.parse(out.trim()); + assert.deepEqual(parsed, { effort: 'high', model: 'opus' }); + } finally { + try { unlinkSync(fixture); } catch { /* swallow */ } + } +});