feat(opt): optimization lens CA-OPT-001 (procedure→skill) — v5.7 Fase 1 Chunk 2a
First detector of the 'is the config OPTIMAL?' axis (vs the existing 'correct?' scanners). New orchestrated scanner family CA-OPT (count 14->15), the deterministic half of the hybrid optimization lens. CA-OPT-001 (low, Missed opportunity): a multi-step procedure in CLAUDE.md (>=6 consecutive numbered steps) that belongs in a skill. Reads recommendation + provenance from the best-practices register (BP-MECH-003). Conservative by design; the negative corpus proves null false-positives. Prose-judgment cases (lifecycle->hook, 'never'->permission) are deferred to the Chunk 2b opus analyzer. Wiring mirrors OST: orchestrator entry, humanizer (OPT->'Missed opportunity' + family), scoring (OPT->'CLAUDE.md', existing area -> no new posture row -> byte-stable), strip-helper (OST,OPT), SC-5 regenerated under hermetic HOME (additive OPT entry only). 10 new tests; suite 1045->1055, self-audit A/A, readmeCheck passed (all verified with a clean HOME). Note: the pre-existing TOK test reads the real ~/.claude (non-hermetic) -> run the suite with a clean HOME for deterministic results. Tracked as a separate follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
55f83a3c99
commit
e7833b65fc
14 changed files with 350 additions and 38 deletions
|
|
@ -850,4 +850,24 @@ export const TRANSLATIONS = {
|
|||
recommendation: 'See the details for which output style to adjust.',
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// OPT — Optimization Lens (mechanism-fit)
|
||||
// Category: Missed opportunity
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
OPT: {
|
||||
static: {
|
||||
'A multi-step procedure in CLAUDE.md belongs in a skill': {
|
||||
title: 'A long checklist in CLAUDE.md could be a skill instead',
|
||||
description: 'Your CLAUDE.md has a multi-step procedure that loads on every turn, costing tokens whether or not you\'re doing that task. Procedures fit better as a skill, whose steps load only when you actually run them.',
|
||||
recommendation: 'Move the steps into a skill under `.claude/skills/`. Keep CLAUDE.md for facts Claude should always know, not step-by-step procedures.',
|
||||
},
|
||||
},
|
||||
patterns: [],
|
||||
_default: {
|
||||
title: 'Your setup could fit Claude Code a little better',
|
||||
description: 'A check found a setup that works but where a different mechanism would fit the job better.',
|
||||
recommendation: 'See the details for the suggested change.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const SCANNER_TO_CATEGORY = {
|
|||
GAP: 'Missed opportunity',
|
||||
PLH: 'Configuration mistake',
|
||||
OST: 'Configuration mistake',
|
||||
OPT: 'Missed opportunity',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ const SCANNER_AREA_MAP = {
|
|||
DIS: 'Settings',
|
||||
COL: 'Plugin Hygiene',
|
||||
OST: 'Settings',
|
||||
OPT: 'CLAUDE.md',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
146
scanners/optimization-lens-scanner.mjs
Normal file
146
scanners/optimization-lens-scanner.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* OPT Scanner — Optimization Lens / mechanism-fit (v5.7 Fase 1 Chunk 2a)
|
||||
*
|
||||
* The first detector of the "is the config OPTIMAL?" axis (vs. the existing
|
||||
* "is it CORRECT?" scanners). It reads the machine-readable best-practices
|
||||
* register (knowledge/best-practices.json) and flags config that works but uses
|
||||
* a mechanism a better one would fit — the deterministic half of the hybrid
|
||||
* motor (the opus analyzer for prose-judgment cases is Chunk 2b).
|
||||
*
|
||||
* CA-OPT-001 A multi-step procedure in CLAUDE.md should be a SKILL (BP-MECH-003).
|
||||
* CLAUDE.md is for facts Claude holds every turn; a procedure there
|
||||
* costs always-loaded tokens whether or not you run it, and a skill's
|
||||
* body loads only on invoke. Detection is deliberately CONSERVATIVE
|
||||
* (a run of >= 6 consecutive numbered steps) to keep precision high —
|
||||
* the negative corpus in the tests proves null false-positives.
|
||||
* Framed as a Missed opportunity (humanizer), severity LOW.
|
||||
*
|
||||
* Provenance: the recommendation + claim come from the register entry (only a
|
||||
* `confirmed` entry is used user-facing — Verifiseringsplikt); an inline default
|
||||
* is the graceful fallback if the register is unavailable. Fixture-gated: the
|
||||
* marketplace-medium CLAUDE.md has no numbered lists, so it emits nothing (SC-5
|
||||
* byte-stable; the additive OPT scanner entry is stripped from frozen baselines).
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||
|
||||
const SCANNER = 'OPT';
|
||||
const STEP_THRESHOLD = 6;
|
||||
const STEP_RE = /^\s*\d+\.\s+\S/;
|
||||
const PROCEDURE_TITLE = 'A multi-step procedure in CLAUDE.md belongs in a skill';
|
||||
|
||||
// Graceful fallback if the register is missing/unreadable (the register is the
|
||||
// source of truth; this keeps the scanner working without it).
|
||||
const DEFAULT_MECH_003 = {
|
||||
claim:
|
||||
'A multi-step procedure in CLAUDE.md should be a skill — CLAUDE.md is for facts Claude ' +
|
||||
'should hold all the time; procedures belong in skills.',
|
||||
recommendation:
|
||||
'Extract the procedure into .claude/skills/; its body then loads only on invoke instead ' +
|
||||
'of every turn.',
|
||||
};
|
||||
|
||||
/** Return the confirmed register entry for `id`, or null (→ caller uses default). */
|
||||
function confirmedEntry(id) {
|
||||
try {
|
||||
const e = getEntry(loadRegister(), id);
|
||||
return e && e.confidence === 'confirmed' ? e : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest run of consecutive numbered-list items. Blank lines and indented
|
||||
* continuation lines neither extend nor break a run; any other non-step line
|
||||
* breaks it. Conservative by design (a wrapped, non-indented step line ends the
|
||||
* run → undercount, never overcount).
|
||||
* @param {string} text
|
||||
* @returns {{count:number, startIndex:number, firstStep:string}}
|
||||
*/
|
||||
function longestNumberedRun(text) {
|
||||
const lines = String(text).split('\n');
|
||||
let maxRun = 0;
|
||||
let maxStartIdx = 0;
|
||||
let maxFirstStep = '';
|
||||
let run = 0;
|
||||
let runStartIdx = 0;
|
||||
let runFirstStep = '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (STEP_RE.test(line)) {
|
||||
if (run === 0) {
|
||||
runStartIdx = i;
|
||||
runFirstStep = line.trim();
|
||||
}
|
||||
run++;
|
||||
if (run > maxRun) {
|
||||
maxRun = run;
|
||||
maxStartIdx = runStartIdx;
|
||||
maxFirstStep = runFirstStep;
|
||||
}
|
||||
} else if (line.trim() === '' || /^\s+\S/.test(line)) {
|
||||
// blank or indented continuation — part of the list, neither step nor break
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
return { count: maxRun, startIndex: maxStartIdx, firstStep: maxFirstStep };
|
||||
}
|
||||
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const findings = [];
|
||||
const claudeMdFiles = ((discovery && discovery.files) || []).filter((f) => f.type === 'claude-md');
|
||||
|
||||
const entry = confirmedEntry('BP-MECH-003');
|
||||
const claim = (entry && entry.claim) || DEFAULT_MECH_003.claim;
|
||||
const recommendation = (entry && entry.recommendation) || DEFAULT_MECH_003.recommendation;
|
||||
|
||||
let filesScanned = 0;
|
||||
for (const file of claudeMdFiles) {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(file.absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
filesScanned++;
|
||||
|
||||
const parsed = parseFrontmatter(content);
|
||||
const body = parsed.body || content;
|
||||
const bodyStartLine = parsed.bodyStartLine || 1;
|
||||
const run = longestNumberedRun(body);
|
||||
|
||||
if (run.count >= STEP_THRESHOLD) {
|
||||
findings.push(
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: PROCEDURE_TITLE,
|
||||
description: claim,
|
||||
file: file.relPath || file.absPath,
|
||||
line: bodyStartLine + run.startIndex,
|
||||
evidence: run.firstStep,
|
||||
recommendation,
|
||||
category: 'mechanism-fit',
|
||||
details: {
|
||||
mechanism: 'skill',
|
||||
steps: run.count,
|
||||
register: entry ? entry.id : null,
|
||||
source: entry ? entry.source.url : null,
|
||||
confidence: entry ? entry.confidence : null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import { scan as scanDisabledInSchema } from './disabled-in-schema-scanner.mjs';
|
|||
import { scan as scanCollision } from './collision-scanner.mjs';
|
||||
import { scan as scanSkillListing } from './skill-listing-scanner.mjs';
|
||||
import { scan as scanOutputStyle } from './output-style-scanner.mjs';
|
||||
import { scan as scanOptimizationLens } from './optimization-lens-scanner.mjs';
|
||||
|
||||
// Directory names that identify test fixture / example directories
|
||||
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
|
||||
|
|
@ -66,6 +67,7 @@ const SCANNERS = [
|
|||
{ name: 'COL', fn: scanCollision, label: 'Plugin Skill Collision' },
|
||||
{ name: 'SKL', fn: scanSkillListing, label: 'Skill-Listing Budget' },
|
||||
{ name: 'OST', fn: scanOutputStyle, label: 'Output-Style Validation' },
|
||||
{ name: 'OPT', fn: scanOptimizationLens, label: 'Optimization Lens' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue