diff --git a/lib/parsers/manifest-yaml.mjs b/lib/parsers/manifest-yaml.mjs index c5a3903..93d386d 100644 --- a/lib/parsers/manifest-yaml.mjs +++ b/lib/parsers/manifest-yaml.mjs @@ -32,7 +32,7 @@ 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|) the +// `profile_used`: name of the model profile (economy|balanced|premium|fable|) 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 diff --git a/lib/profiles/fable.yaml b/lib/profiles/fable.yaml new file mode 100644 index 0000000..8d69b18 --- /dev/null +++ b/lib/profiles/fable.yaml @@ -0,0 +1,21 @@ +--- +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 +--- diff --git a/lib/profiles/phase-signal-resolver.mjs b/lib/profiles/phase-signal-resolver.mjs index 965e560..0292c71 100644 --- a/lib/profiles/phase-signal-resolver.mjs +++ b/lib/profiles/phase-signal-resolver.mjs @@ -67,6 +67,11 @@ export function resolvePhaseSignalFromFile(briefPath, 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 (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); const getArg = (name) => { diff --git a/lib/profiles/resolver.mjs b/lib/profiles/resolver.mjs index 3d17bc1..b2ec44b 100644 --- a/lib/profiles/resolver.mjs +++ b/lib/profiles/resolver.mjs @@ -45,7 +45,7 @@ import { resolvePhaseSignal } from './phase-signal-resolver.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const BUILTIN_PROFILES_DIR = __dirname; // lib/profiles/ -const BUILTIN_NAMES = new Set(['economy', 'balanced', 'premium']); +const BUILTIN_NAMES = new Set(['economy', 'balanced', 'premium', 'fable']); /** * Resolve the path to a profile file. @@ -221,7 +221,13 @@ export function validateProfileFile(path, opts = {}) { * @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 {{model: string, source: 'brief-signal'|'flag'|'env'|'default'}} + * @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 @@ -234,7 +240,10 @@ export function validateProfileFile(path, opts = {}) { * 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 + // 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 { @@ -246,8 +255,11 @@ export function resolvePhaseModel(phase, briefPath, argv, env = process.env) { } 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 { model: signal.model, source: 'brief-signal' }; + return effort !== undefined + ? { effort, model: signal.model, source: 'brief-signal' } + : { model: signal.model, source: 'brief-signal' }; } } } @@ -278,7 +290,9 @@ export function resolvePhaseModel(phase, briefPath, argv, env = process.env) { } } const model = phaseModels[phase] || 'opus'; - return { model, source: profile_source }; + return effort !== undefined + ? { effort, model, source: profile_source } + : { model, source: profile_source }; } // CLI shim — invoked by commands/trek*.md via Bash. @@ -300,7 +314,8 @@ if (import.meta.url === `file://${process.argv[1]}`) { if (args.includes('--json')) { process.stdout.write(JSON.stringify(r) + '\n'); } else { - process.stdout.write(`model=${r.model} source=${r.source}\n`); + const effort = 'effort' in r ? ` effort=${r.effort}` : ''; + process.stdout.write(`model=${r.model} source=${r.source}${effort}\n`); } process.exit(0); } diff --git a/tests/validators/profile-validator.test.mjs b/tests/validators/profile-validator.test.mjs index 7cd3fec..f86d8a0 100644 --- a/tests/validators/profile-validator.test.mjs +++ b/tests/validators/profile-validator.test.mjs @@ -1,5 +1,5 @@ // tests/validators/profile-validator.test.mjs -// SC #1, #2, #3: profile-validator validates lib/profiles/{economy,balanced,premium}.yaml +// SC #1, #2, #3: profile-validator validates lib/profiles/{economy,balanced,premium,fable}.yaml // (innebygde profiler) plus rejects invalid models and invalid enum types. import { test } from 'node:test'; @@ -18,9 +18,9 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..', '..'); -// SC #1: alle 3 innebygde profiler grønne +// SC #1: alle 4 innebygde profiler grønne -for (const profileName of ['economy', 'balanced', 'premium']) { +for (const profileName of ['economy', 'balanced', 'premium', 'fable']) { test(`SC #1: lib/profiles/${profileName}.yaml validates clean`, () => { const r = validateProfile(join(REPO_ROOT, 'lib', 'profiles', `${profileName}.yaml`)); assert.equal(r.valid, true,