// 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 --phase --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'; /** * 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. */ 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; if ('model' in entry) 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. if (import.meta.url === `file://${process.argv[1]}`) { 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 --phase [--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); }