diff --git a/plugins/voyage/lib/validators/brief-validator.mjs b/plugins/voyage/lib/validators/brief-validator.mjs index 760ba9f..293ced9 100644 --- a/plugins/voyage/lib/validators/brief-validator.mjs +++ b/plugins/voyage/lib/validators/brief-validator.mjs @@ -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', diff --git a/plugins/voyage/lib/validators/profile-validator.mjs b/plugins/voyage/lib/validators/profile-validator.mjs index 48e84d6..c7fec26 100644 --- a/plugins/voyage/lib/validators/profile-validator.mjs +++ b/plugins/voyage/lib/validators/profile-validator.mjs @@ -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') { diff --git a/plugins/voyage/tests/validators/brief-validator.test.mjs b/plugins/voyage/tests/validators/brief-validator.test.mjs index 6e501d2..a9fd185 100644 --- a/plugins/voyage/tests/validators/brief-validator.test.mjs +++ b/plugins/voyage/tests/validators/brief-validator.test.mjs @@ -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')); +});