chore(cleanup): R-b R3 — profiles, phase signals and the effort dialog removed, one fixed path per command

The four profiles, the resolver, the profile validator and docs/profiles.md go;
/trekbrief Phase 3.5 and the brief's phase_signals checks go (an older brief's
key is ignored); MANIFEST_PROFILE_DRIFT and profile_used go. Every Profile,
Composition rule, Sequencing gate and High-effort section leaves the commands.
Decision 209: /trekresearch runs the full swarm with contrarian-researcher as
wave 2 every run; /trekreview and the coordinator run all four passes with no
high-effort normalization. Voyage agents are spawned with no model (their own
model: opus applies); the two built-in spawns pass model "opus". --profile
stays in the parser as a retired flag that swallows its value. §Model & effort
moves to docs/architecture.md; the sequencing-gate eval case goes.

Files removed: 44 (43 from the R3 list + tests/commands/trekreview.test.mjs,
whose every test pinned removed text). Files edited: 43.
Tests: 2467 (2466 pass / 0 fail / 1 skipped) before, 2309 (2309 pass / 0 fail /
0 skipped) after. Durable check: all three groups green, none pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-07 21:12:57 +01:00
commit 2a35d067d9
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
87 changed files with 225 additions and 4967 deletions

View file

@ -13,6 +13,7 @@
import { readFileSync } from 'node:fs';
import { isMain } from '../util/is-main.mjs';
// --profile: retired in cleanup R-b: the value is consumed and ignored.
const FLAG_SCHEMA = {
trekbrief: {
boolean: ['--quick', '--fg'],

View file

@ -32,15 +32,10 @@ const OPTIONAL_KEYS = [
const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS);
// Optional string-typed manifest keys (v4.1 Step 3 — additive forward-compat).
// `profile_used`: name of the model profile (economy|balanced|premium|fable|<custom>) the
// step was executed under. Absence is fine (v4.0 manifests have no
// profile concept); presence MUST be a string.
// Unlike OPTIONAL_BOOLEAN_KEYS, absence is NOT defaulted — the field is simply
// missing from `parsed` so downstream consumers can distinguish "not set" from
// "explicitly empty string".
const OPTIONAL_STRING_KEYS = [
'profile_used',
];
const OPTIONAL_STRING_KEYS = [];
const OPTIONAL_STRING_KEYS_SET = new Set(OPTIONAL_STRING_KEYS);

View file

@ -1,70 +0,0 @@
// lib/parsers/profile-jaccard.mjs
// String-normalisering helper for cross-tier Jaccard smoke-test (Step 18).
//
// Plan steps from different model tiers (sonnet vs opus) often differ
// only in punctuation, casing, or trivial wording (`logger.info` vs
// "logger.info" vs `logger info`). To avoid trivial false-negatives in
// cross-tier Jaccard, every step title passes through `normalizeStep`
// before set membership is computed.
//
// Normalisering rules (per research/02 §3.4):
// 1. Lowercase the entire string.
// 2. Strip backticks and parentheses (`...` and (...)).
// 3. Collapse runs of whitespace to a single space.
// 4. Trim leading + trailing whitespace.
//
// We do NOT stem or lemmatize — that would over-normalize and mask real
// disagreement (e.g. "Add tests for X" vs "Verify tests for X" should
// remain distinct).
/**
* Normalize a single step-title string.
* @param {unknown} step
* @returns {string}
*/
export function normalizeStep(step) {
if (typeof step !== 'string') return '';
return step
.toLowerCase()
.replace(/[`()]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Normalize an array of step titles.
* @param {string[]} steps
* @returns {string[]}
*/
export function normalizeSteps(steps) {
if (!Array.isArray(steps)) return [];
return steps.map(normalizeStep).filter((s) => s.length > 0);
}
/**
* Verify step-count parity within a tolerance band.
* @param {string[]} stepsA
* @param {string[]} stepsB
* @param {number} tolerance fraction (default 0.20 = ±20%)
* @returns {{ok: boolean, ratio: number, message: string}}
*/
export function checkStepCountParity(stepsA, stepsB, tolerance = 0.2) {
const a = Array.isArray(stepsA) ? stepsA.length : 0;
const b = Array.isArray(stepsB) ? stepsB.length : 0;
if (a === 0 || b === 0) {
return {
ok: false,
ratio: 0,
message: `step-count parity failed: empty input (a=${a}, b=${b})`,
};
}
const ratio = Math.abs(a - b) / Math.max(a, b);
return {
ok: ratio <= tolerance,
ratio,
message:
ratio <= tolerance
? `step-count parity OK (a=${a}, b=${b}, ratio=${ratio.toFixed(3)})`
: `step-count parity exceeded ${tolerance}: a=${a}, b=${b}, ratio=${ratio.toFixed(3)}`,
};
}

View file

@ -1,21 +0,0 @@
---
profile_version: "1.0"
name: balanced
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 4
parallel_agents_max: 6
external_research_enabled: false
brief_reviewer_iter_cap: 2
---

View file

@ -1,26 +0,0 @@
---
profile_version: "1.0"
name: economy
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: sonnet
- phase: execute
model: sonnet
- phase: review
model: sonnet
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 3
external_research_enabled: false
brief_reviewer_iter_cap: 1
# Experimental: the cross-tier Jaccard floor (0.55) rests on parked-synthetic
# fixtures — empirical Step-17 calibration is deferred to v4.2. Drop this flag
# in the SAME change that lands calibration (status: empirical). See
# tests/synthetic/profile-jaccard-calibration.md and docs/profiles.md.
experimental: true
---

View file

@ -1,21 +0,0 @@
---
profile_version: "1.0"
name: fable
phase_models:
- phase: brief
model: fable
- phase: research
model: fable
- phase: plan
model: fable
- phase: execute
model: fable
- phase: review
model: fable
- phase: continue
model: fable
parallel_agents_min: 6
parallel_agents_max: 8
external_research_enabled: true
brief_reviewer_iter_cap: 3
---

View file

@ -1,99 +0,0 @@
// 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';
import { BASE_ALLOWED_MODELS } from '../validators/profile-validator.mjs';
import { isMain } from '../util/is-main.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.
* `effort` is gated against EFFORT_LEVELS and `model` against BASE_ALLOWED_MODELS;
* values outside those allowlists are dropped (treated as absent).
*/
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;
// MAJOR fix (S4): gate `model` against BASE_ALLOWED_MODELS, mirroring the
// effort gate above. brief-validator already rejects out-of-allowlist
// models at validation time; this is defense-in-depth so a brief that
// slipped validation cannot hand a junk model to an agent spawn.
if ('model' in entry && BASE_ALLOWED_MODELS.includes(entry.model)) 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.
// Footgun guard (v5.9): this shim's `model` output is brief-signal-only — it
// never consults the profile layer. For command wiring, the composed resolver
// CLI (`resolver.mjs --resolve-phase-model`, brief > profile > default) is the
// single resolution source for {effort, model}. Do not re-wire commands/*.md
// Bash blocks back to this shim.
if (isMain(import.meta.url)) {
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);
}

View file

@ -1,21 +0,0 @@
---
profile_version: "1.0"
name: premium
phase_models:
- phase: brief
model: opus
- phase: research
model: opus
- phase: plan
model: opus
- phase: execute
model: opus
- phase: review
model: opus
- phase: continue
model: opus
parallel_agents_min: 6
parallel_agents_max: 8
external_research_enabled: true
brief_reviewer_iter_cap: 3
---

View file

@ -1,324 +0,0 @@
// lib/profiles/resolver.mjs
// Profile resolution layer (v4.1 SC #5-#9; v5.1.1 brief-signal extension).
//
// Locked interface contract (per brief Preferences):
// loadProfile(name) → ProfileObject
// - Reads lib/profiles/<name>.yaml or custom voyage-profiles/<name>.yaml.
// - Throws Error (cause: PROFILE_NOT_FOUND) when not found.
// - Returns parsed object with phase_models flattened to {brief: 'sonnet', ...}
// (object form for downstream JSON-stats; conversion from YAML list-of-dicts).
//
// resolveProfile(argv, env) → {profile, profile_source}
// - Resolution order: --profile flag > VOYAGE_PROFILE env > 'premium' default.
// - profile_source: 'flag' | 'env' | 'default'.
//
// resolveTrekcontinueProfile(planPath, argv) → {profile, profile_source}
// - --profile flag in argv wins with 'flag'.
// - Otherwise reads plan.md frontmatter via parseDocument; returns
// plan-frontmatter `profile` field with 'inheritance'.
// - If flag overrides inheritance, console.error emits an advisory.
// - For v4.0-style plans without `profile:` field, returns 'default' premium.
//
// validateProfileFile(path) → Result
// - Thin wrapper around validateProfile from profile-validator.mjs.
//
// resolvePhaseModel(phase, briefPath, argv, env)
// → {model, source} (v5.1.1 ADDITIVE)
// - Highest-priority lookup step: brief.phase_signals[phase].model (source='brief-signal').
// - Falls through via resolveProfile (flag → env → default) when brief signal absent.
// - Never throws; ENOENT/parse failures degrade silently to the next step.
// - phase ∈ {brief, continue} (outside PHASE_SIGNAL_PHASES) always falls through.
// - Controls ORCHESTRATOR model only. Sub-agents read agents/*.md frontmatter
// directly; commands must inject {resolved model} into Agent-tool spawn sites
// for sub-agents to honor brief signals. See feedback_voyage_opus_always.md.
//
// Custom.yaml lookup order: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.yaml
// Both attempted paths included in error message on miss (HIGH-risk-mitigering).
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { parseDocument } from '../util/frontmatter.mjs';
import { validateProfile } from '../validators/profile-validator.mjs';
import { resolvePhaseSignal } from './phase-signal-resolver.mjs';
import { isMain } from '../util/is-main.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BUILTIN_PROFILES_DIR = __dirname; // lib/profiles/
const BUILTIN_NAMES = new Set(['economy', 'balanced', 'premium', 'fable']);
/**
* Resolve the path to a profile file.
* Built-in profiles: lib/profiles/<name>.yaml
* Custom profiles: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.yaml
*
* @returns {{path: string|null, attempted: string[]}}
*/
export function findProfilePath(name, opts = {}) {
const cwd = opts.cwd || process.cwd();
const home = opts.home || homedir();
const attempted = [];
if (BUILTIN_NAMES.has(name)) {
const builtinPath = join(BUILTIN_PROFILES_DIR, `${name}.yaml`);
attempted.push(builtinPath);
if (existsSync(builtinPath)) return { path: builtinPath, attempted };
}
// Custom: repo-root first, then home
const repoCustom = join(cwd, 'voyage-profiles', `${name}.yaml`);
attempted.push(repoCustom);
if (existsSync(repoCustom)) return { path: repoCustom, attempted };
const homeCustom = join(home, '.claude', 'voyage-profiles', `${name}.yaml`);
attempted.push(homeCustom);
if (existsSync(homeCustom)) return { path: homeCustom, attempted };
return { path: null, attempted };
}
/**
* Flatten phase_models list-of-dicts to object form: {brief: 'sonnet', research: 'opus', ...}
*/
function flattenPhaseModels(list) {
const out = {};
if (!Array.isArray(list)) return out;
for (const entry of list) {
if (entry && typeof entry === 'object' && typeof entry.phase === 'string' && typeof entry.model === 'string') {
out[entry.phase] = entry.model;
}
}
return out;
}
/**
* Load and parse a profile file by name.
* @param {string} name
* @param {{cwd?: string, home?: string}} [opts]
* @returns {{name: string, profile_version: string, phase_models: object, parallel_agents_min: number, parallel_agents_max: number, external_research_enabled: boolean, brief_reviewer_iter_cap: number, _path: string}}
* @throws {Error} cause: PROFILE_NOT_FOUND | PROFILE_PARSE_ERROR
*/
export function loadProfile(name, opts = {}) {
const { path, attempted } = findProfilePath(name, opts);
if (!path) {
const err = new Error(
`Profile "${name}" not found. Attempted paths:\n - ${attempted.join('\n - ')}`,
);
err.cause = 'PROFILE_NOT_FOUND';
err.attempted = attempted;
throw err;
}
let text;
try { text = readFileSync(path, 'utf-8'); }
catch (e) {
const err = new Error(`Cannot read profile "${name}" at ${path}: ${e.message}`);
err.cause = 'PROFILE_READ_ERROR';
throw err;
}
const doc = parseDocument(text);
if (!doc.valid) {
const err = new Error(`Profile "${name}" parse error: ${doc.errors[0]?.message || 'unknown'}`);
err.cause = 'PROFILE_PARSE_ERROR';
throw err;
}
const fm = doc.parsed.frontmatter || {};
return {
name: fm.name,
profile_version: fm.profile_version,
phase_models: flattenPhaseModels(fm.phase_models),
parallel_agents_min: fm.parallel_agents_min,
parallel_agents_max: fm.parallel_agents_max,
external_research_enabled: fm.external_research_enabled,
brief_reviewer_iter_cap: fm.brief_reviewer_iter_cap,
_path: path,
};
}
/**
* Resolve profile name from argv + env + default.
* Order: --profile flag > VOYAGE_PROFILE env > 'premium'.
*
* @param {{flags?: object} | string[]} argv Either parsed argv or a flags-object.
* @param {object} [env]
* @returns {{profile: string, profile_source: 'flag'|'env'|'default'}}
*/
export function resolveProfile(argv, env = process.env) {
const flags = (argv && typeof argv === 'object' && argv.flags) ? argv.flags : (argv || {});
if (typeof flags['--profile'] === 'string' && flags['--profile'].length > 0) {
return { profile: flags['--profile'], profile_source: 'flag' };
}
if (typeof env.VOYAGE_PROFILE === 'string' && env.VOYAGE_PROFILE.length > 0) {
return { profile: env.VOYAGE_PROFILE, profile_source: 'env' };
}
return { profile: 'premium', profile_source: 'default' };
}
/**
* Resolve profile for /trekcontinue: prefers explicit flag, falls back to plan
* frontmatter (inheritance), then 'premium' default if plan has no profile field.
*
* @param {string} planPath Path to plan.md
* @param {{flags?: object} | string[]} argv
* @param {{env?: object, console?: Console}} [opts]
* @returns {{profile: string, profile_source: 'flag'|'inheritance'|'default'}}
*/
export function resolveTrekcontinueProfile(planPath, argv, opts = {}) {
const env = opts.env || process.env;
const con = opts.console || console;
const flags = (argv && typeof argv === 'object' && argv.flags) ? argv.flags : (argv || {});
const flagProfile = (typeof flags['--profile'] === 'string' && flags['--profile'].length > 0)
? flags['--profile'] : null;
// Read plan-frontmatter to detect inheritance
let planProfile = null;
if (planPath && existsSync(planPath)) {
try {
const text = readFileSync(planPath, 'utf-8');
const doc = parseDocument(text);
if (doc.valid) {
const fm = doc.parsed.frontmatter || {};
if (typeof fm.profile === 'string' && fm.profile.length > 0) {
planProfile = fm.profile;
}
}
} catch {
// swallow — degrades gracefully to default
}
}
if (flagProfile) {
if (planProfile && planProfile !== flagProfile) {
con.error(`[voyage] profile inheritance overridden by --profile flag: ${planProfile} → ${flagProfile}`);
}
return { profile: flagProfile, profile_source: 'flag' };
}
if (planProfile) {
return { profile: planProfile, profile_source: 'inheritance' };
}
// v4.0-style plan without profile: default to premium
return { profile: 'premium', profile_source: 'default' };
}
/**
* Validate a profile YAML file.
* Thin wrapper for locked-interface compatibility.
*/
export function validateProfileFile(path, opts = {}) {
return validateProfile(path, opts);
}
/**
* Resolve the model for a specific pipeline phase, applying brief-signal override
* as the new highest-priority lookup step (v5.1.1).
*
* @param {string} phase One of brief|research|plan|execute|review|continue
* @param {string|null} briefPath Absolute or repo-relative path to brief.md, or null
* @param {string[]|object} argv Full process.argv array OR parsed flags object
* @param {object} [env] Environment-variable record (defaults to process.env)
* @returns {{effort?: string, model: string, source: 'brief-signal'|'flag'|'env'|'default'}}
*
* `effort` (v5.9 ADDITIVE) is the brief signal's effort passed through when
* present — so commands consume ONE coherent {effort, model, source} result
* instead of two split CLI calls. Absent when the brief carries no valid
* effort signal for the phase (commands default to 'standard' per the
* composition rule).
*
* Error handling contract:
* - Never throws. Any failure (ENOENT on briefPath, malformed YAML, missing
* phase_signals entry, missing model field) silently falls through.
* - Treats unreadable briefPath as equivalent to briefPath=null.
* - Returns the fallthrough resolution when phase ∈ {brief, continue} (not in
* PHASE_SIGNAL_PHASES).
*
* Controls ORCHESTRATOR model only. Sub-agents read agents/*.md frontmatter
* directly; commands must inject {resolved model} at Agent-tool spawn sites.
*/
export function resolvePhaseModel(phase, briefPath, argv, env = process.env) {
// Step 1: brief-signal lookup. `effort` is captured independently of `model`
// so a signal like {effort: high} (no model) still passes effort through
// while the model falls to the profile layer.
let effort;
if (typeof briefPath === 'string' && briefPath.length > 0 && existsSync(briefPath)) {
let fm = null;
try {
const text = readFileSync(briefPath, 'utf-8');
const doc = parseDocument(text);
if (doc && doc.valid) fm = doc.parsed && doc.parsed.frontmatter;
} catch {
// swallow — degrade gracefully to fallthrough
}
if (fm) {
const signal = resolvePhaseSignal(fm, phase);
if (signal && typeof signal.effort === 'string') effort = signal.effort;
if (signal && typeof signal.model === 'string' && signal.model.length > 0) {
return effort !== undefined
? { effort, model: signal.model, source: 'brief-signal' }
: { model: signal.model, source: 'brief-signal' };
}
}
}
// Step 2+3+4: fall through via existing resolveProfile chain
// (flag → env → default), then index into profile.phase_models[phase].
// Normalize argv: resolveProfile expects flags-object form. When called with
// a raw process.argv-style array, extract --profile here.
let normalizedFlags = argv;
if (Array.isArray(argv)) {
const idx = argv.indexOf('--profile');
normalizedFlags = (idx >= 0 && idx + 1 < argv.length)
? { '--profile': argv[idx + 1] }
: {};
}
const { profile, profile_source } = resolveProfile(normalizedFlags, env);
let phaseModels = {};
try {
const p = loadProfile(profile);
phaseModels = p.phase_models || {};
} catch {
// Unknown profile → fall back to premium
try {
const p = loadProfile('premium');
phaseModels = p.phase_models || {};
} catch {
phaseModels = {};
}
}
const model = phaseModels[phase] || 'opus';
return effort !== undefined
? { effort, model, source: profile_source }
: { model, source: profile_source };
}
// CLI shim — invoked by commands/trek*.md via Bash.
// Usage: node lib/profiles/resolver.mjs --resolve-phase-model --phase plan --brief-path foo.md --json
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
if (args.includes('--resolve-phase-model')) {
const getArg = (name) => {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
};
const phase = getArg('--phase');
const briefPath = getArg('--brief-path');
if (!phase) {
process.stderr.write('Usage: resolver.mjs --resolve-phase-model --phase <name> [--brief-path <path>] [--json]\n');
process.exit(2);
}
const r = resolvePhaseModel(phase, briefPath || null, args, process.env);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify(r) + '\n');
} else {
const effort = 'effort' in r ? ` effort=${r.effort}` : '';
process.stdout.write(`model=${r.model} source=${r.source}${effort}\n`);
}
process.exit(0);
}
// Otherwise no-op (the module is primarily imported, not run directly).
}

View file

@ -520,8 +520,7 @@ export function judgeFilter(findings) {
*
* `no-citation` REFUTES (the finding names no location, so it makes no
* checkable claim) and is dropped. `unknown-rule_key` does not: an ad-hoc key
* is a real defect wearing the wrong label — v5.1.1 high-effort mode already
* KEEPS these, normalised to PLAN_EXECUTE_DRIFT — so it routes to `unverified`.
* is a real defect wearing the wrong label, so it routes to `unverified`.
*
* The brief rule runs here too: a finding `lowerableBriefConflict` accepts is
* set to MINOR with `brief_lowered_from: 'MAJOR'`; every other finding gets

View file

@ -9,7 +9,6 @@
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';
import { parseSuccessCriteria, runnableCheck, firstCommand } from '../verification/criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
@ -18,8 +17,6 @@ export const REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER = ['type', 'task', 'slug', 'pr
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']);
// 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']);
@ -77,55 +74,6 @@ 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.
// Coerce to String so the gate fires regardless of whether YAML parsed the value as
// 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)) {
@ -156,15 +104,7 @@ export function validateBriefContent(text, opts = {}) {
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',
'brief_version ≥ 2.1 requires phase_signals (or phase_signals_partial: true)',
'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)) {

View file

@ -58,7 +58,6 @@ import {
testFirstFileEntries, testFirstFiles,
} from '../parsers/plan-schema.mjs';
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, fail } from '../util/result.mjs';
import { parsePlanVerification, firstCommand, runnableCheck, refusal } from '../verification/criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
@ -426,29 +425,6 @@ export function validatePlanContent(rawText, opts = {}) {
else warnings.push(...v18);
}
// v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only.
// If plan frontmatter declares `profile: <name>` and any step manifest
// declares `profile_used: <other>`, emit a warning (NOT an error) so
// operators see drift but parsing remains forward-compat.
if (strict) {
const planFm = parseDocument(text).parsed?.frontmatter;
const planProfile =
planFm && typeof planFm.profile === 'string' ? planFm.profile : null;
if (planProfile) {
for (const m of manRes.parsed) {
const stepProfile = m.manifest && m.manifest.profile_used;
if (typeof stepProfile === 'string' && stepProfile !== planProfile) {
warnings.push(issue(
'MANIFEST_PROFILE_DRIFT',
`step ${m.n}: profile_used = ${stepProfile}, plan profile = ${planProfile}`,
'A step manifest declares a different profile than the plan frontmatter; ' +
'verify whether this is intentional (manual override) or accidental drift.',
));
}
}
}
}
return {
valid: errors.length === 0,
errors,

View file

@ -1,195 +0,0 @@
// lib/validators/profile-validator.mjs
// Validate model-profile YAML files (lib/profiles/*.yaml + custom voyage-profiles/*.yaml).
//
// Profile schema (v4.1, profile_version 1.0):
// name : string
// phase_models : list-of-dicts [{phase: string, model: string}, ...]
// 6 entries required (brief, research, plan, execute, review, continue)
// parallel_agents_min : number (≥ 1)
// parallel_agents_max : number (≥ parallel_agents_min)
// external_research_enabled : boolean
// brief_reviewer_iter_cap : number (≥ 1)
// experimental : boolean (OPTIONAL) — true marks a tier whose
// gating constants are not yet empirically
// calibrated (e.g. economy's parked-synthetic
// cross-tier Jaccard floor). Absent ⇒ stable.
//
// Issue codes:
// PROFILE_MISSING_FIELD — required top-level frontmatter field absent
// PROFILE_INVALID_MODEL — phase_models[N].model not in allowlist
// PROFILE_INVALID_ENUM — wrong-type value (e.g. external_research_enabled is string)
// PROFILE_READ_ERROR — file unreadable or parse-error
// PROFILE_NOT_FOUND — file does not exist
//
// Allowed model values: ['sonnet', 'opus', 'fable']. Haiku is allowed only when
// VOYAGE_ALLOW_HAIKU=1 (the model-choice rule: Haiku is never a default, only
// an explicit opt-in for special use).
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
export const PROFILE_REQUIRED_FIELDS = Object.freeze([
'name',
'phase_models',
'parallel_agents_min',
'parallel_agents_max',
'external_research_enabled',
'brief_reviewer_iter_cap',
]);
export const PROFILE_REQUIRED_PHASES = Object.freeze([
'brief', 'research', 'plan', 'execute', 'review', 'continue',
]);
export const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus', 'fable']);
function getAllowedModels(env = process.env) {
if (env.VOYAGE_ALLOW_HAIKU === '1') {
return [...BASE_ALLOWED_MODELS, 'haiku'];
}
return BASE_ALLOWED_MODELS;
}
export function validateProfileContent(text, opts = {}) {
const env = opts.env || process.env;
const allowedModels = getAllowedModels(env);
const doc = parseDocument(text);
if (!doc.valid) {
return fail(issue('PROFILE_READ_ERROR', `Frontmatter parse error: ${doc.errors[0]?.message || 'unknown'}`));
}
const fm = doc.parsed.frontmatter || {};
const errors = [];
const warnings = [];
// Required field presence
for (const k of PROFILE_REQUIRED_FIELDS) {
if (!(k in fm)) {
errors.push(issue('PROFILE_MISSING_FIELD', `Required profile field missing: ${k}`));
}
}
// Type checks for scalar fields
if ('name' in fm && typeof fm.name !== 'string') {
errors.push(issue('PROFILE_INVALID_ENUM', `name must be string (got ${typeof fm.name})`));
}
if ('parallel_agents_min' in fm && typeof fm.parallel_agents_min !== 'number') {
errors.push(issue('PROFILE_INVALID_ENUM',
`parallel_agents_min must be number (got ${typeof fm.parallel_agents_min})`));
}
if ('parallel_agents_max' in fm && typeof fm.parallel_agents_max !== 'number') {
errors.push(issue('PROFILE_INVALID_ENUM',
`parallel_agents_max must be number (got ${typeof fm.parallel_agents_max})`));
}
if (typeof fm.parallel_agents_min === 'number' && typeof fm.parallel_agents_max === 'number') {
if (fm.parallel_agents_max < fm.parallel_agents_min) {
errors.push(issue('PROFILE_INVALID_ENUM',
`parallel_agents_max (${fm.parallel_agents_max}) < parallel_agents_min (${fm.parallel_agents_min})`));
}
}
if ('external_research_enabled' in fm && typeof fm.external_research_enabled !== 'boolean') {
errors.push(issue('PROFILE_INVALID_ENUM',
`external_research_enabled must be boolean (got ${typeof fm.external_research_enabled})`));
}
if ('brief_reviewer_iter_cap' in fm && typeof fm.brief_reviewer_iter_cap !== 'number') {
errors.push(issue('PROFILE_INVALID_ENUM',
`brief_reviewer_iter_cap must be number (got ${typeof fm.brief_reviewer_iter_cap})`));
}
// Optional field: `experimental` (boolean). Absent ⇒ tier is stable.
if ('experimental' in fm && typeof fm.experimental !== 'boolean') {
errors.push(issue('PROFILE_INVALID_ENUM',
`experimental must be boolean (got ${typeof fm.experimental})`));
}
// phase_models validation
if ('phase_models' in fm) {
if (!Array.isArray(fm.phase_models)) {
errors.push(issue('PROFILE_INVALID_ENUM', `phase_models must be a list-of-dicts (got ${typeof fm.phase_models})`));
} else {
const seenPhases = new Set();
for (let i = 0; i < fm.phase_models.length; i++) {
const entry = fm.phase_models[i];
if (!entry || typeof entry !== 'object') {
errors.push(issue('PROFILE_INVALID_ENUM',
`phase_models[${i}] must be a dict {phase, model} (got ${typeof entry})`,
undefined,
`phase_models[${i}]`));
continue;
}
if (typeof entry.phase !== 'string') {
errors.push(issue('PROFILE_INVALID_ENUM',
`phase_models[${i}].phase must be string`,
undefined,
`phase_models[${i}].phase`));
} else {
seenPhases.add(entry.phase);
if (!PROFILE_REQUIRED_PHASES.includes(entry.phase)) {
errors.push(issue('PROFILE_INVALID_ENUM',
`phase_models[${i}].phase "${entry.phase}" not in [${PROFILE_REQUIRED_PHASES.join(', ')}]`,
undefined,
`phase_models[${i}].phase`));
}
}
if (typeof entry.model !== 'string') {
errors.push(issue('PROFILE_INVALID_MODEL',
`phase_models[${i}].model must be string`,
undefined,
`phase_models[${i}].model`));
} else if (!allowedModels.includes(entry.model)) {
errors.push(issue('PROFILE_INVALID_MODEL',
`phase_models[${i}].model "${entry.model}" not in [${allowedModels.join(', ')}]` +
(entry.model === 'haiku' ? ' (set VOYAGE_ALLOW_HAIKU=1 to allow)' : ''),
undefined,
`phase_models[${i}].model`));
}
}
// All 6 required phases must be present
for (const required of PROFILE_REQUIRED_PHASES) {
if (!seenPhases.has(required)) {
errors.push(issue('PROFILE_MISSING_FIELD',
`phase_models missing required phase entry: ${required}`));
}
}
}
}
return { valid: errors.length === 0, errors, warnings, parsed: { frontmatter: fm } };
}
export function validateProfile(filePath, opts = {}) {
if (!existsSync(filePath)) {
return fail(issue('PROFILE_NOT_FOUND', `Profile file not found: ${filePath}`));
}
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) { return fail(issue('PROFILE_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
const r = validateProfileContent(text, opts);
return { ...r, parsed: { ...(r.parsed || {}), filePath } };
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: profile-validator.mjs [--json] <profile.yaml>\n');
process.exit(2);
}
const r = validateProfile(filePath);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`profile-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}${e.location ? ' at ' + e.location : ''}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}