process.exit() terminates immediately, but Node writes stdout asynchronously when stdout is a pipe — everything still buffered is dropped. scan-orchestrator measured 246 854 bytes to a file against 65 536 to a pipe (131 072 on another run; the cut point is a flush race), so every machine consumer that pipes the envelope got truncated, unparseable JSON. The failure reads like a corrupt file, not like a cut-off, which is what made it survive this long. Reported by org-ops, whose census pipes our output. Closes the class rather than the one CLI where it was visible. campaign-cli, campaign-export-cli, campaign-write-cli, knowledge-refresh-cli, drift-cli and fix-cli all exited the same way on their success paths and were green only because their payloads fit the pipe buffer today; size is not correctness. All 38 sites across 14 files now set process.exitCode and return, which is the pattern self-audit.mjs already used. Two contracts needed care rather than substitution: fail() is a never-returns guard at ~25 call sites, so it throws a CliUsageError the top-level catch renders with the identical "Error: " prefix and exit code 3; the path guards needed an explicit return so main() stops instead of running on. Exit codes and stderr text are unchanged, and the frozen v5.0.0 snapshots are untouched. The class sweep is landed as a test, not as fourteen edits — it caught one site this commit had missed. Suite 1443/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8sS1DuDV6bUJcyumLwbvj
234 lines
8.4 KiB
JavaScript
234 lines
8.4 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 { subtractionCandidates, SUBTRACT_DETECTORS } from './lib/subtraction-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;
|
|
let subtract = false;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--global') includeGlobal = true;
|
|
else if (args[i] === '--subtract') subtract = 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.exitCode = 3;
|
|
return;
|
|
}
|
|
} catch {
|
|
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
|
|
// 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 = [];
|
|
// Opt-in only: the subtraction axis asks a different question and must not
|
|
// fire on a plain `/config-audit optimize` run (brief §7 q3).
|
|
const subtractCands = [];
|
|
const subtractEntry = subtract && register ? confirmedEntry(register, 'BP-SUB-001') : null;
|
|
|
|
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;
|
|
|
|
if (subtractEntry) {
|
|
for (const cand of subtractionCandidates(body)) {
|
|
subtractCands.push({
|
|
file: file.absPath,
|
|
line: bodyStartLine - 1 + cand.startLine,
|
|
endLine: bodyStartLine - 1 + cand.endLine,
|
|
lineCount: cand.lineCount,
|
|
lensCheck: cand.lensCheck,
|
|
mechanism: cand.mechanism,
|
|
signalText: cand.text,
|
|
register: {
|
|
id: subtractEntry.id,
|
|
claim: subtractEntry.claim,
|
|
recommendation: subtractEntry.recommendation || null,
|
|
severity: subtractEntry.severity || 'low',
|
|
source: subtractEntry.source,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}, {}),
|
|
},
|
|
};
|
|
|
|
// Additive ONLY under --subtract: a plain run's payload must stay byte-identical.
|
|
if (subtract) {
|
|
payload.subtract = {
|
|
enabled: true,
|
|
candidates: subtractCands,
|
|
register: subtractEntry
|
|
? [
|
|
{
|
|
id: subtractEntry.id,
|
|
lensCheck: subtractEntry.lensCheck,
|
|
claim: subtractEntry.claim,
|
|
recommendation: subtractEntry.recommendation || null,
|
|
mechanism: subtractEntry.mechanism || null,
|
|
severity: subtractEntry.severity || 'low',
|
|
source: subtractEntry.source,
|
|
},
|
|
]
|
|
: [],
|
|
detectors: SUBTRACT_DETECTORS.map((d) => ({ ...d })),
|
|
};
|
|
payload.counts.subtractCandidates = subtractCands.length;
|
|
}
|
|
|
|
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.exitCode = 3;
|
|
});
|
|
}
|