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
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
---
|
||||
|
|
@ -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
|
||||
---
|
||||
|
|
@ -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
|
||||
---
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
---
|
||||
|
|
@ -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).
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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