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:
parent
32621dcb63
commit
2a35d067d9
87 changed files with 225 additions and 4967 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue