feat(voyage): add phase_signals validation + sequencing gate to brief-validator (v5.1)
This commit is contained in:
parent
8cbb33e1fd
commit
bf68fe6f5f
3 changed files with 131 additions and 1 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue