config-audit/scanners/optimize-lens-cli.mjs
Kjell Tore Guttormsen 1d63492617 fix(acr): optimize lens scopes out plugin-bundled CLAUDE.md + unique candidate paths (M-BUG-11)
The optimize lens CLI fed its precision-gate agent every CLAUDE.md that discovery
returned, including the 256 files under ~/.claude/plugins/ — vendored plugin
CLAUDE.md across every cached version (7 config-audit, 6 ms-ai-architect, 5 okr,
...) plus their bundled tests/fixtures and examples. Running `optimize --global`
on this machine produced 454 candidates across 92 "files", ~250 of them sourced
from plugin-internal files a user cannot act on (the plugin overwrites them on
update). Same class as M-BUG-2: plugin-bundled config is not the user's cascade.

Second defect: candidates were keyed by `relPath || absPath`, but relPath
collides across scopes — a repo-root `CLAUDE.md` and the user-global
`~/.claude/CLAUDE.md` both relPath to `CLAUDE.md`. The two files that actually
matter were merged into one indistinguishable bucket (21 candidates), the agent's
Read(file) would resolve the wrong one, and cache-file relPaths were not readable
relative to cwd at all.

Fix (lens-CLI-local, surgical):
- Filter isPluginBundled (absPath under `.claude/plugins/`) from discovery for
  BOTH halves of the motor (candidate loop + the OPT scanner, which reads
  discovery.files directly). Drops vendored files regardless of active/stale
  version, so excludeCache is unnecessary here.
- Key each candidate by absPath: unique + readable.
No change to file-discovery.mjs or the OPT scanner, so their byte-stable
snapshots are untouched.

Suite 1348/0 (+4: candidate scoping, real-config survives, deterministic scoping,
absolute-path identity). Frozen v5.0.0 + SC-5 snapshots untouched (the lens CLI
has no snapshot; the command is agent-driven, not byte-stable). Dogfood ~/.claude
`optimize --global`: candidates 454->45, deterministic 2->0 (both were stale
plugin-cache copies), distinct files 92->11, repo vs user-global now distinct
(12 + 9 = 21). Residual 45 includes config-audit's own tests/fixtures CLAUDE.md
(repo-specific dogfooding artifact, not a general bug — left alone).
2026-06-30 06:44:36 +02:00

179 lines
6.5 KiB
JavaScript

#!/usr/bin/env node
/**
* optimize-lens CLI — feeds the v5.7 optimization lens (CA-OPT) `/config-audit
* optimize` command. It produces the two halves of the hybrid motor as one JSON
* payload:
*
* 1. `deterministic` — the OPT scanner's high-precision findings (CA-OPT-001:
* a long numbered procedure in CLAUDE.md → skill). Already part of the
* orchestrated audit; surfaced here so /optimize is a complete view.
* 2. `candidates` — recall-oriented prose-judgment candidates from the
* lens-prefilter (lifecycle → hook, unscoped path-specific → rule, "never"
* → permission), each stamped with the CONFIRMED register entry it might fit
* (claim / recommendation / source / severity). The opus
* optimization-lens-agent is the precision gate over these.
*
* Only CONFIRMED register entries are attached (Verifiseringsplikt); a candidate
* whose register rule is missing or unconfirmed is dropped, so the agent never
* sees an unverifiable recommendation.
*
* Usage:
* node optimize-lens-cli.mjs [path] [--output-file <path>] [--global]
*
* Exit codes: 0=ok, 3=unrecoverable error. Zero external dependencies.
*/
import { resolve, sep } from 'node:path';
import { writeFile, readFile, stat } from 'node:fs/promises';
import { discoverConfigFiles } from './lib/file-discovery.mjs';
import { resetCounter } from './lib/output.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
import { scan as optScan } from './optimization-lens-scanner.mjs';
// Files under `.claude/plugins/` are shipped by an installed plugin — vendored
// CLAUDE.md plus its bundled tests/fixtures and examples. They are not the user's
// authored config, so a mechanism-fit suggestion against them is not actionable
// (the user can't edit a file the plugin overwrites on update). Excluded from the
// lens regardless of active/stale version. (M-BUG-11; mirrors the M-BUG-2 rule
// that keeps plugin-bundled config out of the conflict detector.)
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
const isPluginBundled = (file) => (file.absPath || '').includes(PLUGIN_TREE_MARKER);
/** Confirmed register entry for `id`, or null. */
function confirmedEntry(register, id) {
const e = getEntry(register, id);
return e && e.confidence === 'confirmed' ? e : null;
}
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let outputFile = null;
let includeGlobal = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--global') includeGlobal = true;
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
else if (!args[i].startsWith('-')) targetPath = args[i];
}
const absPath = resolve(targetPath);
try {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
}
// Load the register once; tolerate its absence (deterministic half still runs).
let register = null;
try {
register = loadRegister();
} catch {
register = null;
}
resetCounter();
const rawDiscovery = await discoverConfigFiles(absPath, { includeGlobal });
// Scope the lens to the user's authored config: drop plugin-bundled files for
// BOTH halves of the motor (the OPT scanner reads discovery.files directly).
const discovery = {
...rawDiscovery,
files: (rawDiscovery.files || []).filter((f) => !isPluginBundled(f)),
};
// ── Deterministic half: the OPT scanner (CA-OPT-001) ──
const opt = await optScan(absPath, discovery);
// ── Recall half: prose-judgment candidates from the pre-filter ──
const claudeMdFiles = (discovery.files || []).filter((f) => f.type === 'claude-md');
const candidates = [];
for (const file of claudeMdFiles) {
let content;
try {
content = await readFile(file.absPath, 'utf-8');
} catch {
continue;
}
const parsed = parseFrontmatter(content);
const body = parsed.body || content;
const bodyStartLine = parsed.bodyStartLine || 1;
for (const cand of prefilterClaudeMd(body)) {
const entry = register ? confirmedEntry(register, cand.registerId) : null;
if (!entry) continue; // never surface an unverifiable recommendation
candidates.push({
// Absolute path: unique + readable. relPath collides across scopes
// (a repo-root `CLAUDE.md` and the user-global `~/.claude/CLAUDE.md`
// both relPath to `CLAUDE.md`), which would send the agent's Read() to
// the wrong file. (M-BUG-11)
file: file.absPath,
line: bodyStartLine - 1 + cand.line,
lensCheck: cand.lensCheck,
mechanism: cand.mechanism,
signalText: cand.text,
register: {
id: entry.id,
claim: entry.claim,
recommendation: entry.recommendation || null,
severity: entry.severity || 'low',
source: entry.source,
},
});
}
}
// The CONFIRMED prose-judgment entries, so the agent has full provenance even
// for a detector class that produced no candidates this run.
const registerEntries = register
? LENS_DETECTORS.map((d) => confirmedEntry(register, d.registerId))
.filter(Boolean)
.map((e) => ({
id: e.id,
lensCheck: e.lensCheck,
claim: e.claim,
recommendation: e.recommendation || null,
mechanism: e.mechanism || null,
severity: e.severity || 'low',
source: e.source,
}))
: [];
const payload = {
status: 'ok',
target: absPath,
deterministic: opt.findings || [],
candidates,
register: registerEntries,
counts: {
deterministic: (opt.findings || []).length,
candidates: candidates.length,
byLensCheck: candidates.reduce((acc, c) => {
acc[c.lensCheck] = (acc[c.lensCheck] || 0) + 1;
return acc;
}, {}),
},
};
const json = JSON.stringify(payload, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');
}
if (!outputFile) {
process.stdout.write(json + '\n');
}
}
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}