feat(voyage): add phase_signals validation + sequencing gate to brief-validator (v5.1)

This commit is contained in:
Kjell Tore Guttormsen 2026-05-13 21:08:37 +02:00
commit bf68fe6f5f
3 changed files with 131 additions and 1 deletions

View file

@ -9,12 +9,15 @@
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { BASE_ALLOWED_MODELS } from './profile-validator.mjs';
export const BRIEF_REQUIRED_FRONTMATTER = ['type', 'brief_version', 'task', 'slug', 'research_topics', 'research_status'];
export const REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER = ['type', 'task', 'slug', 'project_dir', 'findings'];
export const BRIEF_TYPE_VALUES = Object.freeze(['trekbrief', 'trekreview']);
export const BRIEF_RESEARCH_STATUS_VALUES = ['pending', 'in_progress', 'complete', 'skipped'];
export const BRIEF_BODY_SECTIONS = ['Intent', 'Goal', 'Success Criteria'];
export const PHASE_SIGNAL_PHASES = Object.freeze(['research', 'plan', 'execute', 'review']);
export const EFFORT_LEVELS = Object.freeze(['low', 'standard', 'high']);
function getRequiredFields(type) {
return type === 'trekreview' ? REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER : BRIEF_REQUIRED_FRONTMATTER;
@ -36,6 +39,67 @@ export function validateBriefContent(text, opts = {}) {
}
}
// v5.1 — phase_signals (additive optional field) + version-conditional sequencing gate.
// Composition rule documented in each downstream command's "Composition rule (v5.1)" section.
const hasSignals = 'phase_signals' in fm;
const hasPartial = 'phase_signals_partial' in fm;
if (hasSignals && hasPartial) {
errors.push(issue(
'BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE',
'phase_signals and phase_signals_partial are mutually exclusive — set exactly one',
'Either commit per-phase signals OR record phase_signals_partial: true (force-stop).',
));
}
if (hasSignals) {
if (!Array.isArray(fm.phase_signals)) {
errors.push(issue(
'BRIEF_INVALID_PHASE_SIGNALS',
'phase_signals must be a list of {phase, effort?, model?} entries',
));
} else {
for (const entry of fm.phase_signals) {
if (!entry || typeof entry !== 'object' || !('phase' in entry)) {
errors.push(issue('BRIEF_INVALID_PHASE_SIGNALS', `phase_signals entry must include a "phase" key`));
continue;
}
if (!PHASE_SIGNAL_PHASES.includes(entry.phase)) {
errors.push(issue(
'BRIEF_INVALID_PHASE_SIGNAL_PHASE',
`phase_signals.phase "${entry.phase}" not in [${PHASE_SIGNAL_PHASES.join(', ')}]`,
));
}
if ('effort' in entry && !EFFORT_LEVELS.includes(entry.effort)) {
errors.push(issue(
'BRIEF_INVALID_EFFORT',
`phase_signals.effort "${entry.effort}" not in [${EFFORT_LEVELS.join(', ')}]`,
));
}
if ('model' in entry && !BASE_ALLOWED_MODELS.includes(entry.model)) {
errors.push(issue(
'BRIEF_INVALID_MODEL',
`phase_signals.model "${entry.model}" not in [${BASE_ALLOWED_MODELS.join(', ')}]`,
));
}
}
}
}
// Sequencing gate: brief_version ≥ 2.1 requires phase_signals OR phase_signals_partial.
if (typeof fm.brief_version === 'string') {
const vm = fm.brief_version.match(/^(\d+)\.(\d+)$/);
if (vm) {
const major = Number(vm[1]);
const minor = Number(vm[2]);
const atLeast21 = major > 2 || (major === 2 && minor >= 1);
if (atLeast21 && !hasSignals && !hasPartial && fm.type !== 'trekreview') {
errors.push(issue(
'BRIEF_V51_MISSING_SIGNALS',
'brief_version ≥ 2.1 requires phase_signals (or phase_signals_partial: true)',
'Re-run /trekbrief — Phase 3.5 collects per-phase effort + model signals.',
));
}
}
}
if (fm.type !== undefined && !BRIEF_TYPE_VALUES.includes(fm.type)) {
errors.push(issue(
'BRIEF_WRONG_TYPE',

View file

@ -38,7 +38,7 @@ export const PROFILE_REQUIRED_PHASES = Object.freeze([
'brief', 'research', 'plan', 'execute', 'review', 'continue',
]);
const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus']);
export const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus']);
function getAllowedModels(env = process.env) {
if (env.VOYAGE_ALLOW_HAIKU === '1') {

View file

@ -152,3 +152,69 @@ test('validateBrief — wrong-type error message includes accepted set', () => {
assert.ok(/trekbrief/.test(wrongType.message));
assert.ok(/trekreview/.test(wrongType.message));
});
// --- v5.1 — phase_signals additive field + sequencing gate ---
const SIGNALS_BLOCK = `phase_signals:
- phase: research
effort: standard
- phase: plan
effort: high
model: opus
- phase: execute
effort: low
model: sonnet
- phase: review
effort: standard
`;
test('validateBrief — v5.1 well-formed phase_signals accepted', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', `source: interview\n${SIGNALS_BLOCK}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateBrief — pre-v5.1 brief without phase_signals accepted (backward-compat)', () => {
const r = validateBriefContent(GOOD_BRIEF, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
});
test('validateBrief — v5.1+ brief missing phase_signals + partial emits BRIEF_V51_MISSING_SIGNALS', () => {
const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: "2.1"');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
});
test('validateBrief — v5.1+ brief with phase_signals_partial: true accepted', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', 'source: interview\nphase_signals_partial: true\n');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateBrief — phase_signals + phase_signals_partial both set rejected (mutually exclusive)', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', `source: interview\nphase_signals_partial: true\n${SIGNALS_BLOCK}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE'));
});
test('validateBrief — phase_signals with unknown phase rejected', () => {
const BAD_SIGNALS = `phase_signals:
- phase: nonsense
effort: standard
`;
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', `source: interview\n${BAD_SIGNALS}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_INVALID_PHASE_SIGNAL_PHASE'));
});