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:
Kjell Tore Guttormsen 2026-06-18 21:38:19 +02:00
commit dfe9049b55
6 changed files with 338 additions and 12 deletions

View file

@ -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;
}