voyage/lib/profiles/phase-signal-resolver.mjs
Kjell Tore Guttormsen fdd3ad80d7 feat(voyage): W2 impl (S4) — gate resolver model, adopt native effort:, doc-truth
S4 of the 2.1.181 upgrade — implementation, not a gate. TDD: failing test
written first for the resolver gate, then the fix; suite green throughout.

- Resolver MAJOR (FIX): lib/profiles/phase-signal-resolver.mjs now imports
  BASE_ALLOWED_MODELS from profile-validator and gates `model`
  (if 'model' in entry && BASE_ALLOWED_MODELS.includes(entry.model)),
  mirroring the EFFORT_LEVELS gate one line up. Out-of-allowlist models
  (gpt-4, haiku) are dropped instead of handed to an agent spawn —
  defense-in-depth behind brief-validator's validation-time check. No
  circular import (brief-validator already imports the same symbol).
  +2 tests (drops-invalid / keeps-valid).
- Native effort: (SHIP, static additive): effort: frontmatter on 8 agents —
  retrieval (task-finder, git-historian, dependency-tracer,
  architecture-mapper) = medium; adversarial-reasoning (plan-critic,
  risk-assessor, contrarian-researcher, review-coordinator) = high. The
  other 15 stay unset -> inherit Opus-4.8 default (high). This per-spawn
  REASONING effort is a different axis from brief phase_signals.effort
  (ORCHESTRATION shape) per the S3 decision.
- Doc-truth + axis distinction: new canonical docs/profiles.md
  §Model & effort axes (opus->Opus 4.8 default-high; orchestration vs
  reasoning effort table; native-effort precedence; per-agent levels).
  Short notes in CLAUDE.md (after Agents table) and README.md (Cost
  profile), both pointing to profiles.md.
- Open (non-blocking, unchanged): only STATIC effort shipped — the
  verified-safe minimum. Profile-driven DYNAMIC effort still needs
  verification of the per-spawn effort param or env-var injection.

Matrix: new "S4 resolutions" section. Tests 582 total / 580 pass / 0 fail /
2 skip (was 578 pass; +2). claude plugin validate passes (only pre-existing
root-CLAUDE.md warning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
2026-06-18 12:27:07 +02:00

93 lines
4 KiB
JavaScript

// 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';
/**
* 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.
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 <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);
}