config-audit/scanners/lib/skill-listing-budget.mjs
Kjell Tore Guttormsen 0a631e3061 refactor(skl): extract skill-listing budget to shared lib (single source of truth)
Chunk 1 of the disableBundledSkills GAP feature. Moves the per-description cap,
aggregate budget constants, calibration note, and the enumerate-and-measure step
out of skill-listing-scanner into scanners/lib/skill-listing-budget.mjs — so SKL
(diagnoses overflow) and the upcoming GAP check (prescribes disableBundledSkills)
consume one budget definition instead of two divergent copies.

- New lib: assessSkillListingBudget (pure aggregate math) + measureActiveSkillListing
  (HOME-scoped enumerate-and-measure wrapper).
- SKL delegates measurement; all finding strings kept byte-identical. 18/18 SKL
  tests pass unchanged → behavior-neutral refactor.
- 12 new lib unit tests pin the budget contract. Suite 875 -> 887.
- README badge + CLAUDE.md test counts synced (self-audit --check-readme: passed).

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

125 lines
5.5 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 { estimateTokens, enumeratePlugins, enumerateSkills } from './active-config-reader.mjs';
import { readTextFile } from './file-discovery.mjs';
import { parseFrontmatter } from './yaml-parser.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.
export const BUDGET_FRACTION = 0.02;
export const CONTEXT_WINDOW_ANCHOR = 200_000;
export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
export const LARGE_CONTEXT_WINDOW = 1_000_000;
export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000
// Dependency-free thousands separator (repo invariant: zero external deps).
export const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
// 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';
/**
* @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)
* @returns {BudgetAssessment}
*/
export function assessSkillListingBudget(descLengths) {
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 > AGGREGATE_BUDGET_TOKENS;
return {
scanned: descLengths.length,
aggregateChars,
aggregateTokens,
budgetTokens: AGGREGATE_BUDGET_TOKENS,
overBudget,
overBy: overBudget ? aggregateTokens - AGGREGATE_BUDGET_TOKENS : 0,
};
}
/**
* @typedef {object} ActiveSkillEntry
* @property {string} name
* @property {'user'|'plugin'} source
* @property {string|null} pluginName
* @property {string} path
* @property {number} descLength
*/
/**
* 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).
*
* @returns {Promise<{ skills: ActiveSkillEntry[], aggregate: BudgetAssessment }>}
*/
export async function measureActiveSkillListing() {
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 fm = parseFrontmatter(content)?.frontmatter || null;
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
skills.push({
name: skill.name,
source: skill.source,
pluginName: skill.pluginName,
path: skill.path,
descLength: desc.length,
});
}
const aggregate = assessSkillListingBudget(skills.map((s) => s.descLength));
return { skills, aggregate };
}