feat(voyage): S6 — v5.5 brief framing enforcement (brief_version 2.2)

Implements the CLAUDE.md cross-cutting invariant "brief framing must match
operator intent" as a controlled brief_version 2.1->2.2 bump (operator option A1).
Three defense layers, version-gated at >=2.2 so existing 2.0/2.1 briefs stay
valid (forward + backward compatible), mirroring the phase_signals >=2.1 gate:

- L1 framing: enum field (preserve|refine|replace|new-direction). Enum-checked
  on any version when present (BRIEF_INVALID_FRAMING); missing at >=2.2 ->
  BRIEF_MISSING_FRAMING. /trekbrief Phase 2.5 collects it BEFORE any brief prose
  (non-skippable, even in --quick).
- L2 memory alignment: new brief-reviewer dimension 6 comparing brief Intent/Goal
  + framing against operator memory for explicit contradictions; degrades to
  score 5 (N/A) when no memory context is supplied. Wired into Phase 4e gate
  (memory_alignment.score >= 4).
- L3 obligatory ## TL;DR (<=5 content lines) at >=2.2; soft cap ->
  BRIEF_TLDR_TOO_LONG warning.

trekreview briefs are exempt from the framing/TL;DR gate. Handover 1 PUBLIC
CONTRACT doc, README "What's new", and the CLAUDE.md invariant + agents table
(brief-reviewer 5->6 dimensions) updated to 2.2 (schema axis only; plugin
version badge + CHANGELOG remain S10).

Iron Law followed: validator tests red->green first. Tests 586 -> 606
(+20, 604 pass / 2 skip). claude plugin validate passes (pre-existing
CLAUDE.md root-context warning unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
This commit is contained in:
Kjell Tore Guttormsen 2026-06-18 13:09:50 +02:00
commit 736ae55d66
10 changed files with 409 additions and 31 deletions

View file

@ -18,6 +18,23 @@ export const BRIEF_RESEARCH_STATUS_VALUES = ['pending', 'in_progress', 'complete
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']);
// v5.5 — framing: how this brief relates to prior operator intent (the first layer
// of the framing-alignment defense). Required at brief_version ≥ 2.2.
export const BRIEF_FRAMING_VALUES = Object.freeze(['preserve', 'refine', 'replace', 'new-direction']);
// v5.5 — obligatory TL;DR section (≤ 5 content lines) at the top of brief.md,
// gated at brief_version ≥ 2.2. Soft cap is a warning, not a blocker.
export const BRIEF_TLDR_MAX_LINES = 5;
// Extract the raw text of a `## {heading}` section body (between its heading line
// and the next `## ` heading, or end of document). Returns null if absent.
function extractSection(body, heading) {
const re = new RegExp(`^##\\s+${heading}\\b.*$`, 'm');
const m = re.exec(body);
if (!m) return null;
const after = body.slice(m.index + m[0].length);
const next = after.search(/^##\s/m);
return next === -1 ? after : after.slice(0, next);
}
function getRequiredFields(type) {
return type === 'trekreview' ? REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER : BRIEF_REQUIRED_FRONTMATTER;
@ -88,12 +105,23 @@ export function validateBriefContent(text, opts = {}) {
// a string ("2.1") or a number (2.1). v5.1.0 shipped with an unquoted-2.1 template
// that silently bypassed this gate — fix locked in by quoting the template AND
// accepting both shapes here as defense-in-depth (v5.1.1, finding 3c834097/df1435a2).
// v5.5 — framing enum check fires on ANY version when the field is present but
// malformed. The missing-framing BLOCKER below is version-gated (≥ 2.2).
if ('framing' in fm && !BRIEF_FRAMING_VALUES.includes(fm.framing)) {
errors.push(issue(
'BRIEF_INVALID_FRAMING',
`framing "${fm.framing}" not in [${BRIEF_FRAMING_VALUES.join(', ')}]`,
'framing declares how this brief relates to prior operator intent.',
));
}
if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') {
const vm = String(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);
const atLeast22 = major > 2 || (major === 2 && minor >= 2);
if (atLeast21 && !hasSignals && !hasPartial && fm.type !== 'trekreview') {
errors.push(issue(
'BRIEF_V51_MISSING_SIGNALS',
@ -101,6 +129,29 @@ export function validateBriefContent(text, opts = {}) {
'Re-run /trekbrief — Phase 3.5 collects per-phase effort + model signals.',
));
}
// v5.5 framing enforcement — gated at ≥ 2.2 (trekreview briefs are exempt).
if (atLeast22 && fm.type !== 'trekreview') {
if (!('framing' in fm)) {
errors.push(issue(
'BRIEF_MISSING_FRAMING',
'brief_version ≥ 2.2 requires a framing: field',
`Set framing to one of [${BRIEF_FRAMING_VALUES.join(', ')}] — /trekbrief Phase 2.5 collects it before any brief prose is written.`,
));
}
const tldr = extractSection(body, 'TL;DR');
if (tldr === null) {
const tldrIssue = issue('BRIEF_MISSING_SECTION', 'Required body section missing: ## TL;DR');
if (strict) errors.push(tldrIssue); else warnings.push(tldrIssue);
} else {
const lines = tldr.split('\n').map(l => l.trim()).filter(Boolean);
if (lines.length > BRIEF_TLDR_MAX_LINES) {
warnings.push(issue(
'BRIEF_TLDR_TOO_LONG',
`## TL;DR has ${lines.length} content lines (max ${BRIEF_TLDR_MAX_LINES}) — keep it to a one-glance summary`,
));
}
}
}
}
}