feat(feature-gap): recommend disableBundledSkills under skill-listing pressure
Chunk 2 of the disableBundledSkills GAP feature. Adds a conditional GAP check
that prescribes the `disableBundledSkills` lever — but only when the active
skill listing is measurably over budget (SKL's CA-SKL-002 overflow signal) and
the lever is un-pulled. It stays an opportunity, not noise.
Bundled skills (/code-review, /batch, /debug, /loop, /claude-api, …) live in the
CC binary, not on disk, so their exact cost is unmeasurable here — the finding
says so plainly, and frames the lever as zero-cost budget reclaim that leaves
the user's own skills untouched. CC 2.1.169+.
- Pure, exported bundledSkillsLeverFinding({leverPulled, aggregate}) → finding|null
(severity low, category token-efficiency, CA-GAP-NNN), wired into scan() via the
shared measureActiveSkillListing().
- Lever resolved via new isBundledSkillsDisabled(): env var + settings cascade
read directly, because discovery does NOT tag ~/.claude/settings.json (its
relPath lacks ".claude" when walked from the .claude root) — the dominant
user-scope location for this global preference would otherwise be missed.
- GAP scan() now reads HOME → existing GAP tests retrofitted to withHermeticHome
per the hermetic rule. Snapshots unchanged, contamination grep clean.
- 16 new tests (9 GAP, 7 lib). Suite 887 -> 903. README/CLAUDE.md document the
cross-scanner remediation; test counts synced. self-audit: PASS, configGrade
A 96, pluginGrade A 100, readme gate passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
parent
0a631e3061
commit
dfe9049b55
6 changed files with 338 additions and 12 deletions
|
|
@ -1,7 +1,9 @@
|
|||
/**
|
||||
* GAP Scanner — Feature Gap Scanner
|
||||
* Compares actual configuration against complete Claude Code feature register.
|
||||
* 25 gap dimensions across 4 tiers. Always runs with includeGlobal: true.
|
||||
* 25 gap dimensions across 4 tiers, plus a conditional disableBundledSkills
|
||||
* budget-lever check (remediation companion to SKL CA-SKL-002, fires only under
|
||||
* measured skill-listing pressure). Always runs with includeGlobal: true.
|
||||
* Finding IDs: CA-GAP-NNN
|
||||
*/
|
||||
|
||||
|
|
@ -10,6 +12,7 @@ import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
|||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { measureActiveSkillListing, isBundledSkillsDisabled, BUDGET_CALIBRATION_NOTE } from './lib/skill-listing-budget.mjs';
|
||||
|
||||
const SCANNER = 'GAP';
|
||||
|
||||
|
|
@ -87,6 +90,50 @@ function getSettingsValue(ctx, key) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation companion to SKL CA-SKL-002: when the active skill listing is over
|
||||
* its budget and the `disableBundledSkills` lever is un-pulled, recommend it.
|
||||
*
|
||||
* Bundled (built-in) skills — /code-review, /batch, /debug, /loop, /claude-api
|
||||
* and more — live in the Claude Code binary, not on disk, so their exact listing
|
||||
* cost cannot be measured here. But they draw on the SAME budget the SKL scanner
|
||||
* measures; when that budget is already exceeded, dropping them is a zero-cost
|
||||
* lever that does not touch the user's own skills. We fire ONLY under measured
|
||||
* pressure (SKL's overflow signal) so this stays an opportunity, not noise.
|
||||
*
|
||||
* Pure and exported for unit testing.
|
||||
*
|
||||
* @param {{ leverPulled: boolean, aggregate: (import('./lib/skill-listing-budget.mjs').BudgetAssessment|null) }} args
|
||||
* @returns {object|null} a GAP finding, or null when the lever is pulled or the listing is within budget
|
||||
*/
|
||||
export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
|
||||
if (leverPulled) return null;
|
||||
if (!aggregate || !aggregate.overBudget) return null;
|
||||
|
||||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Bundled skills add to an over-budget skill listing',
|
||||
description:
|
||||
`Your ${aggregate.scanned} active skills already carry ~${aggregate.aggregateTokens} tokens of ` +
|
||||
`description text, over the ${aggregate.budgetTokens}-token listing budget Claude Code allots the ` +
|
||||
'skill listing on a 200k context window (~2% of context, CC 2.1.32). Claude Code also loads its ' +
|
||||
'bundled (built-in) skills — /code-review, /batch, /debug, /loop, /claude-api and more — into that ' +
|
||||
'same listing. They are not on disk, so their exact cost cannot be measured here, but they draw on ' +
|
||||
'the same budget. `disableBundledSkills: true` drops them from the listing, reclaiming space without ' +
|
||||
'touching your own skills.',
|
||||
evidence:
|
||||
`description_tokens~${aggregate.aggregateTokens}; budget@200k=${aggregate.budgetTokens} tok; over_by~` +
|
||||
`${aggregate.overBy} tok; lever=disableBundledSkills (unset) - ${BUDGET_CALIBRATION_NOTE}`,
|
||||
recommendation:
|
||||
'Set `disableBundledSkills: true` in settings.json (or the CLAUDE_CODE_DISABLE_BUNDLED_SKILLS env var) ' +
|
||||
'to hide built-in skills and slash commands from the model and reclaim skill-listing budget (CC 2.1.169+). ' +
|
||||
'Keep it off if you rely on bundled skills like /code-review — in that case trim your own skill ' +
|
||||
'descriptions or use `skillOverrides` instead.',
|
||||
category: 'token-efficiency',
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {GapCheck[]} */
|
||||
const GAP_CHECKS = [
|
||||
// --- Tier 1: Foundation ---
|
||||
|
|
@ -386,6 +433,14 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
}
|
||||
}
|
||||
|
||||
// disableBundledSkills lever — fires only under measured skill-listing pressure
|
||||
// (SKL's CA-SKL-002 overflow signal). HOME-scoped: the listing and the lever
|
||||
// cascade both resolve via process.env.HOME, independent of project discovery.
|
||||
const leverPulled = await isBundledSkillsDisabled(ctx.targetPath);
|
||||
const { aggregate } = await measureActiveSkillListing();
|
||||
const leverFinding = bundledSkillsLeverFinding({ leverPulled, aggregate });
|
||||
if (leverFinding) findings.push(leverFinding);
|
||||
|
||||
const filesScanned = discovery.files.length;
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@
|
|||
* 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 } from './yaml-parser.mjs';
|
||||
import { parseFrontmatter, parseJson } 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.
|
||||
|
|
@ -123,3 +124,49 @@ export async function measureActiveSkillListing() {
|
|||
const aggregate = assessSkillListingBudget(skills.map((s) => s.descLength));
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue