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
465 lines
18 KiB
JavaScript
465 lines
18 KiB
JavaScript
/**
|
|
* GAP Scanner — Feature Gap Scanner
|
|
* Compares actual configuration against complete Claude Code feature register.
|
|
* 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
|
|
*/
|
|
|
|
import { resolve } from 'node:path';
|
|
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';
|
|
|
|
/**
|
|
* @typedef {object} GapCheck
|
|
* @property {string} id - Short identifier (t1_1 through t4_5)
|
|
* @property {string} tier - t1|t2|t3|t4
|
|
* @property {string} title - Human-readable title
|
|
* @property {string} recommendation - What to do
|
|
* @property {(ctx: CheckContext) => Promise<boolean>} check - Returns true if feature IS present
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} CheckContext
|
|
* @property {import('./lib/file-discovery.mjs').ConfigFile[]} files
|
|
* @property {string} targetPath
|
|
* @property {Map<string, object>} parsedSettings - scope → parsed JSON
|
|
* @property {Map<string, string>} fileContents - absPath → content
|
|
*/
|
|
|
|
/**
|
|
* Check if a file belongs to the target project (vs global ~/.claude/).
|
|
* Needed because scope classification can be 'plugin' when running inside ~/.claude/plugins/.
|
|
* @param {CheckContext} ctx
|
|
* @param {import('./lib/file-discovery.mjs').ConfigFile} f
|
|
* @returns {boolean}
|
|
*/
|
|
function isTargetLocal(ctx, f) {
|
|
return f.absPath.startsWith(ctx.targetPath);
|
|
}
|
|
|
|
const TIER_SEVERITY = {
|
|
t1: SEVERITY.medium,
|
|
t2: SEVERITY.low,
|
|
t3: SEVERITY.info,
|
|
t4: SEVERITY.info,
|
|
};
|
|
|
|
/**
|
|
* Lazily read and cache file content.
|
|
* @param {CheckContext} ctx
|
|
* @param {string} absPath
|
|
* @returns {Promise<string|null>}
|
|
*/
|
|
async function getContent(ctx, absPath) {
|
|
if (ctx.fileContents.has(absPath)) return ctx.fileContents.get(absPath);
|
|
const content = await readTextFile(absPath);
|
|
ctx.fileContents.set(absPath, content);
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Check if any settings file has a specific key.
|
|
* @param {CheckContext} ctx
|
|
* @param {string} key
|
|
* @returns {boolean}
|
|
*/
|
|
function anySettingsHas(ctx, key) {
|
|
for (const parsed of ctx.parsedSettings.values()) {
|
|
if (parsed && key in parsed) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get a value from any settings file (first match).
|
|
* @param {CheckContext} ctx
|
|
* @param {string} key
|
|
* @returns {*}
|
|
*/
|
|
function getSettingsValue(ctx, key) {
|
|
for (const parsed of ctx.parsedSettings.values()) {
|
|
if (parsed && key in parsed) return parsed[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 ---
|
|
{
|
|
id: 't1_1', tier: 't1',
|
|
title: 'No CLAUDE.md file',
|
|
recommendation: 'Create a CLAUDE.md at the project root with project-specific instructions, commands, and architecture.',
|
|
check: async (ctx) => ctx.files.some(f => f.type === 'claude-md' && isTargetLocal(ctx, f)),
|
|
},
|
|
{
|
|
id: 't1_2', tier: 't1',
|
|
title: 'No permissions configured',
|
|
recommendation: 'Add permissions.allow and permissions.deny in .claude/settings.json to control tool access.',
|
|
check: async (ctx) => {
|
|
for (const parsed of ctx.parsedSettings.values()) {
|
|
if (parsed?.permissions && (parsed.permissions.allow?.length > 0 || parsed.permissions.deny?.length > 0)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
{
|
|
id: 't1_3', tier: 't1',
|
|
title: 'No hooks configured',
|
|
recommendation: 'Add at least one hook (e.g., PreToolUse for security, Stop for session summaries). See knowledge/hook-events-reference.md.',
|
|
check: async (ctx) => {
|
|
if (ctx.files.some(f => f.type === 'hooks-json')) return true;
|
|
for (const parsed of ctx.parsedSettings.values()) {
|
|
if (parsed?.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
|
|
return Object.keys(parsed.hooks).length > 0;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
{
|
|
id: 't1_4', tier: 't1',
|
|
title: 'No custom skills or commands',
|
|
recommendation: 'Create project-specific skills in .claude/skills/ or commands in .claude/commands/ to automate repetitive workflows.',
|
|
check: async (ctx) => ctx.files.some(f => f.type === 'skill-md' || f.type === 'command-md'),
|
|
},
|
|
{
|
|
id: 't1_5', tier: 't1',
|
|
title: 'No MCP servers configured',
|
|
recommendation: 'Add a .mcp.json at the project root to configure MCP servers for enhanced tool access.',
|
|
check: async (ctx) => ctx.files.some(f => f.type === 'mcp-json'),
|
|
},
|
|
|
|
// --- Tier 2: Configuration Depth ---
|
|
{
|
|
id: 't2_1', tier: 't2',
|
|
title: 'Settings only at one scope',
|
|
recommendation: 'Use all 3 settings scopes: ~/.claude/settings.json (user), .claude/settings.json (project), .claude/settings.local.json (local/personal).',
|
|
check: async (ctx) => {
|
|
const localSettings = ctx.files.filter(f => f.type === 'settings-json' && isTargetLocal(ctx, f));
|
|
const hasGlobal = ctx.files.some(f => f.type === 'settings-json' && !isTargetLocal(ctx, f));
|
|
return (localSettings.length >= 2) || (localSettings.length >= 1 && hasGlobal);
|
|
},
|
|
},
|
|
{
|
|
id: 't2_2', tier: 't2',
|
|
title: 'CLAUDE.md not modular',
|
|
recommendation: 'Use @imports or .claude/rules/ to split large CLAUDE.md files into focused modules.',
|
|
check: async (ctx) => {
|
|
// Has rules files OR has @imports in any CLAUDE.md
|
|
if (ctx.files.some(f => f.type === 'rule')) return true;
|
|
for (const file of ctx.files.filter(f => f.type === 'claude-md')) {
|
|
const content = await getContent(ctx, file.absPath);
|
|
if (content && findImports(content).length > 0) return true;
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
{
|
|
id: 't2_3', tier: 't2',
|
|
title: 'No path-scoped rules',
|
|
recommendation: 'Create .claude/rules/*.md with paths: frontmatter to apply rules only to matching files.',
|
|
check: async (ctx) => {
|
|
for (const file of ctx.files.filter(f => f.type === 'rule')) {
|
|
const content = await getContent(ctx, file.absPath);
|
|
if (content) {
|
|
const { frontmatter } = parseFrontmatter(content);
|
|
if (frontmatter && (frontmatter.paths || frontmatter.globs)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
{
|
|
id: 't2_4', tier: 't2',
|
|
title: 'Auto-memory explicitly disabled',
|
|
recommendation: 'Enable auto-memory by removing autoMemoryEnabled: false from settings.',
|
|
check: async (ctx) => {
|
|
// Present (gap) only if explicitly disabled
|
|
const val = getSettingsValue(ctx, 'autoMemoryEnabled');
|
|
return val !== false;
|
|
},
|
|
},
|
|
{
|
|
id: 't2_5', tier: 't2',
|
|
title: 'Low hook diversity',
|
|
recommendation: 'Use hooks across 3+ events (e.g., SessionStart, PreToolUse, Stop) for comprehensive automation.',
|
|
check: async (ctx) => {
|
|
const events = new Set();
|
|
for (const parsed of ctx.parsedSettings.values()) {
|
|
if (parsed?.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
|
|
for (const event of Object.keys(parsed.hooks)) events.add(event);
|
|
}
|
|
}
|
|
for (const file of ctx.files.filter(f => f.type === 'hooks-json')) {
|
|
const content = await getContent(ctx, file.absPath);
|
|
if (content) {
|
|
const parsed = parseJson(content);
|
|
const hookData = parsed?.hooks || parsed;
|
|
if (hookData && typeof hookData === 'object' && !Array.isArray(hookData)) {
|
|
for (const event of Object.keys(hookData)) events.add(event);
|
|
}
|
|
}
|
|
}
|
|
return events.size >= 3;
|
|
},
|
|
},
|
|
{
|
|
id: 't2_6', tier: 't2',
|
|
title: 'No custom subagents',
|
|
recommendation: 'Create custom agents in .claude/agents/ or ~/.claude/agents/ with specialized tools and model selection.',
|
|
check: async (ctx) => ctx.files.some(f => f.type === 'agent-md'),
|
|
},
|
|
{
|
|
id: 't2_7', tier: 't2',
|
|
title: 'No model configuration',
|
|
recommendation: 'Set model preferences in settings.json (model, modelOverrides) for cost/quality optimization.',
|
|
check: async (ctx) => anySettingsHas(ctx, 'model') || anySettingsHas(ctx, 'modelOverrides'),
|
|
},
|
|
|
|
// --- Tier 3: Advanced Features ---
|
|
{
|
|
id: 't3_1', tier: 't3',
|
|
title: 'No status line configured',
|
|
recommendation: 'Configure statusLine in settings.json to show context window usage, cost, and model info.',
|
|
check: async (ctx) => anySettingsHas(ctx, 'statusLine'),
|
|
},
|
|
{
|
|
id: 't3_2', tier: 't3',
|
|
title: 'No custom keybindings',
|
|
recommendation: 'Create ~/.claude/keybindings.json to customize keyboard shortcuts (e.g., bind chat:newline to Shift+Enter).',
|
|
check: async (ctx) => ctx.files.some(f => f.type === 'keybindings-json'),
|
|
},
|
|
{
|
|
id: 't3_3', tier: 't3',
|
|
title: 'Using default output style',
|
|
recommendation: 'Try "Explanatory" or "Learning" output styles, or create custom styles in .claude/output-styles/.',
|
|
check: async (ctx) => anySettingsHas(ctx, 'outputStyle'),
|
|
},
|
|
{
|
|
id: 't3_4', tier: 't3',
|
|
title: 'No worktree workflow',
|
|
recommendation: 'Use --worktree for parallel feature development. Configure worktree.symlinkDirectories for node_modules.',
|
|
check: async (ctx) => anySettingsHas(ctx, 'worktree'),
|
|
},
|
|
{
|
|
id: 't3_5', tier: 't3',
|
|
title: 'No advanced skill frontmatter',
|
|
recommendation: 'Use disable-model-invocation, context:fork, or argument-hint in skill frontmatter for better control.',
|
|
check: async (ctx) => {
|
|
for (const file of ctx.files.filter(f => f.type === 'skill-md')) {
|
|
const content = await getContent(ctx, file.absPath);
|
|
if (content) {
|
|
const { frontmatter } = parseFrontmatter(content);
|
|
if (frontmatter && (
|
|
frontmatter.disable_model_invocation ||
|
|
frontmatter.context === 'fork' ||
|
|
frontmatter.argument_hint
|
|
)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
{
|
|
id: 't3_6', tier: 't3',
|
|
title: 'No subagent isolation',
|
|
recommendation: 'Use isolation: worktree in agent frontmatter for safe parallel development.',
|
|
check: async (ctx) => {
|
|
for (const file of ctx.files.filter(f => f.type === 'agent-md')) {
|
|
const content = await getContent(ctx, file.absPath);
|
|
if (content) {
|
|
const { frontmatter } = parseFrontmatter(content);
|
|
if (frontmatter && frontmatter.isolation === 'worktree') return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
{
|
|
id: 't3_7', tier: 't3',
|
|
title: 'No dynamic skill context',
|
|
recommendation: 'Use !`command` syntax in skills to inject dynamic context (e.g., !`git branch --show-current`).',
|
|
check: async (ctx) => {
|
|
for (const file of ctx.files.filter(f => f.type === 'skill-md' || f.type === 'command-md')) {
|
|
const content = await getContent(ctx, file.absPath);
|
|
if (content && /!`[^`]+`/.test(content)) return true;
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
{
|
|
id: 't3_8', tier: 't3',
|
|
title: 'No autoMode classifier',
|
|
recommendation: 'Configure autoMode in user/local settings with environment context and allow/deny rules.',
|
|
check: async (ctx) => anySettingsHas(ctx, 'autoMode'),
|
|
},
|
|
|
|
// --- Tier 4: Team/Enterprise ---
|
|
{
|
|
id: 't4_1', tier: 't4',
|
|
title: 'No project .mcp.json in git',
|
|
recommendation: 'Add .mcp.json to git so the team shares MCP server configuration.',
|
|
check: async (ctx) => ctx.files.some(f => f.type === 'mcp-json' && isTargetLocal(ctx, f)),
|
|
},
|
|
{
|
|
id: 't4_2', tier: 't4',
|
|
title: 'No custom plugin',
|
|
recommendation: 'Package reusable skills, agents, and hooks as a Claude Code plugin with .claude-plugin/plugin.json.',
|
|
check: async (ctx) => ctx.files.some(f => f.type === 'plugin-json'),
|
|
},
|
|
{
|
|
id: 't4_3', tier: 't4',
|
|
title: 'Agent teams not enabled',
|
|
recommendation: 'Enable agent teams with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 for parallel multi-agent workflows.',
|
|
check: async (ctx) => {
|
|
for (const parsed of ctx.parsedSettings.values()) {
|
|
const env = parsed?.env;
|
|
if (env && env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1') return true;
|
|
}
|
|
return !!process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
},
|
|
},
|
|
{
|
|
id: 't4_4', tier: 't4',
|
|
title: 'No managed settings',
|
|
recommendation: 'Use managed-settings.json for organization-wide policy enforcement.',
|
|
check: async (ctx) => ctx.files.some(f => f.scope === 'managed'),
|
|
},
|
|
{
|
|
id: 't4_5', tier: 't4',
|
|
title: 'No LSP plugins',
|
|
recommendation: 'Add .lsp.json for real-time code intelligence from language servers.',
|
|
check: async (ctx) => ctx.files.some(f => f.relPath.endsWith('.lsp.json')),
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Scan for feature gaps against Claude Code feature register.
|
|
* @param {string} targetPath
|
|
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} sharedDiscovery - Used when provided with files; otherwise runs own discovery with includeGlobal
|
|
* @returns {Promise<object>}
|
|
*/
|
|
export async function scan(targetPath, sharedDiscovery) {
|
|
const start = Date.now();
|
|
const findings = [];
|
|
|
|
// Use shared discovery if it has files (e.g. from full-machine mode), otherwise run own
|
|
const discovery = (sharedDiscovery && sharedDiscovery.files && sharedDiscovery.files.length > 0)
|
|
? sharedDiscovery
|
|
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
|
|
|
|
// Parse all settings files upfront
|
|
const parsedSettings = new Map();
|
|
for (const file of discovery.files.filter(f => f.type === 'settings-json')) {
|
|
const content = await readTextFile(file.absPath);
|
|
if (content) {
|
|
const parsed = parseJson(content);
|
|
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
|
|
}
|
|
}
|
|
|
|
const ctx = {
|
|
files: discovery.files,
|
|
targetPath: resolve(targetPath),
|
|
parsedSettings,
|
|
fileContents: new Map(),
|
|
};
|
|
|
|
for (const gap of GAP_CHECKS) {
|
|
const present = await gap.check(ctx);
|
|
if (!present) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
severity: TIER_SEVERITY[gap.tier],
|
|
title: gap.title,
|
|
description: `Feature gap: ${gap.title}. ${gap.recommendation}`,
|
|
recommendation: gap.recommendation,
|
|
category: gap.tier,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* Group GAP findings into impact categories for opportunity-based display.
|
|
* @param {object[]} findings - GAP scanner findings (each has .category = t1|t2|t3|t4)
|
|
* @returns {{ highImpact: object[], mediumImpact: object[], explore: object[] }}
|
|
*/
|
|
export function opportunitySummary(findings) {
|
|
const highImpact = [];
|
|
const mediumImpact = [];
|
|
const explore = [];
|
|
|
|
for (const f of findings) {
|
|
if (f.category === 't1') highImpact.push(f);
|
|
else if (f.category === 't2') mediumImpact.push(f);
|
|
else explore.push(f);
|
|
}
|
|
|
|
return { highImpact, mediumImpact, explore };
|
|
}
|