feat(voyage): add phase-signal-resolver helper for v5.1.1 wiring
This commit is contained in:
parent
8f4b79cfc6
commit
3ed2d84caa
2 changed files with 163 additions and 0 deletions
86
plugins/voyage/lib/profiles/phase-signal-resolver.mjs
Normal file
86
plugins/voyage/lib/profiles/phase-signal-resolver.mjs
Normal file
|
|
@ -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 <path> --phase <name> --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 <path> --phase <name> [--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);
|
||||
}
|
||||
77
plugins/voyage/tests/lib/phase-signal-resolver.test.mjs
Normal file
77
plugins/voyage/tests/lib/phase-signal-resolver.test.mjs
Normal file
|
|
@ -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 */ }
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue