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

@ -1,7 +1,12 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import {
assessSkillListingBudget,
envFlag,
isBundledSkillsDisabled,
DESCRIPTION_CAP,
AGGREGATE_BUDGET_TOKENS,
CONTEXT_WINDOW_ANCHOR,
@ -98,3 +103,79 @@ describe('assessSkillListingBudget — aggregate math', () => {
assert.equal(r.scanned, 3);
});
});
describe('envFlag', () => {
it('treats 1/true/yes/on as set', () => {
for (const v of ['1', 'true', 'TRUE', 'yes', 'on', ' 1 ']) {
assert.equal(envFlag(v), true, `expected ${JSON.stringify(v)} → true`);
}
});
it('treats null/empty/0/false/no/off as un-set', () => {
for (const v of [undefined, null, '', '0', 'false', 'no', 'off', ' ']) {
assert.equal(envFlag(v), false, `expected ${JSON.stringify(v)} → false`);
}
});
});
describe('isBundledSkillsDisabled — lever cascade', () => {
async function withHome(fn) {
const home = await mkdtemp(join(tmpdir(), 'config-audit-lever-home-'));
const originalHome = process.env.HOME;
const originalEnv = process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
process.env.HOME = home;
delete process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
try {
return await fn(home);
} finally {
process.env.HOME = originalHome;
if (originalEnv === undefined) delete process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
else process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = originalEnv;
await rm(home, { recursive: true, force: true });
}
}
async function writeSettings(dir, obj) {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'settings.json'), JSON.stringify(obj));
}
it('is false on a clean HOME with no env and no settings', async () => {
await withHome(async () => {
assert.equal(await isBundledSkillsDisabled(), false);
});
});
it('is true when the env var is set', async () => {
await withHome(async () => {
process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = '1';
assert.equal(await isBundledSkillsDisabled(), true);
});
});
it('is true when user ~/.claude/settings.json sets it (the location discovery misses)', async () => {
await withHome(async (home) => {
await writeSettings(join(home, '.claude'), { disableBundledSkills: true });
assert.equal(await isBundledSkillsDisabled(), true);
});
});
it('is true when project .claude/settings.json sets it', async () => {
await withHome(async () => {
const project = await mkdtemp(join(tmpdir(), 'config-audit-lever-proj-'));
try {
await writeSettings(join(project, '.claude'), { disableBundledSkills: true });
assert.equal(await isBundledSkillsDisabled(project), true);
} finally {
await rm(project, { recursive: true, force: true });
}
});
});
it('is false when the setting is present but not strictly true', async () => {
await withHome(async (home) => {
await writeSettings(join(home, '.claude'), { disableBundledSkills: false });
assert.equal(await isBundledSkillsDisabled(), false);
});
});
});