config-audit/scanners/lib/context-window.mjs
Kjell Tore Guttormsen cf75249b5e feat(skl,cml): --context-window auto model→window probe (v5.12 B8b) [skip-docs]
Completes the deferred B8 half. `--context-window auto` now probes the
configured model and calibrates SKL/CML budgets to its real window instead
of always falling back to the conservative advisory anchor.

- lib/context-window.mjs: pure modelToContextWindow() maps known 1M-tier
  model IDs (Fable 5, Opus 4.8/4.7/4.6, Sonnet 4.6 — verified June 2026 —
  plus the explicit [1m] tier tag, dated/provider-prefixed IDs, and the
  opus/sonnet/fable aliases) to the 1M window; unknown/unconfirmed -> null
  (caller keeps the conservative anchor). resolveContextWindow() auto branch
  now probes opts.model: recognized -> auto-probed (not advisory); unknown
  or unpinned -> auto-unresolved (advisory, pre-B8b behavior).
- lib/active-model.mjs (new): resolveActiveModel() reads the model the way
  Claude Code resolves it — shell ANTHROPIC_MODEL override, then settings
  cascade local > project > user. Injectable env, hermetic under test HOME.
- scan-orchestrator: resolves the active model only when the flag is `auto`
  and threads it into resolveContextWindow; posture inherits via runAllScanners.

Default (no flag) and explicit --context-window <n> paths ignore the model
and stay byte-stable; frozen v5.0.0 + SC-5 snapshots untouched. TDD: 17 new
tests (context-window mapping/probe + active-model cascade). Suite 1296 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:29:12 +02:00

132 lines
6 KiB
JavaScript

/**
* Context-window constants — single source of truth.
*
* Several Claude Code budgets scale with the model's context window:
* - the skill listing is allotted ~2% of context (CC 2.1.32, changelog L2860);
* - the "CLAUDE.md is too long" warning threshold scales with it (CC 2.1.169).
*
* We cannot observe the user's actual context window, so scanners anchor on a
* conservative 200k window (the smallest common size — it fires earliest, the
* safe default when the window is unknown) and disclose the relaxed 1M figure.
*
* Zero external dependencies.
*/
// Conservative anchor: the smallest common context window. Budgets anchored
// here fire earliest, which is the safe default when the window is unknown.
export const CONTEXT_WINDOW_ANCHOR = 200_000;
// Large context window (Opus/Sonnet 1M tier). Used to disclose how a budget
// relaxes on large-context models.
export const LARGE_CONTEXT_WINDOW = 1_000_000;
// How much larger the 1M window is than the 200k anchor (= 5). A budget that
// scales linearly with the window relaxes by this factor at 1M.
export const LARGE_CONTEXT_SCALE = LARGE_CONTEXT_WINDOW / CONTEXT_WINDOW_ANCHOR;
// Dependency-free thousands separator (repo invariant: zero external deps).
export const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
// Model families whose context window is the large (1M) tier. Verified June 2026
// (platform.claude.com models overview): Fable 5, Opus 4.8/4.7/4.6 and Sonnet 4.6
// all run a 1M context window. Matched by substring so dated IDs
// (claude-opus-4-8-20260528) and provider-prefixed IDs
// (us.anthropic.claude-opus-4-8) resolve too. Models we cannot confirm (e.g.
// Haiku, older 200k-era IDs) are deliberately left out: the caller then keeps the
// conservative anchor rather than guess a relaxed budget.
export const LARGE_CONTEXT_MODEL_IDS = [
'claude-fable-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
];
// Short aliases Claude Code accepts in the `model` setting / ANTHROPIC_MODEL that
// currently resolve to a 1M-tier model (`opusplan` plans on an Opus-tier model).
export const LARGE_CONTEXT_MODEL_ALIASES = new Set(['opus', 'sonnet', 'fable', 'opusplan']);
/**
* Map a configured model id/alias to its context window, or null when we cannot
* confirm it. Pure: no IO. Used by the `--context-window auto` probe (B8b) so
* known 1M-tier models calibrate budgets instead of falling back to the
* conservative advisory anchor.
*
* @param {string} modelId - e.g. "claude-opus-4-8[1m]", "claude-sonnet-4-6", "opus"
* @returns {number|null} the context window, or null if unrecognized
*/
export function modelToContextWindow(modelId) {
if (typeof modelId !== 'string') return null;
const id = modelId.trim().toLowerCase();
if (!id) return null;
// Explicit tier tag wins — the running session model surfaces as e.g.
// "claude-opus-4-8[1m]". This is the strongest, most future-proof signal.
if (id.includes('[1m]')) return LARGE_CONTEXT_WINDOW;
// Known 1M-tier families (substring → tolerant of date/provider-prefix variants).
for (const fam of LARGE_CONTEXT_MODEL_IDS) {
if (id.includes(fam)) return LARGE_CONTEXT_WINDOW;
}
// Short aliases.
if (LARGE_CONTEXT_MODEL_ALIASES.has(id)) return LARGE_CONTEXT_WINDOW;
// Unknown: cannot confirm the window — keep the conservative anchor (null).
return null;
}
/**
* @typedef {object} ResolvedContextWindow
* @property {number} window - the context window budgets calibrate against
* @property {boolean} advisory - true when the window is unknown: keep the anchor
* but downgrade budget findings to info instead of
* firing them as a breach
* @property {'default'|'explicit'|'auto-probed'|'auto-unresolved'} source
*/
/**
* Resolve the raw `--context-window` CLI value into a window + advisory flag.
*
* Design (B8): the DEFAULT (no flag) is byte-identical to the pre-B8 behavior —
* the conservative 200k anchor at full severity. Only an explicit value changes
* calibration. `auto` asks the tool to figure out the window.
*
* B8b: `auto` now probes the configured model (`opts.model`, resolved from the
* settings cascade / ANTHROPIC_MODEL by the orchestrator). A recognized 1M-tier
* model calibrates to its window (source `auto-probed`, not advisory). When the
* model is unknown or unpinned, it keeps the conservative anchor but marks the
* result advisory (source `auto-unresolved`) so SKL/CML downgrade their budget
* findings to info rather than "crying wolf" on a window we cannot confirm.
*
* @param {string|number|null|undefined} arg
* @param {{ model?: string|null }} [opts] - probe input for `auto` (ignored on the
* default/explicit paths, which stay byte-stable).
* @returns {ResolvedContextWindow}
*/
export function resolveContextWindow(arg, opts = {}) {
if (arg == null) {
return { window: CONTEXT_WINDOW_ANCHOR, advisory: false, source: 'default' };
}
if (String(arg).trim().toLowerCase() === 'auto') {
const probed = modelToContextWindow(opts.model);
if (probed) {
return { window: probed, advisory: false, source: 'auto-probed' };
}
return { window: CONTEXT_WINDOW_ANCHOR, advisory: true, source: 'auto-unresolved' };
}
const n = typeof arg === 'number' ? arg : parseInt(String(arg).trim(), 10);
if (Number.isFinite(n) && n > 0) {
return { window: n, advisory: false, source: 'explicit' };
}
// Unparseable / non-positive: fall back to the conservative default (no advisory).
return { window: CONTEXT_WINDOW_ANCHOR, advisory: false, source: 'default' };
}
/**
* Scale a 200k-anchored budget to a given context window. Linear in the window,
* so it is the identity at the anchor (keeps the default byte-stable).
*
* @param {number} anchorValue - the budget/threshold defined at the 200k anchor
* @param {number} window - the target context window
* @returns {number}
*/
export function scaleForWindow(anchorValue, window) {
return Math.round(anchorValue * (window / CONTEXT_WINDOW_ANCHOR));
}