SKL-002 (skill-listing budget) and CML char-budget now calibrate to a
resolved context window instead of always anchoring at 200k:
- resolveContextWindow(): --context-window <n> calibrates; 'auto' keeps the
conservative 200k anchor but marks advisory (model→window probing deferred
to B8b); no flag → 200k anchor, byte-identical to pre-B8 default.
- scaleForWindow(): linear off the 200k anchor (identity at the anchor).
- SKL + CML each keep an untouched default branch (window===200k && !advisory)
for byte-stability and a calibrated branch; advisory downgrades the budget
finding from a breach (low/medium) to info.
- Flag wired through scan-orchestrator + posture; runAllScanners resolves once
and threads { contextWindow } to scanners (others ignore the 3rd arg).
- CPS intentionally excluded: it has no window-anchored budget (fixed
150-line volatility heuristic), so there is nothing to calibrate.
15 new tests; e2e CLI verified (1M suppresses SKL-002, auto → info, default
unchanged); full suite 1279 green; snapshots byte-stable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
9.1 KiB
JavaScript
199 lines
9.1 KiB
JavaScript
/**
|
|
* Skill-listing budget — single source of truth.
|
|
*
|
|
* Claude Code shows the model a listing of every active skill's `description`
|
|
* so it can decide which skill to invoke. That listing is budgeted two ways:
|
|
* - per description: capped at 1,536 chars (CC 2.1.105, changelog L1502);
|
|
* anything past the cap is silently truncated.
|
|
* - in aggregate: the whole listing is allotted ~2% of the context window
|
|
* (CC 2.1.32, changelog L2860). We do NOT know the user's context window,
|
|
* so the aggregate budget anchors on a conservative 200k window (4,000 tok)
|
|
* and discloses the assumption.
|
|
*
|
|
* Two scanners consume this module so the budget is defined in exactly one place:
|
|
* - SKL (skill-listing-scanner) DIAGNOSES overflow (CA-SKL-001 per-description
|
|
* cap, CA-SKL-002 aggregate).
|
|
* - GAP (feature-gap-scanner) PRESCRIBES the remedy: when the listing is over
|
|
* budget and `disableBundledSkills` is un-pulled, it recommends that lever.
|
|
*
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { join } from 'node:path';
|
|
import { estimateTokens, enumeratePlugins, enumerateSkills } from './active-config-reader.mjs';
|
|
import { readTextFile } from './file-discovery.mjs';
|
|
import { parseFrontmatter, parseJson } from './yaml-parser.mjs';
|
|
import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas } from './context-window.mjs';
|
|
|
|
// Verified per-description skill-listing cap (CC 2.1.105, changelog L1502).
|
|
// Descriptions longer than this are truncated in the listing the model sees.
|
|
export const DESCRIPTION_CAP = 1536;
|
|
|
|
// Aggregate listing budget (CC 2.1.32, changelog L2860): the skill listing the
|
|
// model reads is allotted ~2% of the context window. The context window is
|
|
// unknown, so we anchor on a conservative 200k window — the smallest common
|
|
// size, which fires earliest — and disclose the assumption in the evidence.
|
|
// The 200k/1M window constants live in context-window.mjs (single source of
|
|
// truth, shared with the CML CLAUDE.md char-budget check); re-exported here so
|
|
// existing importers of this module keep working.
|
|
export const BUDGET_FRACTION = 0.02;
|
|
export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
|
|
export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000
|
|
export { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas };
|
|
|
|
// The honest framing required because the budget depends on a context window we
|
|
// cannot observe (jf. TOK CALIBRATION_NOTE). Appended to budget-overflow evidence.
|
|
export const BUDGET_CALIBRATION_NOTE =
|
|
'the budget scales with the context window - this anchors on a conservative 200k ' +
|
|
`window; at ${withCommas(LARGE_CONTEXT_WINDOW)} context the budget is ~${withCommas(LARGE_CONTEXT_BUDGET_TOKENS)} ` +
|
|
'tok and you are likely within it. this is an estimate, not measured telemetry';
|
|
|
|
// Skill-body size guidance (CA-SKL-003). A SKILL.md body over ~5,000 tokens
|
|
// (~500 lines / ~20k chars) should split reference content into supporting files
|
|
// (Claude Code skill-authoring guidance). Unlike the listing budget above, the
|
|
// body is an ON-DEMAND cost: it loads only when the skill is invoked, not every
|
|
// turn — so this is a LOW-severity efficiency signal, not an always-loaded bill.
|
|
export const BODY_TOKEN_THRESHOLD = 5000;
|
|
|
|
// Honest framing for the body-size finding: distinguishes on-demand from
|
|
// always-loaded cost and flags the figure as an estimate. Appended to evidence.
|
|
export const BODY_CALIBRATION_NOTE =
|
|
'this is the skill BODY (SKILL.md below the frontmatter), which loads ON DEMAND only when the ' +
|
|
'skill is invoked - NOT every turn like the always-loaded listing. estimate (chars/4), not measured telemetry';
|
|
|
|
/**
|
|
* @typedef {object} BudgetAssessment
|
|
* @property {number} scanned - number of descriptions assessed
|
|
* @property {number} aggregateChars - sum of each length capped at DESCRIPTION_CAP
|
|
* @property {number} aggregateTokens - estimateTokens(aggregateChars, 'markdown')
|
|
* @property {number} budgetTokens - AGGREGATE_BUDGET_TOKENS (the 200k-anchored budget)
|
|
* @property {boolean} overBudget - aggregateTokens strictly greater than budgetTokens
|
|
* @property {number} overBy - tokens over budget (0 when not over)
|
|
*/
|
|
|
|
/**
|
|
* Pure aggregate-budget assessment. Each description contributes only up to the
|
|
* cap (the tail past the cap is dropped from the listing, and CA-SKL-001 already
|
|
* flags it — so the aggregate does not double-count it).
|
|
*
|
|
* @param {number[]} descLengths - one entry per active skill (description char count)
|
|
* @param {number} [budgetTokens=AGGREGATE_BUDGET_TOKENS] - the listing budget to
|
|
* measure against. Defaults to the 200k-anchored 4,000 tok; B8 passes a
|
|
* window-calibrated budget. Defaulting keeps existing callers byte-stable.
|
|
* @returns {BudgetAssessment}
|
|
*/
|
|
export function assessSkillListingBudget(descLengths, budgetTokens = AGGREGATE_BUDGET_TOKENS) {
|
|
let aggregateChars = 0;
|
|
for (const len of descLengths) {
|
|
const safe = (typeof len === 'number' && Number.isFinite(len) && len > 0) ? len : 0;
|
|
aggregateChars += Math.min(safe, DESCRIPTION_CAP);
|
|
}
|
|
const aggregateTokens = estimateTokens(aggregateChars, 'markdown');
|
|
const overBudget = aggregateTokens > budgetTokens;
|
|
return {
|
|
scanned: descLengths.length,
|
|
aggregateChars,
|
|
aggregateTokens,
|
|
budgetTokens,
|
|
overBudget,
|
|
overBy: overBudget ? aggregateTokens - budgetTokens : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} ActiveSkillEntry
|
|
* @property {string} name
|
|
* @property {'user'|'plugin'} source
|
|
* @property {string|null} pluginName
|
|
* @property {string} path
|
|
* @property {number} descLength
|
|
* @property {number} bodyChars - SKILL.md body length below the frontmatter (on-demand cost)
|
|
* @property {number} bodyLines - body line count
|
|
* @property {number} bodyTokens - estimateTokens(bodyChars, 'markdown')
|
|
*/
|
|
|
|
/**
|
|
* Enumerate every active skill (user + plugin) and measure the listing budget.
|
|
* HOME-scoped: resolves ~/.claude via process.env.HOME (enumeratePlugins /
|
|
* enumerateSkills). Callers that run under test MUST override HOME (see the
|
|
* hermetic-home helper / runScannerWithHome pattern).
|
|
*
|
|
* @param {number} [budgetTokens=AGGREGATE_BUDGET_TOKENS] - listing budget for the
|
|
* aggregate assessment (B8 window-calibration); defaults keep callers byte-stable.
|
|
* @returns {Promise<{ skills: ActiveSkillEntry[], aggregate: BudgetAssessment }>}
|
|
*/
|
|
export async function measureActiveSkillListing(budgetTokens = AGGREGATE_BUDGET_TOKENS) {
|
|
const plugins = await enumeratePlugins();
|
|
const allSkills = await enumerateSkills(plugins);
|
|
|
|
const skills = [];
|
|
for (const skill of allSkills) {
|
|
if (!skill || typeof skill.path !== 'string') continue;
|
|
const content = await readTextFile(skill.path);
|
|
if (!content) continue;
|
|
const parsed = parseFrontmatter(content);
|
|
const fm = parsed?.frontmatter || null;
|
|
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
|
|
const body = (parsed && typeof parsed.body === 'string') ? parsed.body : '';
|
|
const bodyChars = body.length;
|
|
skills.push({
|
|
name: skill.name,
|
|
source: skill.source,
|
|
pluginName: skill.pluginName,
|
|
path: skill.path,
|
|
descLength: desc.length,
|
|
bodyChars,
|
|
bodyLines: bodyChars === 0 ? 0 : body.split('\n').length,
|
|
bodyTokens: estimateTokens(bodyChars, 'markdown'),
|
|
});
|
|
}
|
|
|
|
const aggregate = assessSkillListingBudget(skills.map((s) => s.descLength), budgetTokens);
|
|
return { skills, aggregate };
|
|
}
|
|
|
|
/**
|
|
* Read an env flag, treating null, "", "0", "false", "no", "off" as un-set.
|
|
* @param {string|undefined} v
|
|
* @returns {boolean}
|
|
*/
|
|
export function envFlag(v) {
|
|
if (v == null) return false;
|
|
const s = String(v).trim().toLowerCase();
|
|
return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
|
|
}
|
|
|
|
/**
|
|
* Resolve whether the `disableBundledSkills` lever is effectively ON, reading the
|
|
* env var and the settings cascade directly (user ~/.claude, then project, then
|
|
* project-local).
|
|
*
|
|
* Reads the files directly rather than relying on config-discovery
|
|
* classification: when discovery walks ~/.claude from the .claude root, the
|
|
* user settings.json has a relPath of "settings.json" (no ".claude" segment)
|
|
* and is NOT tagged as settings-json — so the dominant user-scope location for
|
|
* this global preference would otherwise be missed. HOME-scoped via
|
|
* process.env.HOME.
|
|
*
|
|
* @param {string} [projectPath] - project root, to also read project + local settings
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function isBundledSkillsDisabled(projectPath) {
|
|
if (envFlag(process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS)) return true;
|
|
|
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
const candidates = [];
|
|
if (home) candidates.push(join(home, '.claude', 'settings.json'));
|
|
if (projectPath) {
|
|
candidates.push(join(projectPath, '.claude', 'settings.json'));
|
|
candidates.push(join(projectPath, '.claude', 'settings.local.json'));
|
|
}
|
|
|
|
for (const p of candidates) {
|
|
const content = await readTextFile(p);
|
|
if (!content) continue;
|
|
const parsed = parseJson(content);
|
|
if (parsed && parsed.disableBundledSkills === true) return true;
|
|
}
|
|
return false;
|
|
}
|