`optimize --subtract` has only ever proposed. `--apply` executes the blocks the operator picks, behind a backup whose coverage is verified and a scope gate the engine enforces rather than describes. The open design decision from plan §C6 was settled by two measurements, not by taste. It is NOT a fix-engine action: the subtraction axis appears nowhere in scan-orchestrator or optimization-lens-scanner, so verifyFixes' re-scan would mark every removal `verified` whether or not it happened -- a success-shaped no-op, the same shape that made restoreBackup silently do nothing. It is NOT a plan/implement step either: that pipeline needs a finding code, and OPT declares exactly one, for the deterministic check. The approval artifact is written by main context, not by the lens agent. That is where the operator's decision actually happens, and it keeps the feature off the still-unmeasured agent write surface (M-BUG-18 lists optimize as open). Three properties are load-bearing, and each was seen red against its own defect: removals validate against the ORIGINAL content and apply in descending line order; the range check is not redundant with the text check (`line: 0` makes `slice(-1, 0)` empty, so an empty text MATCHES and `splice(-1, 1)` deletes the file's last line); and createBackup skips a nonexistent path while still returning an id, so manifest coverage is asserted before a byte changes. Two guards were green on their own defect and were fixed after measuring: `/\b80\s*%\b/` never matches "80% of the file" -- `%` is a non-word character, so the trailing `\b` demands a word character next. And the caller-arm sweep passed vacuously against HEAD, iterating an empty list; only the added non-emptiness assertion caught it. The floor is repeated, not moved: floor-exclusion still vetoes before anything is proposed, and the engine refuses a load-bearing block again so a hand-built approval cannot route around it. `mv` to `_archive/` is a file-level rule and does not apply to a block excision -- the timestamped backup is the recovery artifact, and a second copy with no restorer would be worse than none. strongestGate moves into write-scope.mjs so the gate ordering has one owner. Dogfooded DRY-RUN against the real ~/.claude/CLAUDE.md: 29 candidates, gate refused all 29 with exit 0 until the scope was approved, then 29/29 spans validated with nothing written. ~789 tokens, ~18% of the file -- corroborating the #40 fasit's ~850, and well short of what a deletion feature is tempted to promise. Suite 1625 -> 1659/0. Frozen v5.0.0 and default-output baselines untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017A6vrtPKsVuM4DJ27p7jzw
147 lines
5 KiB
JavaScript
147 lines
5 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* subtraction-write CLI — execute an APPROVED subtraction set (§C6, chunk #63).
|
|
*
|
|
* The one path in the plugin that removes configuration. It takes no judgement
|
|
* of its own: it is handed a set of blocks a human approved, and its whole job
|
|
* is to refuse anything that no longer matches, is load-bearing, or leaves the
|
|
* repo without an explicit go-ahead.
|
|
*
|
|
* Usage:
|
|
* node subtraction-write-cli.mjs --approved <path.json>
|
|
* [--repo <session-repo-root>]
|
|
* [--approve-scope] [--dry-run]
|
|
* [--output-file <path>] [--json]
|
|
*
|
|
* The approval file is written by MAIN CONTEXT, not by the lens agent —
|
|
* `optimize.md` renders the candidates, the operator picks, and the command
|
|
* materializes the choice. That is where the decision actually happens, and it
|
|
* keeps this path off the unverified agent write surface
|
|
* ([[subagent-harness-blocks-report-writes]] lists optimize as open).
|
|
*
|
|
* { "sessionId": "...",
|
|
* "removals": [ { "file": "...", "line": 12, "endLine": 15, "text": "..." } ] }
|
|
*
|
|
* Exit codes: 0 = verdict, 3 = the CLI could not do its job (bad argv,
|
|
* unreadable or malformed approval file).
|
|
*
|
|
* A gated or refused removal is NOT exit 3. "This write leaves the repo" and
|
|
* "that block no longer looks like that" are verdicts about a write, and they
|
|
* ride in the payload — a command cannot act on something that only ever
|
|
* reached stderr (#62, F3's class).
|
|
*
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { readFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
|
|
import { writeOutputFile } from './lib/write-output.mjs';
|
|
import { requireValidArgs } from './lib/cli-args.mjs';
|
|
import { applySubtraction } from './lib/subtraction-write.mjs';
|
|
|
|
/** Flag surface. Anything else is exit 3. */
|
|
const ARG_SPEC = {
|
|
boolean: ['--json', '--dry-run', '--approve-scope'],
|
|
value: ['--approved', '--repo', '--output-file'],
|
|
};
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
if (!requireValidArgs(args, ARG_SPEC)) return;
|
|
|
|
let approvedPath = null;
|
|
let repo = process.cwd();
|
|
let outputFile = null;
|
|
let jsonMode = false;
|
|
let dryRun = false;
|
|
let approveScope = false;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--json') jsonMode = true;
|
|
else if (args[i] === '--dry-run') dryRun = true;
|
|
else if (args[i] === '--approve-scope') approveScope = true;
|
|
else if (args[i] === '--approved') approvedPath = args[++i];
|
|
else if (args[i] === '--repo') repo = args[++i];
|
|
else if (args[i] === '--output-file') outputFile = args[++i];
|
|
}
|
|
|
|
if (!approvedPath) {
|
|
process.stderr.write('Error: --approved <path> is required\n');
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
|
|
let approval;
|
|
try {
|
|
approval = JSON.parse(await readFile(resolve(approvedPath), 'utf-8'));
|
|
} catch (err) {
|
|
process.stderr.write(`Error: could not read approval file: ${err.message}\n`);
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
|
|
// Malformed input is a tool error, not a verdict: an empty or wrong-shaped
|
|
// approval must not read as "nothing to remove, all done".
|
|
if (!Array.isArray(approval.removals) || approval.removals.length === 0) {
|
|
process.stderr.write('Error: approval file has no `removals` array\n');
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
for (const r of approval.removals) {
|
|
if (!r || typeof r.file !== 'string' || typeof r.text !== 'string') {
|
|
process.stderr.write('Error: every removal needs `file`, `line`, `endLine` and `text`\n');
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const result = await applySubtraction(approval.removals, { repoRoot: repo, dryRun, approveScope });
|
|
|
|
const payload = {
|
|
meta: {
|
|
repo: resolve(repo),
|
|
sessionId: approval.sessionId || null,
|
|
approvedCount: approval.removals.length,
|
|
dryRun,
|
|
},
|
|
gate: result.gate,
|
|
requiresApproval: result.requiresApproval,
|
|
disclosures: result.disclosures,
|
|
targets: result.targets,
|
|
backupId: result.backupId,
|
|
filesWritten: result.filesWritten,
|
|
// The removed text travels back so the caller can show and log exactly what
|
|
// left the file. The backup is the recovery artifact; this is the receipt.
|
|
applied: result.applied,
|
|
refused: result.refused,
|
|
counts: {
|
|
applied: result.applied.length,
|
|
refused: result.refused.length,
|
|
filesWritten: result.filesWritten.length,
|
|
},
|
|
};
|
|
|
|
const json = `${JSON.stringify(payload, null, 2)}\n`;
|
|
if (outputFile) {
|
|
await writeOutputFile(outputFile, json);
|
|
// Nothing on stdout when writing to a file (ux-rules rule 1).
|
|
} else if (jsonMode) {
|
|
process.stdout.write(json);
|
|
} else {
|
|
for (const a of payload.applied) {
|
|
process.stdout.write(`${payload.meta.dryRun ? 'would-remove' : 'removed'}\t${a.file}:${a.line}-${a.endLine}\n`);
|
|
}
|
|
for (const r of payload.refused) {
|
|
process.stdout.write(`refused:${r.reason}\t${r.file}:${r.line}-${r.endLine}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} catch (err) {
|
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
process.exitCode = 3;
|
|
}
|