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
110 lines
4.6 KiB
JavaScript
110 lines
4.6 KiB
JavaScript
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { resolvePhaseSignal, resolvePhaseSignalFromFile } from '../../lib/profiles/phase-signal-resolver.mjs';
|
|
|
|
const FULL_SIGNALS_FM = {
|
|
phase_signals: [
|
|
{ phase: 'research', effort: 'low', model: 'sonnet' },
|
|
{ phase: 'plan', effort: 'standard' },
|
|
{ phase: 'execute', effort: 'high', model: 'opus' },
|
|
{ phase: 'review', effort: 'standard', model: 'sonnet' },
|
|
],
|
|
};
|
|
|
|
test('resolvePhaseSignal — returns {effort, model} for all 4 phases on full-signals brief', () => {
|
|
for (const phase of ['research', 'plan', 'execute', 'review']) {
|
|
const r = resolvePhaseSignal(FULL_SIGNALS_FM, phase);
|
|
assert.ok(r && typeof r === 'object', `phase=${phase} should resolve non-null`);
|
|
assert.ok(typeof r.effort === 'string', `phase=${phase} should have effort`);
|
|
}
|
|
});
|
|
|
|
test('resolvePhaseSignal — returns null when brief has no phase_signals', () => {
|
|
const r = resolvePhaseSignal({ task: 'x' }, 'plan');
|
|
assert.equal(r, null);
|
|
});
|
|
|
|
test('resolvePhaseSignal — returns partial {effort} with model undefined when signal omits model', () => {
|
|
const r = resolvePhaseSignal(FULL_SIGNALS_FM, 'plan');
|
|
assert.equal(r.effort, 'standard');
|
|
assert.equal(r.model, undefined);
|
|
assert.ok(!('model' in r), 'model key should be absent when not in signal');
|
|
});
|
|
|
|
test('resolvePhaseSignal — returns null when phase is not in PHASE_SIGNAL_PHASES', () => {
|
|
assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'brief'), null);
|
|
assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'continue'), null);
|
|
assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'nonsense'), null);
|
|
});
|
|
|
|
test('resolvePhaseSignal — defensive: null/non-object input returns null', () => {
|
|
assert.equal(resolvePhaseSignal(null, 'plan'), null);
|
|
assert.equal(resolvePhaseSignal(undefined, 'plan'), null);
|
|
assert.equal(resolvePhaseSignal('string', 'plan'), null);
|
|
assert.equal(resolvePhaseSignal({ phase_signals: 'not-array' }, 'plan'), null);
|
|
});
|
|
|
|
test('resolvePhaseSignal — drops model not in BASE_ALLOWED_MODELS (defense-in-depth gate)', () => {
|
|
// MAJOR fix (S4): line that copies `model` must gate against the same
|
|
// allowlist brief-validator uses (BASE_ALLOWED_MODELS = ['sonnet','opus']),
|
|
// mirroring how effort is gated against EFFORT_LEVELS. A brief that slipped
|
|
// validation (hand-edited, validation skipped) must not hand a junk model
|
|
// string to a command that then spawns an agent with `model: <junk>`.
|
|
const fm = {
|
|
phase_signals: [
|
|
{ phase: 'plan', effort: 'high', model: 'gpt-4' }, // invalid model
|
|
{ phase: 'review', effort: 'standard', model: 'haiku' }, // not in BASE_ALLOWED_MODELS
|
|
],
|
|
};
|
|
const plan = resolvePhaseSignal(fm, 'plan');
|
|
assert.equal(plan.effort, 'high', 'valid effort still resolves');
|
|
assert.equal(plan.model, undefined, 'invalid model "gpt-4" must be dropped');
|
|
assert.ok(!('model' in plan), 'model key absent when value not in allowlist');
|
|
|
|
const review = resolvePhaseSignal(fm, 'review');
|
|
assert.equal(review.model, undefined, 'haiku must be dropped (not in BASE_ALLOWED_MODELS)');
|
|
assert.ok(!('model' in review), 'model key absent for haiku');
|
|
});
|
|
|
|
test('resolvePhaseSignal — keeps valid models (sonnet, opus) after gating', () => {
|
|
const fm = {
|
|
phase_signals: [
|
|
{ phase: 'research', effort: 'low', model: 'sonnet' },
|
|
{ phase: 'execute', effort: 'high', model: 'opus' },
|
|
],
|
|
};
|
|
assert.equal(resolvePhaseSignal(fm, 'research').model, 'sonnet');
|
|
assert.equal(resolvePhaseSignal(fm, 'execute').model, 'opus');
|
|
});
|
|
|
|
test('resolvePhaseSignalFromFile + CLI shim — writes JSON to stdout, exit 0', () => {
|
|
const fixture = join(tmpdir(), `phase-signal-test-${process.pid}.md`);
|
|
writeFileSync(fixture, `---
|
|
type: trekbrief
|
|
brief_version: "2.1"
|
|
phase_signals:
|
|
- phase: plan
|
|
effort: high
|
|
model: opus
|
|
---
|
|
# x
|
|
`);
|
|
try {
|
|
// Programmatic invocation
|
|
const r = resolvePhaseSignalFromFile(fixture, 'plan');
|
|
assert.deepEqual(r, { effort: 'high', model: 'opus' });
|
|
// CLI shim
|
|
const helperPath = new URL('../../lib/profiles/phase-signal-resolver.mjs', import.meta.url).pathname;
|
|
const out = execFileSync('node', [helperPath, '--brief', fixture, '--phase', 'plan', '--json'], {
|
|
encoding: 'utf-8',
|
|
});
|
|
const parsed = JSON.parse(out.trim());
|
|
assert.deepEqual(parsed, { effort: 'high', model: 'opus' });
|
|
} finally {
|
|
try { unlinkSync(fixture); } catch { /* swallow */ }
|
|
}
|
|
});
|