`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
206 lines
8 KiB
JavaScript
206 lines
8 KiB
JavaScript
/**
|
|
* Write-target scope classification (M-BUG-41).
|
|
*
|
|
* The workflow observes configuration across repos, but every write it then
|
|
* proposes was presented as though it landed where the session stands. Five
|
|
* arms were measured carrying that hole: `implement` (approval prompt names no
|
|
* path at all — only a count), `rollback` (renders repo-relative-looking paths
|
|
* while writing to absolute originals), `fix` (`--global` mixes user-scope and
|
|
* repo rows into one unmarked table), `plan`, and `campaign export`.
|
|
*
|
|
* The gate's STRENGTH comes from the target's scope class, never from which
|
|
* command is asking. Command-owned policy would be five policies to drift apart
|
|
* — the shape that put the lever table in five copies (#61). Both required
|
|
* outcomes then fall out of one table without an exception rule: a plan
|
|
* exported into another repo is *disclosed* (cross-repo is by design there),
|
|
* while a rewrite of `~/.claude/CLAUDE.md` *requires explicit approval*,
|
|
* because it costs in every repo on every turn.
|
|
*
|
|
* `silent` means "no gate of its own", not "no approval": the existing
|
|
* confirmation surfaces stand untouched, and this module only adds location to
|
|
* them.
|
|
*
|
|
* Two orderings below are load-bearing, and both were measured rather than
|
|
* reasoned about:
|
|
*
|
|
* `plugin-managed` before `user-scope` — the canonical
|
|
* `~/.claude/config-audit/` and the legacy `~/.config-audit/` both exist on a
|
|
* real machine, and every command writes session state into them. Matched the
|
|
* other way round, the gate fires on every write ever made and gets switched
|
|
* off, which is worse than having no gate.
|
|
*
|
|
* `user-scope` before `cross-repo` — `~/.claude/.git` exists (the operator's
|
|
* `~/.claude` is a git repo whose `.gitignore` is `*`). A plain
|
|
* `.git`-upward-walk therefore answers "another repo" for
|
|
* `~/.claude/CLAUDE.md`, silently downgrading the strongest gate on the
|
|
* subtraction axis's primary target to disclosure-only.
|
|
*
|
|
* This module classifies. It never writes, never prompts, and never decides
|
|
* whether an approved write is a good idea.
|
|
*/
|
|
|
|
import { existsSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
|
|
/**
|
|
* True when `child` is `parent` itself or lives underneath it.
|
|
*
|
|
* Uses `relative()` rather than `startsWith()`: a sibling directory whose name
|
|
* merely prefixes the parent's (`my-plugin-2` against `my-plugin`) satisfies
|
|
* `startsWith` and would skip the gate entirely.
|
|
*
|
|
* @param {string} parent - Absolute directory path.
|
|
* @param {string} child - Absolute path to test.
|
|
* @returns {boolean}
|
|
*/
|
|
function isWithin(parent, child) {
|
|
const rel = relative(parent, child);
|
|
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
}
|
|
|
|
/**
|
|
* Default repo-root test. Kept injectable so classification is testable
|
|
* without a fixture tree.
|
|
*
|
|
* @param {string} dir - Absolute directory path.
|
|
* @returns {boolean}
|
|
*/
|
|
function defaultIsRepoRoot(dir) {
|
|
return existsSync(join(dir, '.git'));
|
|
}
|
|
|
|
/**
|
|
* Walk upwards from `absPath` looking for the nearest enclosing repo root.
|
|
*
|
|
* @param {string} absPath - Absolute path to start from.
|
|
* @param {(dir: string) => boolean} isRepoRoot - Repo-root predicate.
|
|
* @returns {string|null} The nearest repo root, or null if there is none.
|
|
*/
|
|
function nearestRepoRoot(absPath, isRepoRoot) {
|
|
let dir = absPath;
|
|
for (;;) {
|
|
if (isRepoRoot(dir)) return dir;
|
|
const parent = resolve(dir, '..');
|
|
if (parent === dir) return null;
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The scope classes, in match order.
|
|
*
|
|
* Declaration order IS match order — this object is the single source for the
|
|
* class name, its gate, its disclosure wording and its predicate, so no caller
|
|
* and no test can hold a second copy that drifts.
|
|
*
|
|
* @type {Record<string, {gate: 'silent'|'disclose'|'require-ok', disclosure: string|null, matches: Function}>}
|
|
*/
|
|
export const SCOPE_CLASSES = {
|
|
// The plugin's own bookkeeping: session state, backups, ledgers. Not the
|
|
// user's configuration, and written on essentially every run.
|
|
'plugin-managed': {
|
|
gate: 'silent',
|
|
disclosure: null,
|
|
matches: (target, ctx) => ctx.pluginRoots.some((root) => isWithin(root, target)),
|
|
},
|
|
|
|
// Where the session stands. The ordinary case.
|
|
'in-repo': {
|
|
gate: 'silent',
|
|
disclosure: null,
|
|
matches: (target, ctx) => ctx.repoRoot !== null && isWithin(ctx.repoRoot, target),
|
|
},
|
|
|
|
// Machine-wide configuration: loaded in every repo, on every turn, so the
|
|
// cost of a change here is not confined to the project in front of the user.
|
|
'user-scope': {
|
|
gate: 'require-ok',
|
|
disclosure: 'This writes to your machine-wide Claude configuration, outside this project. '
|
|
+ 'It affects every project you open, so it needs your explicit go-ahead.',
|
|
matches: (target, ctx) => isWithin(ctx.userConfigRoot, target),
|
|
},
|
|
|
|
// A different project. Some commands do this by design; the gate is to say
|
|
// so, not to refuse.
|
|
'cross-repo': {
|
|
gate: 'disclose',
|
|
disclosure: 'This writes into a different project than the one you are working in. '
|
|
+ 'Any directories it needs there will be created.',
|
|
matches: (target, ctx) => {
|
|
const root = nearestRepoRoot(target, ctx.isRepoRoot);
|
|
return root !== null && root !== ctx.repoRoot;
|
|
},
|
|
},
|
|
|
|
// Neither this project, nor another project, nor machine-wide config.
|
|
'outside': {
|
|
gate: 'require-ok',
|
|
disclosure: 'This writes to a location outside any project and outside your Claude '
|
|
+ 'configuration, so it needs your explicit go-ahead.',
|
|
matches: () => true,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Gate strengths, weakest first. Lives here rather than in a caller: a second
|
|
* copy of this ordering would decide, independently, which gate a multi-target
|
|
* write shows — the drift shape the class table itself exists to prevent.
|
|
*/
|
|
export const GATE_RANK = ['silent', 'disclose', 'require-ok'];
|
|
|
|
/**
|
|
* The strongest gate among already-classified targets. One `require-ok` target
|
|
* in a set drives the whole surface: a run that would write machine-wide config
|
|
* does not get to be quiet because most of its other targets are ordinary.
|
|
*
|
|
* @param {Array<{gate: string}>} targets
|
|
* @returns {string} The strongest gate, or 'silent' when there are no targets.
|
|
*/
|
|
export function strongestGate(targets) {
|
|
let worst = 'silent';
|
|
for (const t of targets) {
|
|
if (GATE_RANK.indexOf(t.gate) > GATE_RANK.indexOf(worst)) worst = t.gate;
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
/**
|
|
* Classify a write target relative to the repo the session stands in.
|
|
*
|
|
* @param {string} targetPath - The path that is about to be written.
|
|
* @param {string|null} sessionRepoRoot - Repo root of the current session.
|
|
* @param {object} [options]
|
|
* @param {(dir: string) => boolean} [options.isRepoRoot] - Repo-root predicate.
|
|
* @param {string} [options.home] - Override for the home directory.
|
|
* @returns {{scopeClass: string, gate: string, disclosure: string|null, target: string}}
|
|
*/
|
|
export function classifyWriteTarget(targetPath, sessionRepoRoot, options = {}) {
|
|
const home = options.home ?? homedir();
|
|
const isRepoRoot = options.isRepoRoot ?? defaultIsRepoRoot;
|
|
|
|
const target = resolve(targetPath);
|
|
const ctx = {
|
|
repoRoot: sessionRepoRoot === null || sessionRepoRoot === undefined
|
|
? null
|
|
: resolve(sessionRepoRoot),
|
|
userConfigRoot: join(home, '.claude'),
|
|
// Both roots are live: `backup.mjs` prefers `~/.claude/config-audit/` and
|
|
// falls back to the legacy `~/.config-audit/`.
|
|
pluginRoots: [
|
|
join(home, '.claude', 'config-audit'),
|
|
join(home, '.config-audit'),
|
|
],
|
|
isRepoRoot,
|
|
};
|
|
|
|
for (const [scopeClass, spec] of Object.entries(SCOPE_CLASSES)) {
|
|
if (spec.matches(target, ctx)) {
|
|
return { scopeClass, gate: spec.gate, disclosure: spec.disclosure, target };
|
|
}
|
|
}
|
|
|
|
// Unreachable: `outside` matches unconditionally. Kept so a future edit that
|
|
// narrows the last predicate fails loudly instead of returning undefined.
|
|
throw new Error(`write-scope: no class matched ${target}`);
|
|
}
|