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:
Kjell Tore Guttormsen 2026-06-20 23:10:03 +02:00
commit e7833b65fc
14 changed files with 350 additions and 38 deletions

View file

@ -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.',
},
},
};

View file

@ -42,6 +42,7 @@ const SCANNER_TO_CATEGORY = {
GAP: 'Missed opportunity',
PLH: 'Configuration mistake',
OST: 'Configuration mistake',
OPT: 'Missed opportunity',
};
/**

View file

@ -169,6 +169,7 @@ const SCANNER_AREA_MAP = {
DIS: 'Settings',
COL: 'Plugin Hygiene',
OST: 'Settings',
OPT: 'CLAUDE.md',
};
/**

View 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);
}

View file

@ -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' },
];
/**