Compare commits

...

71 commits

Author SHA1 Message Date
d5714261d1 fix(agents): an agent cannot promise what its tools forbid
R4 — `verifier-agent.md` carried two contracts at once: §Output Format said
"Append to: implementation-log.md", §Read-Only Guarantee said "never modifies
any files", and `tools:` granted only Read/Glob/Grep. Which one wins is
nondeterministic, and the loss is not a blocked write but a full-file Write on
the log the agent SHARES with the parallel implementer agents — the defect
`implement-log-append.test.mjs` exists to prevent, entering through the one file
that test does not read. The orchestrator half was already right
(`implement.md` Step 5 appends with Bash `>>` and tells the agent not to write),
so the fix is one-way: the agent file now returns its report inline and names
who appends it, and why a Write there would clobber.

The guard is the blanket invariant over the catalogue, not a fact about one
file: any agent whose tools grant no write capability must instruct no write AND
say positively that it returns findings inline. Tools and body are both read, so
stripping `Write` from any agent whose body still writes turns it red. Measured
1 of 7 agents carried the defect; the sweep asserts a write-tool-less agent
exists so the invariant cannot pass vacuously.

R6 — both "Required Frontmatter" rules were enforced by nothing, and the only
test reading agent frontmatter checked `name:` against a hand-written 3-of-7
list. The new guard takes nothing by hand: required keys are parsed from each
rule's own yaml block, the swept files from each rule's own `paths:`, the plugin
name from plugin.json — add a key to a rule and it is enforced next run. The
repo was already 7/7 and 21/21 compliant, so a green first run proves nothing:
all seven arms were seen red against a temporarily introduced defect one at a
time, including emptying a rule's yaml block to show the derivation is not
vacuous.

The color enum is deliberately NOT guarded: the official subagent docs list
red/blue/green/yellow/purple/orange/pink/cyan (no magenta) while issue 19292
lists magenta but neither purple nor orange, and this plugin ships both. Pinning
an unsettled set would encode an unverified premise rather than measure one.

Suite 1777 -> 1785, 0 fail. Frozen tests/snapshots/v5.0.0/ untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEMKCAyVzYTMzLaqPcULVr
2026-08-20 22:59:51 +02:00
44b222859e feat(scanners): the recovery path is code you can run, not prose you can read
R1+R2 as one chunk — both KRITISK rows of the Q3 severity table sit on the
restore path, and neither closes alone.

R1: rollback-engine.mjs verified every checksum before AND after each write,
resolved the legacy backup root and reported createdNotRemoved — and none of it
was reachable. Measured: 16 files under scanners/ carry a process.argv entry;
the engine was not one of them. commands/rollback.md drove the restore as model
prose: an ESM import block a template cannot execute, ad-hoc `cp` offered
underneath as the runnable path, and "(checksum verified)" pre-rendered three
times in the success output. `cp` establishes no checksum, so the verification
was a property of the template rather than of the run — on the one surface that
runs when the user is already in trouble.

R2: implement.md Step 3 hand-built its backup (mkdir, cp, a date-derived id, a
manifest typed out in the template) while parseManifest knew one frozen sample
of that format, pinned by a HAND-WRITTEN fixture instead of by the template's
own text. Rename a key and parseManifest returns zero files while rollback
reports success.

Fixing only R1 leaves the new CLI parsing a prose format; fixing only R2 leaves
a clean format with no runnable entry.

- scanners/rollback-cli.mjs — --list / --create / --restore / --delete over the
  existing engine, on the shared requireValidArgs gate. Exit 0 done, 1
  outstanding (gate refusal with nothing written, or a backup that covered fewer
  targets than given), 2 a file failed, 3 could not do the job. A gated restore
  is 1, not 3: "this write leaves your project" is a verdict about a write that
  WAS examined, and it rides in the payload where a command under 2>/dev/null
  can act on it.
- createBackup gains `created` (recorded, never copied — no backup can hold a
  file that does not exist) and `skipped`, so a backup covering fewer files than
  asked is no longer indistinguishable from a clean one.
- implement.md Step 3 and rollback.md now call the CLI. parseManifest's
  implement-format branch stays: nothing writes that shape now, but every backup
  made before this chunk is on disk in it.
- backup-restore-contract.test.mjs checks every field rollback.md renders
  against a payload produced by RUNNING the CLI. That is what replaced
  "(checksum verified)".

20 guards seen red against the original state before any production code, then
each against its own defect. Two holes that surfaced there were mine: the
implement assertion matched `--create` as a substring of `--created` and stayed
green when the call was removed; and mutating the argv gate showed
requireValidArgs sets exit 3 by itself, so a CLI can report that it could not
parse its arguments and still run the restore underneath — that case is now
asserted on the bytes.

Suite 1752 -> 1777, 0 fail. Frozen tests/snapshots/v5.0.0 untouched. Dogfooded
through the templates' own command lines against a sandboxed HOME, including the
machine-wide arm: refused with the file unchanged, then restored under
--approve-scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Logq8GGWKhtyDem63FTEnG
2026-08-18 21:28:15 +02:00
b35ff449e8 chore(release): v6.0.0 — "Prose is not a contract" (MAJOR: finding IDs name the check)
MAJOR, because M-BUG-28 changed what a finding ID means: the {NNN} in
CA-{SCANNER}-{NNN} names the CHECK, not its emission position. IDs are
therefore not unique per finding -- one check failing in three files emits
three findings sharing an ID -- and any consumer keying on `id` alone must
move to (id, file, line).

The release theme is a class of defect rather than a feature area. Three
sweeps (Q1, Q2, Q_AUDIT) kept surfacing the same shape: a command template
stated an invariant in prose, code on the other side depended on it, and
nothing checked that the two still agreed. Q1 put the write-scope gate in
code (measured: 9 writers under scanners/, 1 imported the gate). Q2 checks
each template's argv against the CLI that receives it (--stale-after 30
reached its CLI as one argument under zsh and was ignored while the command
reported success). Q_AUDIT measured and ranked the third instance --
data contracts hand-built by the model and parsed by engines that know one
frozen example -- with the recovery path on top, deliberately not yet fixed.

Measured this session, not carried forward:
  37 commits since v5.13.0 (git log v5.13.0..HEAD --oneline | wc -l)
  1752 tests, 0 failing, post-bump
  self-audit --check-readme: PASS, readmeCheck.passed, 0 mismatches
  check-versions.mjs: 0 ERROR (1 WARN = the unreleased bump this cut closes)
  scanners 16, agents 7, commands 21, hooks 4, knowledge 8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeJyEVn9GuBcBQbE6EYwm
2026-08-18 20:57:58 +02:00
c3af74dfbc docs: add SECURITY.md with vulnerability reporting policy
Reporting address, canonical repo, response process, and a supported-
versions table anchored to the current v5.13.0 tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Kp4MhrqsJRbcGyjS3TnP
2026-08-16 21:14:25 +02:00
adcf44fe4e fix(scanners): a moved-finding pair is humanized on both sides, not flattened into one
drift-cli ran the flat-finding humanizer directly over movedFindings, an array
of {from, to} pairs. humanizeFinding builds a new object from named finding
fields only, so from/to were silently dropped and the report's m.from.severity
crashed on undefined — exit 3, no output file, every time a drift diff had a
moved finding in default (non-JSON) mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjJAbZp4beBu6xM3RrGK5j
2026-08-14 21:33:10 +02:00
7df8e0d65b feat(scanners): a redundancy claim that belongs to one model is scoped to it
Anthropic documents that Claude Opus 5 verifies its own work, and that telling
it to double-check or to delegate verification to a subagent causes
over-verification -- token cost with no quality gain. The general subtraction
detector (BP-SUB-001) already surfaces those blocks for every user, with no
model-awareness at all.

`optimize --subtract --for-model <name>` adds the missing half. It ANNOTATES a
subset of the candidates --subtract already produced; it is not a second
detector and can never widen the candidate set. A second SUBTRACT_DETECTORS
entry would have collided with BP-SUB-001 on de-dup, and a prose-only signal in
the agent prompt would have been untestable.

There is no auto-detection, by measurement rather than omission: a CLAUDE.md has
no frontmatter and no resolvable target model, and this operator's own `route`
skill deliberately runs a different model per session -- the same file is read
by whichever model comes next. So the model is named, and the citation is
reported as conditional everywhere a human sees it (agent report copy, and the
Step 7a listing that is the last surface before an approval file).

Precision comes from the TARGET, not the verb list. Measured across the
409-file corpus: 392 BP-SUB-001 candidates, 31 (7.9%) carry a verify verb, and
0 also carry a reflexive or delegated target. Two independent raw-text greps
found 0 as well, so the zero is the corpus rather than an over-narrow regex.
Those 31 verb-only blocks -- "sjekk relevante config-filer", "Type-sjekk:
pyright", "To verify plugin functionality" -- are exactly the false positives a
verb-only version would have produced, which is BP-JUDG-001's 7/7 failure
arriving one lens over. The numbers live in the register entry's note and are
pinned by a test, because a session that cannot see the measurement reads the
zero as a broken detector and loosens it.

`recognized` is reported separately from `matchedCount`: a typo'd model name and
a genuinely clean config both yield zero, and without the distinction the CLI
would report a silent no-op as good news. Dogfooded on the real machine --
`opus-5` gives recognized:true/matchedCount:0, `oppus5` gives recognized:false.

source.published is absent because the guide carries no visible publish date;
its absence is asserted so a later session does not invent one to match the
other entries' shape. Both quoted sentences were verified verbatim 2026-08-12.

The payload stays additive -- forModel and per-candidate modelScope appear only
under the flag, so a plain --subtract run is byte-identical to before (asserted
on the serialized bytes, since a key set to undefined passes a shallow check).

Suite 1724 -> 1752 (+28). The one remaining failure is the pre-existing
drift-cli --output-file crash, untouched by this work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRuXt6tZyowi8QYNKLSHQm
2026-08-12 23:16:23 +02:00
05b4e9d797 docs: consolidate GOVERNANCE.md to the canonical repo-standard copy (D11)
Removes the local copy (byte-identical baseline, md5 736fc9d6) and
repoints the README link to the canonical file in repo-standard, which
differs only by generalising plugin/marketplace wording to
repository/organisation. config-audit's class (plugin) has never
required GOVERNANCE.md in required_files, so nothing was gated on it.
2026-08-12 22:23:51 +02:00
dc800560b7 docs(plan): the prose-invariant sweep finds the class's third instance (Q_AUDIT)
Q1 was gate-in-prose, Q2 was argv-in-prose; the sweep's answer to 'what is
the third' is data-contract-in-prose: templates hand-build files (backup
manifest, session state) that engines and hooks later parse. Rated list of
9, topped by two recovery-path findings: the rollback engine has no CLI
entry (restore runs as model prose with a pre-rendered 'checksum verified'
line), and implement's hand-built manifest format is pinned only by a
hand-written fixture — the seam that already produced one success-shaped
no-op. Every number in the doc is command-produced (Fable session, no
advisor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeVSUuNqSCDUgTvfLKv4fK
2026-08-12 22:14:48 +02:00
30c78aeda0 fix(scanners): the command layer's argv is now checked against the CLI that receives it
A command template is a caller with no compiler behind it. It names a scanner and
an argv; nothing checked that the scanner still accepts them. M-BUG-45 measured
what that costs: `--stale-after` arrived malformed, was ignored, and the command
reported "all 14 entries re-verified within the last 90 days" about a threshold
the user had just overridden.

The new guard builds the argv from each template's OWN text (#63 — a hand-typed
call is a path no user takes), reading all three forms a flag appears in,
including the comment-only `GLOBAL_FLAG=""  # --global`; that third form is the
one that dies unobserved, since the default path leaves the variable empty.
Measured: 38 invocations, 54 (CLI, flag) pairs, 15 CLIs, 0 dead scanner paths.

Two premises in the plan text were falsified by measuring:

  - "the flag exists in the CLI's BOOL_FLAGS/VALUE_FLAGS" — only 3 of 34 scanner
    files declare such a surface. The contract is checked on BEHAVIOUR instead:
    run the CLI, ask whether it calls the flag unknown.
  - `--full-machine` was predicted dead on `posture`. It is live. The fasit was
    wrong, not the code.

What the measurement found instead: `campaign-export-cli` was the only one of the
fifteen without the shared `requireValidArgs` gate. Its hand-rolled chain guards
every value branch with `argv[i + 1] !== undefined`, so a trailing `--repo` fell
past all of them to the `startsWith('--')` catch-all and was reported as an
unknown flag — about the flag the CLI itself requires. Classification of "value
flag, no value" across all fifteen: 14 correct, 1 wrong. It now uses ARG_SPEC +
requireValidArgs like the other twelve; valid argv reaches the existing loop
byte-for-byte unchanged. Special-casing it in the test would have rebuilt, in
test code, the prose exception Q1 deleted.

And what the guard itself got wrong, which is worse than what it was looking for:
probing a flag means RUNNING the CLI, and some flags are writers. Its first run
let `drift-cli --save` default its target to the working directory and overwrite
the operator's real ~/.config-audit/baselines/default.json — an ungated write
outside the repo, produced by the guard whose whole subject is ungated writes
outside the repo. Every probe now runs under hermeticEnv() with its own empty
cwd, and the cwd is asserted empty afterwards. Isolation that is only a
convention is not isolation. Side effect: 65s -> 13s, because a hermetic HOME
stops every probe from enumerating ~/.claude.

All six arms seen RED against their own defect, twice — including the ORIGINAL
class (remove --approve-scope from fix-cli) and the plan's own verification
(delete the write-scope-cli line from a template). The non-emptiness arm is
derived from the tree, not pinned to a count that would only be a drift point.

Suite 1707 -> 1724, frozen v5.0.0 + default-output snapshots 0 changed files.

Not fixed here, found while verifying and pre-existing at 749b710: the suite was
NOT green on HEAD. output-file-robustness fails on drift-cli, root cause
diff-engine.mjs:194 — `m.from.severity` where `m.from` is undefined in the moved
section of the drift report. It crashes after the scan, in formatting, so the
CLI exits 3 with no output file. Its own chunk, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pj6UoTi6iPsAB2B2j6EZ1k
2026-08-12 21:58:38 +02:00
749b710de7 feat(scanners): the write gate now runs in code, not in the templates' prose
`write-scope.mjs` has existed since M-BUG-41, but only one writer ever called
it. Measured 2026-08-12: 9 files under `scanners/` write to disk, 1 imported
the gate; 21 command templates, 17 mention a write, 5 call `write-scope-cli`.
Five templates paraphrasing one policy is the shape that put the lever table in
five copies (#61) — one level up.

The defect was never "8 ungated writers = 8 bugs". Four of them write the
plugin's own bookkeeping and must STAY ungated: a gate that fires on every run
gets switched off, and then it guards nothing. The defect is that nothing
declared WHICH, so the question was answered by reading, and answered
differently each time it was asked.

`tests/lib/write-gate-coverage.test.mjs` makes the answer structural: every
writer either imports the gate or holds an EXEMPT entry naming where the bytes
land. Seen RED against today's tree before the fix (4 ungated writers), and
each of its four assertions was separately seen red against its own defect.

Two premises in the plan text were falsified by measuring them first:

  - `scan-orchestrator` was carried as "plugin-managed, legitimately exempt".
    `--save-baseline` derives its path from the SCAN TARGET, so `--global`
    lands `~/.claude/.config-audit-baseline.json` — user-scope, require-ok. It
    is gated. `lib/baseline.mjs` is the genuinely exempt one.
  - the first sweep scored 9 writers with a regex that could not match
    `writeFileSync(`, so `lib/backup.mjs` — a real writer — read as clean. The
    guard covers sync and async forms, strips comments before matching, and
    asserts non-emptiness so a regex that stops matching cannot make every
    other assertion vacuously green (#63, #64).

Gated: fix-engine, rollback-engine, campaign-export-cli, scan-orchestrator.
All five call sites share ONE reduction, `evaluateWriteTargets` — four copies
of classify/strongestGate/dedup is the drift this exists to prevent.

`campaign export` still DISCLOSES rather than refuses: cross-repo is by design
there, and tightening it into a refusal would break the feature. A dry run is
still not a write, so it is never gated (#63). A refusal is a verdict about a
config that WAS examined, so it rides in the payload and keeps the 0/1/2 exit
contract (#62) — and the verdict now reaches the success payload too, since
stderr is discarded by `2>/dev/null` (F3's class).

commands/fix.md carries `--approve-scope` from the answer the user gives, with
the rule stated where it can be read: classifying is not approving.

Dogfooded end to end: a target outside the session root refuses with zero bytes
written, then applies under `--approve-scope`.

Suite 1703 -> 1707/0. Frozen v5.0.0 + default-output snapshots: 0 changed files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pkn22uGCgk6QZA738zNmHL
2026-08-12 21:15:16 +02:00
e60b80978b docs(plan): the gate defect was a tracking defect, not a model defect (v6 quality plan)
The unenforced scope gate in fix-engine was already written down in STATE's
open-items paragraph, formatted identically to "4 inline copies of a target
guard". A stronger model reading that paragraph reaches the same conclusion,
because nothing in the data says one item can let a write reach
~/.claude/CLAUDE.md unapproved and the others cannot. The missing thing is a
severity axis, not reasoning power.

Second occurrence of one class: #63 was a gate not firing because the command
layer was untested; #65 is a gate not firing because the engine never reads
it. Two instances of "only prose stood behind a write gate".

Measured, not asserted: 9 writers in scanners/, 1 imports the gate; 21 command
templates, 17 name a write, 5 invoke write-scope-cli. The 8 ungated writers
are mostly legitimate — the defect is that nothing declares WHICH, so the
question is answered by reading rather than by a guard.

Plan: Q1 gate into code + explicit exemption table (blocks the release), Q2
contract tests built from each template's own text, Q_AUDIT one Fable session
to find the rest of the class, Q3 severity axis in tracking, Q4 release
v6.0.0. Model routing per chunk — not a blanket upgrade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mCkx9wGywqNQzsXkBMzJ1
2026-08-12 20:48:52 +02:00
6bb100f2e0 docs(knowledge): the judgment axis is knowledge, not a detector (B2)
Article rule 1 ("give Claude judgement instead of rules") gets its register
entry, and deliberately no detector. `BP-JUDG-001` carries `lensCheck: null`.

The cut between deterministic prefilter and prose judge was the open design
decision. It was settled by measurement, and the measurement declined both
halves:

- 409 real CLAUDE.md files (38488 lines, 8689 prose blocks): the caging class
  fires 7 times, and all 7 are false positives ("rendered prose-side",
  "naming is a flag on the class"). Verified along an independent grep path
  that bypasses block-splitting and sentence-splitting entirely, in both word
  orders: 5 lines and 1 line, none an instruction.
- The narrow variant (absolute + form noun + numeric cap) fired 8 times —
  one duplicated block seen seven times across plugin caches, precision 0 %.
  The pre-committed rule required 90 % over 20 distinct fires.
- Where the shape does occur — 45 lines across 4755 skill/agent/command files
  — it is the author's editorial policy (emoji, sentence length, slide
  titles). Nothing in the text separates that from a vendor's over-tight
  guardrail, and the article's reasoning does not transfer: the model is not
  the author of a user's config.

So no CA-OPT-002; finding-codes keeps OPT next-free = 2. The numbers live in
the entry's own `note`, so the next session does not re-derive the question.

Two premises the chunk falsified. The brief justified a separate axis by
saying these blocks sit inside `floor-exclusion`'s floor — but the article's
own canonical line carries no floor marker at all, so "inside the floor"
cannot define the axis (the corpus tendency is 76 %, which is a tendency, not
a mechanism). And the fasit's own form-noun vocabulary was wrong: `name` and
`format` alone drove 97 % of fires.

Not folded into `--subtract`: a third "loosen instead of delete" verdict in
the subtraction judge is the AS#5 mixing STATE forbids, and with the corrected
vocabulary there are 0 collisions to arbitrate anyway.

Guards, both seen red against their own defect first: the entry must exist,
be confirmed, date its source and name NO lensCheck; and every lensCheck in
the register must be backed by a real detector.

No behaviour changed — no new finding, no output change, nothing consumes the
entry yet — hence `docs`, not `feat`. Suite 1701 -> 1703/0; frozen v5.0.0 and
default-output baselines 0 changed files. Fasit:
docs/b2-judgment-lens-fasit.local.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mCkx9wGywqNQzsXkBMzJ1
2026-08-12 20:31:22 +02:00
dbb6a6a3cf feat(scanners): a path written in prose is now resolved, not assumed (C3)
`import-resolver` follows @import targets; a path written in ordinary prose
was checked by nothing. CA-CML-013 resolves those too — one finding per file,
severity low, against both the CLAUDE.md's own directory and the scan root,
because a nested file may legitimately write repo-root-relative paths.

The design work here is the SILENCE list, and every entry on it was measured
against 407 real CLAUDE.md files rather than argued for:

- Bare filenames excluded: admitting them tripled the output (2350 vs 810),
  led by name-drops of tools that exist elsewhere on the machine.
- Org/repo slugs, npm packages, pytest node ids and prose enumerations
  excluded: 111 fires, inspected, all false positives.
- Bare folder names excluded on the same reasoning one level up: 183 of the
  remaining 699 fires (26%), led by `open/` — a Forgejo remote namespace
  prefix, not a directory. This one overturned a premise the fasit had
  asserted without measuring; the deviation is recorded rather than the
  prediction quietly edited.
- Containment is checked against the scan root, not the file's own dir: a
  base a `..` chain can escape is not a base. Measured — without it,
  `../../../../etc/passwd` resolved to the real file and silenced its own
  finding, while a legitimate `../docs/x.md` still resolves.

Rule ORDER is the reported reason (first match wins), so `npm test` is
silenced as a command rather than as a bare token, and two silences with
different causes keep their own fixtures. Twelve classes, pinned by name.

Both load-bearing rules were seen RED against their own defect: deleting
containment fails 1 test, deleting the slug rule fails 6.

Dogfooded through the argv the command template itself constructs, which
found a true positive in our own CLAUDE.md — `lib/humanizer.mjs` where the
file is `scanners/lib/humanizer.mjs`. Fixed here.

Suite 1662 -> 1701, 0 failing. Frozen v5.0.0 and default-output baselines:
0 changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJbfM3N8zWQ1wA2voTrZxz
2026-08-12 20:11:12 +02:00
33bfd5ff5b fix(commands): the removal gate was classified against the wrong root
Found by review after the SUB-WRITE commit, and both defects were in the
template rather than the engine every prediction in the fasit was about.

`--repo` is what a write target is classified AGAINST. The template passed the
SCAN target, and under `--global` that target IS ~/.claude -- so
~/.claude/CLAUDE.md matched `in-repo` and the gate went `silent`. Measured
against the real config: gate silent, scopeClass in-repo, 29 removals applied
with no approval asked. That is the same silent downgrade #62 measured for a
naive .git-upward walk, arriving through a different door, on the one target
this chunk was sequenced behind M-BUG-41 to protect. Every other gated template
already passed `--repo "$PWD"`; this one was the only outlier.

The dry run also could not validate the machine-wide case -- the case that is
mandatory in v1. The gate returned before any file was read, so a dry run there
reported 29 scope-gate refusals and zero checked spans, and the first run able
to find a stale approval would have been the one that writes. A gate guards a
WRITE, and a dry run is not one: `requiresApproval` and the disclosures are
still reported, so the operator is still asked.

The new caller-arm guard was itself red against the corrected template, matching
prose that merely NAMES the CLI. Narrowed to lines that invoke it.

Guards seen red against the original defects: `--repo "<target-path>"` red,
`--repo` omitted red, gate-blocks-dry-run red. Re-dogfooded as the template now
calls it: require-ok / user-scope / 29 spans validated / 0 files written.

Suite 1659 -> 1662/0. Frozen baselines untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017A6vrtPKsVuM4DJ27p7jzw
2026-08-10 06:15:25 +02:00
000e47f9d2 feat(scanners): the subtraction axis can now remove what it proposes (SUB-WRITE)
`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
2026-08-10 06:10:42 +02:00
1543830c52 feat(commands): a write that leaves the repo says so before you approve it (M-BUG-41)
The chain observed configuration across repos but presented every write it then
proposed as though it landed where the session stands. STATE named two arms;
measuring found five, and two of them are worse than the two already known:

- implement — the approval prompt named NO path at all, only a count, so a plan
  editing ~/.claude/CLAUDE.md and one editing ./CLAUDE.md produced byte-identical
  prompts.
- rollback — the file list rendered `.claude/settings.json`, a repo-relative
  FORM, while the restore writes to the absolute original. The other arms were
  silent; this one pointed the wrong way.
- fix — paths were visible but unclassified, and --global mixed machine-wide and
  project rows into one unmarked table.

The gate's strength comes from the target's scope class, never from the command
asking: five command-owned policies would drift apart the way five copies of the
lever table did. SCOPE_CLASSES is one source for class, gate, wording and
predicate; templates render `disclosures[]` from the CLI instead of restating
what a class means.

Two orderings in that table are load-bearing, and both were measured:

- plugin-managed before user-scope. Both ~/.claude/config-audit/ and the legacy
  ~/.config-audit/ are live, and every command writes session state there. The
  other order fires the gate on every write ever made and gets it switched off,
  which is worse than no gate.
- user-scope before cross-repo. ~/.claude/.git EXISTS, so a plain .git-upward
  walk answers "another repo" for ~/.claude/CLAUDE.md and silently downgrades
  the strongest gate on the subtraction axis's primary target to disclosure.

disclose is not require-ok: campaign export is cross-repo by design, so the gate
there says so rather than refusing. Distinct from require-target-dir.mjs, which
asks whether a scan ROOT is readable (exit 3) — a different invariant, left
unmerged along with its four inline copies.

Also structural, both found while building this: the hand-maintained GUARDED
list in the unknown-flag sweep now derives its completeness from the directory
(measured complete at 14 of 14 first, so nothing was hiding — but the 15th CLI
would have been swept by nothing); and prose shape-guards use whitespace-
tolerant patterns, after one went red against a command file that did say the
right thing, line-wrapped.

Gated: implement, fix, rollback, plan, campaign export. Suite 1596 -> 1625/0,
frozen v5.0.0 and default-output baselines 0 changed files. No new GAP dimension,
no lever, no finding code — utilization denominators untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013941cEohSD5Aw56FVAtBgZ
2026-08-10 05:40:12 +02:00
49bae2657f docs(plan): promote the scope-gate + subtraction-write ahead of the additive work
Operator decision 2026-08-10 (session #61): the subtraction axis' write half is
wanted in use, so it moves ahead of C3/B2/B3. M-BUG-41 (scope-gate) is promoted
with it as the prerequisite — subtraction-write targets ~/.claude/CLAUDE.md,
which is by definition outside the repo the session stands in, and that is
exactly the gate M-BUG-41 is missing on its two measured arms.

Adds §C6 recording the frames the chunk inherits rather than invents: the
deterministic floor runs before the judge, ~/.claude is archived by mv and never
rm, user level is mandatory in v1, and the honest sizing is ~20% of the file —
not the 80% the framing invites. The apply mechanism is deliberately left open,
to be settled in the chunk's fasit against the gate rather than before it.

Also corrects the release level in the list: MAJOR (v6.0.0), since M-BUG-28
shipped with a BREAKING CHANGE footer that outranks the minor the additions
alone would have implied.

Records the rejection of a sibling /repo-reinit skill so it is not revived: it
would be a third implementation of one judgement alongside this axis and
/doctor Check 3, and regenerating a CLAUDE.md destroys the floor that makes a
mature one valuable. repo-init already owns the fresh-repo case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq3nye21RVYk4pZLeT8pGz
2026-08-10 05:20:01 +02:00
9ae4be26d2 feat(scanners): model/effort routing becomes a lever, not a 25th dimension (C4)
New GAP finding CA-GAP-028: authored subagents exist and not one of them names
`model:` or `effort:`, so every delegated task runs on the main conversation's
model (`model` defaults to `inherit`). Cites BP-MODEL-001/002, landed in C1.
`whats-active` and `manifest` now carry `model`/`effort` per agent.

Shipped as a conditional LEVER rather than a 25th dimension, and the choice was
made by measurement: as a t3 dimension the agent-less marketplace-medium fixture
would count it vacuously-present, moving the denominators 41->42 and utilization
44->45 — which flips `segment` "Developing"->"Competent" in the frozen v5.0.0
posture baseline, a field strip-retired-gap.mjs does not mask. A lever never
enters those denominators. The general rule is now an invariant in CLAUDE.md.

One check across both axes, not one per axis: it fires only when neither is used
anywhere, so a deliberate everything-on-one-model policy stays silent. Cost is
recall, chosen for precision.

Found by dogfooding, fixed red-first: `model: inherit` is the documented default
spelled out, so it must not count as routing — otherwise a config opts out of the
opportunity without changing anything real.

Two pre-existing defects surfaced and closed on the way:
- The humanizer guard asserted TRANSLATIONS.GAP.static EQUALS the dimension
  titles, which forbade humanizing any lever — all three existing levers fell
  through to the generic "feature opportunity" default, wrong for a budget lever.
  Guard now requires coverage of every emittable title, seen red against those
  three before the entries were written.
- Two hand-written copies of the lever list (finding-codes guard, humanizer
  guard) merged into one exported LEVERS registry carrying code AND title.
- suppression-validation pinned CA-GAP-028 as an unoccupied number; C4 claimed
  it. Fixed structurally with a derived first-free id, not by picking a new
  literal — same class as #60's "bump this again".

Suite 1596/0. Frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq3nye21RVYk4pZLeT8pGz
2026-08-10 05:07:23 +02:00
e861e63a7b feat(knowledge): model + effort routing enter the register, dated [skip-docs]
C1: two entries the optimization lens can cite for the model/effort axis,
both read out of the primary sources in this session rather than from the
plan's 2026-07-14 summary of them.

Verifying corrected the plan's own numbers: effort is settable in SIX
places, not five (/effort, the /model slider, --effort,
CLAUDE_CODE_EFFORT_LEVEL, settings effortLevel, skill/subagent
frontmatter), the default is high on every supporting model EXCEPT Opus
4.7 (xhigh), and the level count is model-dependent (Opus 4.6 and Sonnet
4.6 have no xhigh).

- BP-MODEL-001: subagent `model` defaults to `inherit`, so a subagent
  that names no model costs what the session costs; the documented pin
  is overridable by CLAUDE_CODE_SUBAGENT_MODEL and per-invocation model
- BP-MODEL-002: effort is an axis separate from model choice, and higher
  is not universally better (`max` "may show diminishing returns and is
  prone to overthinking")
- both carry the 2026-07-07 model/effort blog as a corroborating source
  with a real `published` date, so B1's evidence-age rule has teeth:
  measured stale with reasons ['evidence-age'] at a reference date 378
  days past publication while their verified stamps are pristine. The
  docs pages themselves get NO published date — they carry none, and
  guessing one in the field whose whole job is dating evidence is the
  lie the rule exists to catch
- new blanket guard: every corroborating source must carry a parseable
  published date. Without it newestEvidenceMs() returns null and the
  entry stays green on evidence of any age — a silent hole. Seen red
  against its own defect before it was trusted
- knowledge-refresh-cli's stale branch no longer expires on every new
  entry: its reference date has to sit after every `verified` stamp, was
  bumped once for BP-SUB-001 and would have needed a third bump now, so
  it moves to a date no stamp can reach

Register 14 -> 16 entries. Frozen v5.0.0 snapshots untouched (no scanner
output changes); suite 1579/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKjojcdYYiCQP5AudUyQ5e
2026-08-10 04:36:09 +02:00
542f983178 test(scanners): assert the published IDs off real output, by title
The three guards landed with M-BUG-28 verify key->number (registry), that every
emitted code is declared (sweep), and that every declared code is claimed by a
call site (orphan check). None of them verifies title->number: that the call
site at PLH source position 3 passes `plugin-json-shadows-default` and not its
neighbour. Transposing two keys that are both valid satisfies all three.

Measured, not assumed: with the two PLH keys swapped, finding-codes.test.mjs,
finding-code-coverage.test.mjs and the orphan check all stayed GREEN. Only this
test goes red.

It reads the ID off a real scan and keys on the finding TITLE -- the assertion
README actually makes. Covers the three places where numbering is deliberately
not source order (CA-PLH-015 and CA-PLH-016 at source positions 3 and 4,
CA-TOK-006 at position 8) plus CA-CML-001. The PLH-016 case also pins the
documented non-uniqueness: four entry problems, one check, one ID.

Suite 1573 -> 1577, 0 failing. Frozen v5.0.0 untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
2026-08-09 23:34:02 +02:00
7a794b47eb fix(scanners)!: a finding ID names the check, not the emission (M-BUG-28)
BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.

Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.

scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.

IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.

unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.

Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.

Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.

Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).

Suite 1535 -> 1573, 0 failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
2026-08-09 23:26:36 +02:00
4027cdcf54 fix(scanners): retire the autoMode GAP dimension, a /doctor duplicate (D1)
CC 2.1.226's /doctor Check 8 covers auto mode with usage-weighted judgement.
The binding positioning forbids carrying a feature whose whole value is
duplicating a /doctor check, so the "adopt this feature" nudge goes. The
deterministic side stays: SET still validates autoMode structure and still
flags it as dead config in shared project settings. GAP dimensions 25 -> 24.

The title lived in FOUR tables, not the two the removal was scoped against:
the dimension list, scoring TITLE_TO_ID, the humanizer's static translations,
and the scoring denominators (TIER_COUNTS t3 8->7, TOTAL_DIMENSIONS 25->24,
MAX_WEIGHTED 42->41) -- the one that moves a user-visible number. findGapId
falls back to 'unknown' silently, so a partial removal would have degraded
without failing. A blanket sync invariant now asserts all four against
GAP_CHECKS instead of comparing occurrences pairwise; each arm was verified
red against its own defect (denominator drift, orphaned humanizer entry,
resurrected dimension).

Frozen tests/snapshots/v5.0.0/ stays untouched. strip-retired-gap.mjs is the
removal twin of strip-added-scanner.mjs: it strips the retired dimension from
whichever side still carries it and re-derives GAP IDs, since retiring a
dimension from mid-list shifts every later ID by one. Derived utilization
figures are dropped from comparison rather than recomputed -- recomputing them
in a test helper would assert the new arithmetic against itself, and
scoring.test.mjs already pins them exactly. Re-seeding was rejected: it would
silently bake in any other drift across every scanner those four files cover.

risk_score, risk_band, verdict, overallGrade, maturity and segment are
byte-identical across the change (severity info carries zero risk weight; GAP
is excluded from the overall grade). Utilization shifts 43 -> 44 on the fixture.

D2 (CA-SKL-002) is NOT removed. Verified against the primary source first: the
CC changelog carries exactly one budget-fraction statement (L3786, 2.1.32) and
nothing supersedes it, so our 2% is current and 002 is not a duplicate with a
stale figure. /doctor's ~1% could not be reconciled from the changelog and it
discloses its own numbers as disk estimates, so it is recorded, not adopted.
Left explicitly unverified in a code note: L3786 says "character budget" while
we express tokens -- a 4x difference nobody can settle from the wording.

Suite 1531 -> 1535, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsfPGxgwbR3MY54wDC6hat
2026-08-09 22:43:04 +02:00
182a37c1af fix(scanners): close the CLI argument class across all fourteen CLIs
`KNOWN_OPEN` in cli-unknown-flag-rejection.test.mjs named two CLIs as still
carrying the argument-swallow defect. That number was the previous session's
field of view, not a measurement. Measuring all fourteen found **7** open on
the unknown-flag arm and **10** on a second arm the deferral note never
described.

Arm 1 — unknown flag: with no `else` branch, `--zzz` leaves no trace. exit 0,
full payload, a confident answer to a question the caller did not ask.

Arm 2 — the sharper one: `a === '--output-file' && args[i + 1]` asks only
whether a next token EXISTS, never whether it is a value. `manifest`,
`campaign-cli` and `knowledge-refresh-cli` each wrote a file literally named
`--json` into the caller's working directory when handed `--output-file
--json`, exit 0, with `--json` mode silently dropped. A wrong answer is bad;
an unintended file on disk is worse.

Two of the CLIs this catches were already in GUARDED and green on arm 1 while
arm 2 stood open a few lines away — the guard asserted one relation instead of
the invariant.

Fixed with a shared gate (`lib/cli-args.mjs`) that runs BEFORE each CLI's own
parse loop rather than replacing it: valid argv reaches the existing parser
byte-for-byte unchanged, so the byte-stability argument is structural rather
than empirical. `drift-cli`, `fix-cli` and `plugin-health-scanner` were
already correct on both arms and were moved into GUARDED instead of rewritten.
The three CLIs with a bespoke unknown-flag branch had it removed once the gate
made it unreachable.

Suite 1488 → 1531. Frozen v5.0.0 snapshots untouched; `self-audit
--check-readme` passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P6Rh59Mtj4uYrdYMCYZJE
2026-08-09 21:42:00 +02:00
3d6ddb273c fix(scanners): the fix engine downgraded a near-miss on xhigh to high (C2)
`fix-engine.mjs` carried its own copy of the valid `effortLevel` list, and
that copy had gone stale on `xhigh` (CC 2.1.154's top Opus tier) while
`settings-validator.mjs` had all five. The nearest-match "fix" therefore
corrected `xhig` — and `XHIGH` — to `high`: the tool silently changed the
tier the user asked for, in the one code path whose whole job is to write
the corrected value back to disk.

Fixed by sharing the validator's table instead of aligning the copy, so the
two cannot drift again. Guard asserts the blanket invariant — a one-character
near-miss on EVERY valid tier corrects back to that same tier — rather than
pinning the one level that happened to be missing; verified red against the
original stale array.

Suite 1488/0. Frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P6Rh59Mtj4uYrdYMCYZJE
2026-08-09 21:29:00 +02:00
c76dc537ce fix(scanners): a target path that does not exist is an error, not a grade
Surfaced by the router dogfood: quoting the placeholder stopped the shell from
swallowing it, which moved the failure down into the CLIs — and revealed that
most of them never check the target at all. Measured:

  node scanners/posture.mjs /nonexistent/path/xyz --output-file …
  exit 0
  Health: B (86/100) — Good shape — a few items to address

Nothing in that output distinguishes it from a real audit: well-formed
envelope, all 10 areas present, 16 opportunities reported. A typo'd path did
not fail — it flattered.

Exit 3 is the right code by the plugin's own contract: 0/1/2 are PASS/WARNING/
FAIL about a configuration that WAS examined, and every command template gates
on exactly that distinction, so a bad path flowed through the whole workflow as
a clean result.

This was a consistency gap, not a design question. Measured across the nine
target-taking CLIs, four already did it right with the same message and the
same exit code (manifest, token-hotspots-cli, whats-active, optimize-lens-cli);
five did not (scan-orchestrator and drift-cli exit 1, posture,
plugin-health-scanner and fix-cli exit 0). The five now share
lib/require-target-dir.mjs, which carries that exact behaviour. The four with
inline copies are left alone — consolidating them is a cleanup, not part of
this fix.

The guard is asserted over ALL nine CLIs, so a new one cannot join the wrong
half, and a third case is covered: a target that exists but is a regular file.
A valid target — including an empty directory — is explicitly unaffected.

Suite 1483 -> 1486, frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDAwy1ZXRpZxht1wyCeSbF
2026-08-09 21:18:18 +02:00
0b763f25c1 fix(commands): router dogfood — five seam defects, plus the placeholder class
Dogfooding `/config-audit` (the router) against the repo, fasit written before
any run (docs/router-fasit.local.md, untouched). Every claim below is measured
behaviour, not a reading of the source.

1. Bare `<target-path>` inside the step-3 fence is a shell REDIRECTION, not an
   argument. Measured in zsh: both CLIs failed before starting, no output file
   was written, and the echoed status was 1 — inside the band the router's own
   gate calls "continue normally". Quoting makes an unsubstituted placeholder
   reach argv, so it fails in the CLI where the exit code means something.
   Swept the whole class: 30 sites across 12 further command files, since a
   defect in one file is a class until the opposite is measured. New guard:
   command-placeholder-shell-safety.test.mjs.

2. The orchestrator's exit code was discarded. Two commands on one line share a
   single trailing `echo $?`, which reports only the last: measured, an
   orchestrator exit 3 echoed as posture's 0, so the "3 -> stop" gate could
   never fire. Both statuses are now captured and echoed.

3. "Running 12 configuration scanners" — the orchestrator registers 16. The new
   test binds the narrated count to the registry so the next scanner added
   cannot re-stale it silently.

4. The Area Breakdown table hardcoded 7 rows; posture emits 9 quality areas.
   Token Efficiency (a B on this repo) and Plugin Hygiene never reached the
   user. Rows added, and the row set is now asserted against lib/scoring.mjs.
   Label aligned: "MCP Servers" -> "MCP", as posture emits it.

5. Step 6 rendered "the headline line from the humanized stderr scorecard" and
   forbade deriving a replacement — while step 3 sent posture's stderr to
   /dev/null, as UX rule 2 requires, and the prose is absent from the JSON
   payload (measured). The slot could only be improvised. posture's stderr now
   goes to a file in the session dir, as commands/posture.md already did; the
   user still never sees raw scanner output.

Also: `grep -q -- "--raw"` matched any argument CONTAINING --raw (measured on
`--rawdog` and on a path with --raw in it) — anchored to whole arguments.
SCOPE_FLAGS renamed SCOPE_FLAG, since zsh does not word-split and the plural
invited the M-BUG-45 shape.

command-shell-state-shape.test.mjs only recognised line-initial assignments, so
it reported the idiomatic `node …; STATUS=$?` capture as never assigned. Widened
to assignments after a separator; verified it still fails on a real cross-block
reference before trusting it.

Suite 1477 -> 1483, frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDAwy1ZXRpZxht1wyCeSbF
2026-08-09 21:18:05 +02:00
ca199cc5f6 docs(readme): carry the ownership basis in the AI-disclosure (org-ops D12)
D12 (org-ops, 2026-08-01) requires three things of the plugin-class
disclosure line: generator, process, and the ownership basis (Anthropic
Consumer Terms §4). Our line carried only the first two — the inline
rewrite in #54 closed catalog's original order, which was measurably
narrower than the decision it derived from.

Adopts the form already live verbatim in llm-security, voyage and
ai-psychosis. Position unchanged (head block, line 9); repo-standard
stays green and `self-audit --check-readme` passes. MIT verified
against LICENSE and .claude-plugin/plugin.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDAwy1ZXRpZxht1wyCeSbF
2026-08-09 21:00:16 +02:00
b3f6866644 docs(readme): cut the badge row to four (repo-standard BADGE-COUNT WARN)
repo-standard v0.2.0 flagged 7 badges, past the measured inflection of 5
(Trockman et al., ICSE 2018) where a badge row reads as clutter rather
than evidence.

Dropped Commands/Agents/Hooks: the counts are stated in the sections
they describe, and every one of them was a hand-synced number that could
silently go stale — the same defect class that made the tests badge wrong
(1441 vs 1477) before it was removed. Kept Version, Platform, Scanners
(the distinguishing number, and the one count `self-audit --check-readme`
still asserts against the filesystem) and License.

`self-audit --check-readme`: PASS, exit 0 — absent badges are skipped by
design, so removing three does not weaken the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oUzeHA1Kmi2z8ug83PzbS
2026-08-04 11:47:21 +02:00
1b49bcc766 docs(readme): bring the first screen up to the org repo-standard
repo-standard v0.1.1 gate reported 5 ERROR + 5 WARN + 1 SKIP. All ERROR
fixed, every WARN decided:

ERROR (fixed)
- HEADING-MISSING x3: added `## Install`, renamed "What This Plugin Does
  Not Cover" -> `## Non-goals`, "Version History" -> `## Changelog`.
- INSTALL-NO-CLI: `marketplace add` now followed by the actual CLI
  command `claude plugin install config-audit@ktg-plugin-marketplace`.
  The `enabledPlugins` JSON stays beside it as the second form.
- README-DESC: opening line is now the published forge/plugin.json
  description verbatim, so description == catalog == README.

WARN (fixed)
- README-H1: `# Config-Audit Plugin for Claude Code` -> `# config-audit`.
- BADGE-STATIC-CLAIM: dropped the static `tests-1441` badge — it asserted
  a run nothing verifies, and it was stale (real count 1477). The
  Testing section now carries the measured number plus the fact that no
  CI runs it. self-audit --check-readme skips absent badges, still PASS.
- LINK-NON-REPO: `open/claude-code-llm-security` -> `open/llm-security`
  (the org's rename).

SKIP (fixed, closes catalog's coord message)
- LINK-OUTSIDE-REPO README:7: the `../../README.md` disclosure link was a
  monorepo leftover pointing outside the repo at an anchor that never
  existed. Replaced with the inline text the polyrepo migration intended.

Also removed the "What's New in v5.4.0" section: 24 lines of release notes
as the first TOC entry on a v5.13.0 repo, duplicating the v5.4.0 row in
the changelog table it sat above.

Gate re-run: 0 ERROR, 11 checks passed. Full suite 1477/1477.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oUzeHA1Kmi2z8ug83PzbS
2026-08-03 21:55:06 +02:00
d68b2152e3 docs: /doctor-overlap decision merged into v5.14 plan + positioning
Oppgave A measured (fasit-first, CC 2.1.220): /doctor overlaps our
judgment lenses and alarms, not the deterministic validators. Binding
outcome: 0 whole scanners removed, 2 measured function-duplicates
scheduled for removal (GAP autoMode dimension, CA-SKL-002 alarm role),
5 surfaces repositioned. Plan rewritten with D-chunks + B2-B4 merged
against the existing C-chunks and open dogfood posts; old rejection #5
(prose contradiction detection) superseded by B3 with evidence.

README gains the division-of-labor section vs /doctor; CLAUDE.md gains
the never-duplicate-a-doctor-check invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019NeaMRXVGzh9oSwigJDjE9
2026-08-03 11:41:07 +02:00
c8d4dc9421 docs: add v5.14 /doctor-overlap brief (written by session #52)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019NeaMRXVGzh9oSwigJDjE9
2026-08-03 11:36:57 +02:00
d66035ed86 fix(knowledge): freshness can no longer be green on outdated evidence
assessFreshness aged only the entry's own source.verified stamp, so an
entry re-verified against an old source stayed green while a newer source
sat unnoticed (BP-SUB-001 was stamped 2026-07-31, a week after the
superseding-grade article of 2026-07-24 was published). The stamp
certifies the old source; it says nothing about the evidence.

- entries may carry corroborating sources[] with published dates
- new evidence-age rule: stale when the NEWEST published date across all
  sources exceeds evidenceStaleAfterDays (default 365); re-verifying the
  old source never clears it, only newer evidence does
- source.supersededBy marks a replaced source: stale regardless of stamp
- stale items now carry reasons[] (verified-age / no-verified-date /
  superseded / evidence-age)
- BP-SUB-001 gains the 2026-07-24 context-engineering article as a
  verified corroborating source (near-verbatim coverage). NOT added to
  BP-MECH-*/BP-SIZE-001: own verification found no mechanism-choice or
  size-limit content in the article, contrary to the brief's assumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019NeaMRXVGzh9oSwigJDjE9
2026-08-03 11:36:57 +02:00
caea8aca23 fix(commands): stop answering questions the caller did not ask
Dogfooding `campaign` + `knowledge-refresh` against a throwaway ledger. Seven
defects, all found by running the commands as written and measuring, not by
reading them.

The headline pair only existed together. `knowledge-refresh` built
`STALE_AFTER="--stale-after 30"` and expanded it unquoted, trusting the shell to
split it in two. bash does; zsh — the macOS default, and what the Bash tool runs
here — does not. The CLI got one argv entry, matched no flag, and because it had
no unknown-flag branch, silently kept the 90-day default and reported "✓ All 14
register entries were re-verified within the last 90 days": a true-sounding
sentence about a threshold the user had just overridden. Fixing either half alone
leaves a silent wrong answer or a loud one; both are fixed, and a guard now
rejects any template that packs a flag and its value into one variable.

`knowledge-refresh` also read one register and wrote another: step 6 named an
unanchored `knowledge/best-practices.json` while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/…`, which for an installed plugin is the cache. The
validation gate then ran the cached test against the cached register — green no
matter what was written. The two copies were byte-identical that day, which is
exactly why it was invisible.

`campaign` vouched for repos it could not read. `add /finnes/ikke` returned
`added` + exit 0; `refresh-tokens` then put the phantom in `swept[]` with a
0-token delta and left `skipped[]` empty, so the machine-wide bill claimed
coverage of three repos on a machine with two. Paths stay tracked — an unmounted
volume is a legitimate absence — but are reported as `addedUnverified`, and the
command names them.

Two class sweeps, both measured rather than assumed. `posture` was the single
scanner (1 of 14) whose fatal catch exited 1, which ux-rules defines as a normal
WARNING grade — a crash indistinguishable from a result. And all 13 payload
writers failed on a `--output-file` whose parent did not exist, which on a fresh
machine turned `campaign`'s first run into "the ledger may be corrupt"; they now
share `scanners/lib/write-output.mjs`.

Predicted breadth was too wide for the first time in five sessions: 6 of 8 CLIs
predicted to lack unknown-flag rejection, 4 measured. `drift` and `fix` already
reject them, via a construct the grep did not recognise — a grep matches an
implementation, the invariant is a behaviour. The sweep was rewritten to run each
CLI with a bogus flag and read the exit code.

Suite 1453 → 1469/0. Frozen snapshots untouched. `optimize-lens-cli` and
`token-hotspots-cli` share the unknown-flag defect and are deferred to the v5.14
argument-handling chunk with their positional-swallow arm; the count is recorded
in the guard rather than rounded down to zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NHWjN8EnoxSqRvMTLK2NE
2026-08-01 21:26:39 +02:00
acd1cf1248 fix(commands): stop writing files no later step can read, and payloads nobody asked for
Dogfooding the four read commands (posture, tokens, manifest, whats-active)
surfaced four defect classes, all in the seam between what a command template
promises and what the scanner behind it actually does.

M-BUG-40, fifth arm: posture wrote four temp files it could never read back.
#49 closed the $$/cross-block class in four commands, but posture survived it —
and so did the guard written to prevent exactly this. The guard compared each
$$ path to the block that created it, so a path written once and then read via
prose had no second occurrence to flag. Measured live: written from PID 21614,
read attempted from PID 23772. The invariant is now blanket (no $$ in any temp
path), which also caught fix.md and feature-gap.md.

M-BUG-43: 6 of 7 scanners write their payload to stdout when --raw/--json is
set even when --output-file was given, and the templates redirected only
stderr. Measured: posture 255 182 B, whats-active 35 922 B, drift 28 316 B,
manifest 23 825 B, tokens 8 768 B. fix and feature-gap never read the file they
wrote, so both recovered one letter grade from a quarter-megabyte dump.

tokens swallowed --json and --with-telemetry-recipe: documented, never
threaded, so --json returned the humanized payload where the docs promise
byte-stable v5.0.0 output.

M-BUG-42: manifest's render contract asked for {load}; the payload carries
loadPattern, so the Load column rendered blank for all 96 rows.

Four new tests (1449 -> 1453), each verified red before the fix. The
render-contract test checks {field} names against a live payload from a
fixture, since a hardcoded key list would drift. Frozen v5.0.0 snapshots
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGCk9o27eWo9uXLjkZTXEq
2026-08-01 20:40:07 +02:00
09f817977c fix(commands): stop assuming shell state survives between blocks
Dogfooding `plan` + `implement` against a throwaway config surfaced one root
defect with many arms: the command templates treat consecutive fenced blocks as
one shell. They are not. Every ```bash fence runs as its own Bash call in its own
process, so a variable set in one block is empty in the next, and `$$` is a
different PID (measured: 21710 vs 22109).

The planner agent confirmed the sharpest arm at runtime, reporting that
`Mode: $RAW_FLAG` "arrived literally unsubstituted" — `--raw` was documented in
three command files while being functionally dead. A machine sweep found the same
root in 20 places across 9 files, well past the two the written fasit predicted:

  - `$RAW_FLAG` read from non-shell agent prompts (analyze, plan, implement)
  - `$TMPFILE` read across blocks (tokens, manifest, whats-active,
    plugin-health) — each command could not read the file it had just written
  - `$GLOBAL_FLAG` across blocks (fix)
  - `$TODAY` never assigned in any block (campaign), passing
    `--reference-date ""` to a write CLI in six places
  - three `$$` temp paths handed to the Read tool (fix), which expands neither

All now follow the hardened drift.md pattern: a fixed literal path, or a
re-derivation inside each block that needs it.

Also fixed, all confirmed against ground truth rather than inferred:

  - `implement` printed a rollback ID it never captured (the timestamp lived only
    inside a command substitution) — the one message a user reads after a bad run
  - `plan` reported "No analysis results found" for valid sessions, because Read
    was pointed at a glob it cannot expand; now uses Glob and verifies the
    analysis report exists before spawning the agent
  - five phase commands wrote state.yaml with two of four required fields; since
    the agent writes all four, a follow-up write silently deleted the rest
  - `implement` promised rollback deletes created files; rollback deliberately
    leaves them (M-BUG-26 still open) — the doc, not the engine, was wrong
  - `implement` claimed a score delta with no pre-change measurement
  - `verifier-agent` was told to write a report it has no tool to write
  - dead `Task` tool name in always-loaded rule context; planner-agent template
    demonstrated the inline file content its own line 110 forbids

The sweeps land as tests/commands/command-shell-state-shape.test.mjs, verified
red before the fix and proven able to fail by reintroducing the defect. Two
existing tests asserted the old bash-block mechanism rather than the intent and
were updated. Suite 1449/0; frozen v5.0.0 snapshots and all scanner code
untouched.

Not fixed, deliberately: neither command scope-gates its actions to the audit
target. The generated plan included an edit to a real file under ~/.claude,
outside the throwaway target, because the skill/agent scanners are machine-wide.
That is a design change, not a side fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195udHgCcFegzm7ecKku2Yc
2026-08-01 20:12:17 +02:00
de8a7b5d51 fix(scanners): stop discarding our own stdout when it is a pipe
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
2026-07-31 21:40:15 +02:00
b85919f2ec fix(commands): close the promises the command templates could not keep
DEL B chunk `interview` (+ discover/status/cleanup/help). Fasit written before
the run predicted 8 defects and refuted 4 candidates; all 8 confirmed, all 4
refutations held, and three predictions turned out too narrow.

- M-BUG-36: `drift --list` reached the command as 0 bytes. drift-cli accepted
  --output-file but list mode ignored it, and the listing goes to stderr, which
  the command discards per ux-rules rule 2. Fixing the caller alone would not
  have helped.
- M-BUG-37: feature-gap's "Create backup" step ran fix-cli without --apply.
  Dry-run is the default, so no backup existed (backupId: null) while the
  command went on to edit config believing it could roll back.
- M-BUG-38: fix-cli told users to recover with scanners/rollback-cli.mjs, which
  does not exist. Dead reference in the one message read after a bad fix.
- M-BUG-21 fourth arm: five templates carried literal [--global]/[--full-machine]
  inside executable bash blocks. A bracketed placeholder does not start with a
  dash, so every scanner's arg loop takes it as the scan target.
- interview and analyze never said which session they act on; interview could
  rewind a finished session; cleanup interpolated an unvalidated id into rm -rf
  (an empty id deletes every session); status advertised a `resume` command that
  does not exist and documented an `all` argument it never parsed.

TDD: 9 red tests first, including a machine sweep for dead /config-audit
references and for bracketed flags in bash blocks. Suite 1432 -> 1441/0.
Frozen v5.0.0 snapshots untouched; --raw/--json contracts unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGvA1uUQn2hPBPMaCKK6x3
2026-07-31 21:27:07 +02:00
001090261e fix(plugin-health): make the command able to read what the scanner found
Dogfooding `/config-audit plugin-health` against a fasit registered before the
run: 11 of 12 predictions confirmed, 1 refuted with evidence, 0 deviations.
The command's default path could not produce the report it documents.

M-BUG-21 (third arm): the argument loop ended in
`else if (!args[i].startsWith('-')) targetPath = args[i]` with no unknown-flag
branch, so `--output-file /tmp/x.json` was dropped and its value became the scan
target. Worse than in drift-cli: a non-existent path discovers no plugins, so the
scanner answered "No plugins found" (info) with exit 0 — a reassuring answer, not
an error. Unknown options and a value-less `--output-file` now exit 3.

M-BUG-33: the scanner had no `--output-file` and its default-mode report goes to
stderr, which `commands/plugin-health.md` discards with `2>/dev/null` before
telling the agent to read stdout. Zero bytes captured.

M-BUG-34: per-plugin rows and the grade formula never left `scan()` — the only
grade code, `formatPluginHealthReport`, had no caller — and cross-plugin findings
were flattened behind a `category` they share with per-plugin findings. The
mandated table and Cross-Plugin section were unbuildable, so the command had to
fabricate them. `scanDetailed()` now returns them; `scan()`'s frozen v5.0.0
envelope is unchanged by construction.

M-BUG-35: `.claude-plugin/marketplace.json` was flagged as an unknown file. It is
the documented catalog location, and `"source": "./"` makes the repo root its own
plugin, so one `.claude-plugin/` legitimately holds both.

Also: `commands/posture.md` ran both optional scanners in default mode under
`2>/dev/null` and read stdout — the same class as feature-gap.md:133 in the fix
chunk. A CLI-side flag fix does not close its callers.

Tests 1420 -> 1432, red first. Frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhhZ8zg1amR7YCAPqiZWdt
2026-07-31 21:08:32 +02:00
05f1e954d0 fix(fix): validate the arguments, back up renames, and verify the scope it fixed
Dogfooding `/config-audit fix` against a throwaway repo copy. All eight
predictions registered in the fasit before the run were confirmed, and three
further defects surfaced that were not predicted.

- M-BUG-21, third arm: the argument loop ended in `!arg.startsWith('-') =>
  targetPath`, so an unknown flag was dropped and its value became the target.
  In `fix` that is the WRITE target under `--apply`. Unknown options and a
  value-less `--output-file` now exit 3.
- `--dry-run` was documented in the command's argument-hint and never
  implemented; `--output-file` did not exist, so `commands/fix.md` told the
  agent to Read a file nothing produced. Both now exist.
- M-BUG-31: `file-rename` was excluded from the backup set, so a renamed rule
  file had no backup entry while the command promised one and returned a
  backupId that could not restore it.
- M-BUG-32: `verifyFixes` hardcoded `includeGlobal: false`, so after a
  `--global` run every untouched user-scope finding was reported as verified.
  Reproduced against an unmodified ~/.claude/CLAUDE.md.
- M-BUG-29: a rename was applied before other fixes on the same file, which
  then failed with ENOENT while the run still exited 0. Renames sort last.
- M-BUG-30: `severityOrder[s] || 4` maps critical (0) to 4, so critical fixes
  sorted last. The old test used the same falsy fallback and agreed with the
  bug. Now `?? 4`.
- A failed fix exits 2 instead of 0, matching the other scanners' convention.

Frozen tests/snapshots/v5.0.0/ untouched; --json/--raw stdout byte-identical.
Suite 1420/0 (+10).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ3MCDCnyw7wZSPnUXVhYS
2026-07-31 18:41:27 +02:00
1182f85767 fix(drift): validate the arguments and the baseline anchor drift never checked
Dogfooding `/config-audit drift` against the machine. Fasit written before the
run; 6/6 predictions plus both F7 arms confirmed, 0 deviations.

M-BUG-21 (both arms):
The arg loop ended in `else if (!arg.startsWith('-')) targetPath = arg` with no
unknown-flag branch, so an unrecognised flag was dropped silently and its VALUE
became the scan target. `--output-file /tmp/x.json` scanned /tmp/x.json — a path
that does not exist — and reported the near-empty scan as drift, forever. The
same silence was destructive for `--save --name` with the value omitted: the
name stayed `default` and an existing baseline was overwritten. And the flag
ux-rules rule 2 requires did not exist at all: commands/drift.md ran the CLI
under `2>/dev/null` while telling the agent to read stdout, but the default
report, the --save confirmation and --list all write to stderr. All three modes
captured nothing.

M-BUG-27 (found during the run, not predicted):
diff-engine never compared the baseline's stored target_path against the current
target. The machine's `default` baseline is anchored to a test fixture, so
`/config-audit drift` diffed two unrelated trees, marked all 20 baseline
findings resolved and all 15 current ones new, and reported trend "improving".
A reassuring, entirely false signal — and the default path.

Root cause is one thing, not three: the CLI validated neither its flags nor its
anchor. Same class as the rollback chunk's "nothing agreed where a backup lives".

Fix: unknown options and value-less --name/--baseline/--output-file exit 3;
--output-file follows the posture.mjs pattern; `_baselineAnchor` rides in the
default-mode payload (stderr alone is invisible under `2>/dev/null`) while
--json/--raw stdout stays v5.0.0-shaped.

Suite 1410/0 (+12). Frozen snapshots untouched; raw/json/default backcompat green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RGrL3noVdFSUhohaKTMSN
2026-07-31 18:20:04 +02:00
c60f849d2e chore(release): v5.13.0 — pipeline hardening batch (M-BUG-11..25 + --subtract)
Batch release of everything since v5.12.5: one new lens mode and 14 real bugs,
all of them dogfooding finds — either from running the plugin against the
maintainer's real machine, or from walking analyze -> plan -> implement ->
rollback end-to-end on a throwaway repo copy.

Minor, not patch. STATE recorded this batch as "fix: only"; the log says
otherwise — e9921d3 ships `optimize --subtract`, a user-facing opt-in flag, so
semver requires a minor. Verified by reading `git log v5.12.5..HEAD` rather than
trusting the note: 11 fix, 1 feat, 8 docs.

Consequence: the planned v5.13 work (model routing, effort awareness, dead
references) now targets v5.14. docs/v5.13-model-routing-effort-deadref-plan.md
keeps its filename so existing references resolve, and says so at the top —
leaving a doc named for a version that shipped something else is exactly the
dead-reference class that plan is about.

Gates, all re-run against ground truth before writing anything:
- node --test 'tests/**/*.test.mjs' -> 1398 pass / 0 fail
- scanners/self-audit.mjs --check-readme -> passed (tests badge 1344 -> 1398;
  the path in STATE said scripts/, which does not exist)
- catalog check-versions.mjs -> 0 ERROR

Counts unchanged: scanners 16, agents 7, commands 21, hooks 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AA5LT1UaDzctNkMi414qzA
2026-07-31 17:33:48 +02:00
8f149891c9 fix(rollback): restore the backup path contract the engine and the commands disagreed on
Pipeline step 4 dogfood. `/config-audit rollback` could not see a single one of
the four real backups on this machine, and reported "Backup not found" for one
that was sitting right there.

Four defects, one root: nothing agreed on where a backup lives or what its
manifest looks like.

- M-BUG-22 `lib/backup.mjs` resolved `~/.config-audit/backups` (pre-v2.2.0)
  while every command, agent and doc uses `~/.claude/config-audit/backups`.
  The auto-backup hook and fix-cli wrote to the first, implement to the second,
  rollback read only the first. Canonical root now, with the legacy root kept
  readable so older backups stay listable and restorable (`legacy: true`).
- M-BUG-25 `parseManifest` understood only the engine's quoted `original_path:`
  spelling, but implement hand-builds its manifest with `- backup:`/`original:`/
  `sha256:`. Every implement-made backup parsed to zero files and restoreBackup
  returned `{restored: [], failed: []}` — a success-shaped no-op. Both formats
  parse now, and a manifest with unparseable entries throws instead of
  pretending to succeed.
- M-BUG-23 both session hooks watched `~/.config-audit/sessions`, which does not
  exist; sessions live under `~/.claude/`. "Check for active sessions" had never
  fired once. It fires now.
- M-BUG-24 the suite called createBackup() against the developer's real home —
  it had left nine stray backups there, and cleanupOldBackups() deletes past ten.
  Root is overridable via CONFIG_AUDIT_BACKUP_ROOT; both test files use it.

Rollback still cannot delete files implement CREATED — no backup can hold a file
that never existed. It no longer does so silently: manifests carry a `created:`
list, restoreBackup returns `createdNotRemoved`, and rollback.md requires the
report. Automatic deletion is a destructive action and needs its own design.

Verified against backup 20260717_032636 on a throwaway copy: all three files
restore byte-exact (sha256 match), zero writes outside the copy, backup dir
unmodified. Suite 1382 -> 1398/0; frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SejM9RQAa1Hfuq7Ek2WfFr
2026-07-31 17:23:30 +02:00
b8cbbc0f5b docs(subtract): correct the ground-truth counts and keep the gate re-runnable
Two record fixes, no behaviour change.

The README quoted "35 blocks, 13 genuinely ambiguous" for the hand-built ground
truth. Counting its own rows gives 48 classified blocks and 19 ambiguous — the
headline in the fasit disagreed with the table beneath it, apparently by
collapsing letter-suffixed sub-blocks for the summary while listing them
separately. Labels are untouched in both files; only the counts are restated,
and now as row counts, which are reproducible with a grep rather than by
recounting a classification.

The dogfood gate script that proves the blocking §8 criterion lived only in the
session scratchpad, which would have made "zero load-bearing blocks proposed,
11/18 groups, ~756 tok" unverifiable claims the moment the session ended —
precisely the premise-not-fact class this repo's own rules warn about. It now
lives at scripts/dogfood-subtraction-gate.local.mjs, gitignored via a new
*.local.mjs pattern because it indexes the operator's private config by line
number and must never reach the public mirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RW2haJXbxZpKivKHseSXNh
2026-07-31 16:28:38 +02:00
e9921d3c9d feat(optimize): add --subtract, the subtraction axis, behind a deterministic floor
Every command so far asked an addition question — what to add, what to move,
what it costs. Nothing asked what is no longer earning its always-loaded rent.
This adds that axis as a fourth lensCheck on the existing hybrid motor rather
than a new scanner or a 22nd command: the measured payoff (~18% of one file)
justifies a mode, not machinery.

It is the only lens that proposes REMOVING config, so it carries a guarantee
the others don't need: a load-bearing block is never a candidate. Precision is
asymmetric — a missed dead line costs a few tokens per turn, a deleted one
costs a wrong remote or a broken script — so the floor is decided in code
(lib/floor-exclusion.mjs) before the opus judge sees anything, never in prose.

Granularity is the leaf block, with two structural exceptions: a paragraph
ending in ':' merges with the list it introduces, and an ordered list is a
contract whose steps inherit floor from any sibling. Unordered lists
deliberately do not inherit — a load-bearing bullet and a disposable one
routinely share a list, and container-reasoning is the error the hand-built
ground truth exists to catch.

Verified against that ground truth (built before any classifier existed), with
the comparison machine-checked rather than read by eye: zero load-bearing
blocks proposed, 11/18 deletable groups surfaced, ~756 tok ~ 18% of a ~4300
token file — inside the pre-registered band. The first run found five floor
violations the synthesized fixture missed; each got a structural rule and a
fixture shape so it cannot regress.

Three real bugs the dogfood run exposed, all now covered:
- JS \b is ASCII-only, so /\bunngå\b/ never matches — every Norwegian keyword
  ending in æ/ø/å was silently dead.
- A bare word/word is not a path; "pros/cons" vetoed the largest deletable
  block until PATH_RE was tightened to rooted paths and globs.
- "Mid-sentence" must key on a preceding lowercase letter; the loose version
  read **bold labels:** and quoted openers as entities, costing 4 of 11 groups.

BP-SUB-001 is grounded entirely in the Anthropic steering blog already cited by
BP-MECH-001..004 and asserts nothing from the talk that motivated the feature —
no "80%", no ablation figure.

Suite 1365 -> 1382/0. Frozen v5.0.0 snapshots untouched; plain optimize output
byte-identical on identical input (--subtract adds keys only when passed).
knowledge-refresh-cli's reference date moved to 2026-08-01: its premise that
every seed entry was verified 2026-06-20 expired when BP-SUB-001 got a genuine
verification date, and backdating the entry to fit the test would have been a
lie about when its source was checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RW2haJXbxZpKivKHseSXNh
2026-07-31 16:24:51 +02:00
c0625c568f docs(plan): age signal measured dead; close §7 q1-q3 on optimize --subtract
Ran the brief's own §8 pre-build checks before writing any code, and two of
them changed the design.

Age signal (§5A, §8): per-line git blame over four real instruction files gives
single-date shares of 88% / 55% / 100% / 58%. Instruction blocks trace back to
bulk commits, and blame reports last-touch rather than vintage — a reformatting
commit (this repo's own 96e32df) makes old instructions look young, so the
signal is biased, not merely sparse. That also kills the mtime fallback. §8
pre-registered this exact outcome and its consequence, so shape A does not ship
as a CA-VIN-* vintage scanner.

Premise correction (§6.1): ~/.claude IS git-tracked as of 2026-07-26 (7 commits,
remote on an external backup volume, 47 files). The rule it justified — mv to
_archive/, never rm — stands on different grounds and is unchanged.

§7 q1: both scopes, user-level mandatory in v1 (the floor test is defined there
and the always-loaded cost sits there).
§7 q2: not deterministically classifiable — the deciding blocks require reading
content against container. Deterministic pre-filter -> precision-gated judge,
with floor-exclusion running BEFORE the judge so a load-bearing block is never a
candidate.
§7 q3: ships as /config-audit optimize --subtract, a fourth lensCheck class
emitting CA-OPT-* with a BP-SUB-001 register rule. No new scanner, no new
command, no badge bump, no snapshot risk. Proportionality decided it: the
hand-built fasit puts the honest payoff at ~850-1400 always-loaded tokens on a
~4300-token file (~20%, not 80%), with 26 of 34 blocks classified floor.

The fasit itself (34 blocks, 13 marked ambiguous per §7.2) is local-only — it
quotes the operator's global CLAUDE.md verbatim and this repo's only remote is
the public open/ mirror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x2i9NDEtMR9FU7ufaCnBX
2026-07-31 15:43:03 +02:00
31073c2178 docs(plan): fasit must cover the ambiguous middle, not the poles (§7.2)
§8's floor gate rests on §6.0's classification test, which is a judgment call
rather than a mechanical one. The four named must-survive items ("only Forgejo",
"bash is 3.2", the test command, "~/.claude is not git-tracked") are clear-cut —
any mechanism gets them right, so a fasit built from them proves nothing.

The gate is actually decided by blocks like "Conventional Commits:
type(scope): beskrivelse" (local convention or a nag the model follows anyway?),
"commit ofte med beskrivende meldinger", or the model-routing rubric — local
policy that reads like generic advice. §7.2 now requires 3-5 such blocks in the
fasit deliberately.

Cross-references verified: every section pointer in the brief (§1-§8, §6.0, §5A,
§3.1, §7.2) resolves to an existing heading. Shipping a dead prose reference in
the brief that proposes detecting them would have been an odd artifact — that is
CA-CML's finding class, v5.13 chunk 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNrtHo9hKSLKNyMS6b4Zuy
2026-07-29 09:57:07 +02:00
36c55fb167 docs(plan): add the floor constraint — compensatory vs load-bearing (§6.0)
Operator corrected two things about the brief committed in 3086e8b/3252b51.

Provenance: the operator watched the recording and identifies Boris Cherny on
stage, so the attribution is confirmed by direct observation, not a channel's
claim. The verbatim figures (80 %, "more intelligent without the prompts") still
reach us through the summary's editing and stay at that confidence level. §1 now
carries both levels separately, and the register source string reflects the split
instead of flattening to "unverified".

Design: "start with what it must have" is the constraint the whole feature turns
on, so it is a hard constraint (§6.0), not a candidate-shape detail. Model
capability erodes compensatory instructions ("read the whole file first") and
does nothing to load-bearing local facts ("only Forgejo", "bash is 3.2", the test
command) — the model isn't failing at intelligence there, it cannot know. A tool
that treats them alike deletes the Forgejo constraint because Opus 5 "is smart
enough now". Rebuild is therefore three tiers: floor restored immediately, earned
returns on repeated stumbling, dead never comes back. Policy prohibitions stay in
the floor by decision rather than classification — asymmetric cost, cheap to keep.

Consequences threaded through: §5 disqualifies any shape that cannot express the
distinction, §7.2 becomes the core open question (age and class are independent
signals, so age alone can never carry the call), and §8 gains a blocking floor
test with a hand-built fasit and named must-survive items.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNrtHo9hKSLKNyMS6b4Zuy
2026-07-29 09:55:22 +02:00
3252b514ed docs(plan): close ordering question, pin the untested age premise
Two corrections to the brief committed in 3086e8b.

The ordering question (§7.4) still pointed at STATE.md for a decision the
operator had already made, and STATE.md is gitignored — so the tracked artifact
carried a stale queue and deferred to a file a future session cannot read. It
now records the decision inline: delete-and-rebuild goes ahead of pipeline step
4, prior order stands underneath.

§8 gains the premise the brief was quietly resting on: §5A claims project-level
CLAUDE.md/rules yield usable per-block git ages. That is untested. If instruction
blocks trace to one bulk commit, the age signal carries no information and shape
A collapses to the phrasing heuristic — which would answer §7.2 for us. Checked
before any scanner code, not after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNrtHo9hKSLKNyMS6b4Zuy
2026-07-29 09:50:33 +02:00
3086e8bb11 docs(plan): brief for delete-and-rebuild (config subtraction axis)
Operator relayed the "delete your CLAUDE.md every six months" idea from a
third-party summary of a Boris Cherny talk. Assessed rather than adopted: the
provenance is secondhand and deliberately kept non-load-bearing (this repo has
one scar from treating a plausible quote as fact), while the feature is argued
from the repo's own logic.

The gap is real and verified, not assumed: feature-gap has no inverse, and grep
over scanners/ confirms nothing measures instruction AGE — 'stale' appears only
for knowledge-register entries and plugin-cache versions. drift's saveBaseline/
diffEnvelopes plus backup.mjs/rollback-engine.mjs are already the undo
machinery that makes deletion a measurement rather than a gamble.

CLI ground truth checked against claude --help: --bare (sets
CLAUDE_CODE_SIMPLE=1), --system-prompt, --setting-sources, --add-dir. So the
env var the video calls undocumented is a documented flag here, which is what
would make an ablation harness buildable.

Brief only — no code, no chunk breakdown, no version committed. Three candidate
shapes with a likely landing (deterministic CA-VIN vintage scanner first),
hard constraints, open questions, and testable verification criteria.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNrtHo9hKSLKNyMS6b4Zuy
2026-07-29 09:49:25 +02:00
b4d819b72d fix(acr): pin Bash >> append discipline on shared implementation log (M-BUG-20)
implement.md spawns implementer agents in parallel batches, all appending to
the same implementation-log.md. Dogfooding showed agents satisfying 'Append
result to:' with a full-file Write — the last writer clobbered 4 of 6 entries.
Pin the mechanism in both contracts: append with Bash >> heredoc, never the
Write/Edit tool on the shared log. Shape tests pin the instruction in both
files (empirically verified: agents given the >> instruction appended safely).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:53:06 +02:00
0cd87e0597 fix(rul): globToRegex corrupts mid-pattern /**/ globs (M-BUG-19)
The ? -> [^/] replacement ran AFTER the {{GLOBSTAR_SLASH}} placeholder was
restored to '(?:/.+/|/)', corrupting the group opener '(?:' into '([^/]:' —
every rule pattern containing a mid-pattern '/**/' silently matched only the
zero-dir branch and live rules were flagged 'matches no files' (CA-RUL).
Found by dogfooding /config-audit implement on a throwaway repo copy: the
implementer agent's correct 'posts/**/post.md' rule was flagged dead.

Fix: run the ? replacement before placeholder restoration. Fixture outcomes
byte-identical; frozen v5.0.0 baselines untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:53:06 +02:00
4b7b2d9c48 fix(acr): analyze persists agent-returned report (M-BUG-18)
The Claude Code subagent harness instructs spawned agents NOT to write
report/summary/findings/analysis .md files — the parent reads the final
text message. Verified live: analyzer-agent skipped Write entirely and
returned the report inline, so analysis-report.md never landed on disk
and the plan/interview/status phases would find nothing to read.

New contract (orchestrator-writes pattern): analyzer-agent returns the
complete report as its final message; the analyze command saves it
verbatim to the session directory before presenting the summary.

Same class exists in plan/feature-gap/optimize/scanner agent pairs —
deliberately left for their own dogfood chunks (plan is judged as-is
first per the pipeline sequence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTontYwY5JGS4nL2AuiASy
2026-07-16 20:24:12 +02:00
69a4654dd7 docs(plan): v5.13 plan — model routing, effort awareness, dead references
Video-derived audit ('The Model Isn't the Moat') cross-checked against
primary sources. Verified: orchestrator+cheap-worker pattern and 5-level
per-agent effort tuning (official docs); rejected: the 'Fable low ≈ Opus
high' chart claim (contradicted by Anthropic's own pages). Five chunks:
register entries BP-MODEL-001/002, fix-engine xhigh hygiene, CA-CML dead
prose references, feature-gap model/effort opportunity, planner-agent
adversarial gate. Sequenced AFTER DEL B pipeline dogfood + batch release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTontYwY5JGS4nL2AuiASy
2026-07-14 10:45:57 +02:00
97867dbf37 fix(acr): AGT findings humanize to "Wasted tokens" not "Other" (M-BUG-17)
The agent-listing scanner (AGT) emits an always-loaded per-turn token cost
("Agent description is long, re-sent every turn in the always-loaded listing";
scanner category 'token-efficiency', "the dominant single always-loaded
source"). But SCANNER_TO_CATEGORY in humanizer.mjs had no AGT entry, so its
findings fell through to the 'Other' fallback (humanizer.mjs:140) — a bucket
that isn't even in the analyzer-agent's category list. Neither the scanner
prefix nor the per-finding category ('token-efficiency' is not in
CATEGORY_TO_IMPACT) resolved AGT to its true impact.

Same class as M-BUG-16/15: a finding type without its matching humanizer
mapping landing on a default that mismatches its own evidence. The analogous
SKL body finding correctly buckets "Wasted tokens"; AGT (the same always-loaded
token-waste mechanism) silently landed under the meaningless "Other".

Found during analyze-prep premise-verification of the linkedin-posts scan: the
3 AGT findings bucketed "Other" while the analogous SKL findings bucketed
"Wasted tokens".

Fix: add AGT: 'Wasted tokens' to SCANNER_TO_CATEGORY, alongside TOK/CPS/SKL.
RED-first (extended the Wasted-tokens category test to include AGT; the 'Other'
fallback test still uses a synthetic 'XXX' scanner, unaffected). Frozen v5.0.0
untouched (AGT post-dates it; humanizer bypassed for --raw/--json); no
default-output snapshot contains AGT -> 0 regen. Suite 1359/0.

Verified end-to-end on linkedin-posts: 3 AGT findings now "Wasted tokens", 0
"Other" remaining, 28 findings unchanged (category-only). All 16 orchestrator
scanner prefixes now covered by the category map (class closed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-30 13:51:55 +02:00
239e88cecb fix(acr): on-demand copy for oversized skill-body finding (M-BUG-16)
The skill-listing check emits a third finding for an oversized skill BODY
(v5.11 B7, RAW title "Skill body is large (loads on demand when the skill
runs)"). The body is an ON-DEMAND cost — it loads only when the skill is
invoked, not the always-loaded listing Claude reads every turn. The scanner is
careful to distinguish the two (RAW title + comment + evidence note).

But the humanizer-data SKL.static map had no entry for this title, so it fell
through to SKL._default ("A skill is using more of the listing budget than it
should"). The humanized title therefore claimed a listing-budget cost and
directly contradicted the finding's own humanized evidence ("loads ON DEMAND
only ... NOT every turn like the always-loaded listing") — the same internal
contradiction class as M-BUG-15/M-BUG-14, and the same "new finding type added
without a matching humanizer entry" gap the scanner checklist warns about.

Found by finding-granularity premise-verification of the linkedin-posts scan
before feeding it to the analyze pipeline (the prior session's pass focused on
the GAP findings and did not catch the SKL fall-through).

- humanizer-data.mjs: add SKL.static entry for "Skill body is large (loads on
  demand when the skill runs)" with on-demand-correct title ("A skill's body is
  large (it loads only when that skill runs)"), description, and recommendation.
  No listing-budget language; tier1/tier3 forbidden-word checks pass.
- RED-first tests at both layers: humanizer.test.mjs (humanizeFinding path:
  title is not the listing-budget _default, conveys on-demand body) and
  humanizer-data.test.mjs (static entry exists, on-demand-correct).

RAW envelope unaffected (humanizer bypassed for --raw/--json), frozen v5.0.0
snapshots untouched, default-output fixtures contain no oversized-body skill so
no snapshot regen. Suite 1357->1359/0 (+2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-30 13:30:22 +02:00
2975b0563f fix(acr): honest absence-state copy for two GAP enhancement findings (M-BUG-15)
The t2_3 ("No path-scoped rules") and t3_6 ("No subagent isolation")
feature-gap checks iterate a collection (rule files / agent files) and return
false for an EMPTY one — so they fire even when the user has zero rules / zero
subagents, the same state their presence-gap siblings flag. But the humanized
titles presupposed the feature already exists:
  - "Your rules all load on every conversation" (with zero rules)
  - "Your subagents share Claude's main work folder" (with zero subagents)
The second directly contradicts GAP-005 "You haven't set up any specialized
helper agents yet" in the same report — a user cannot simultaneously have no
subagents and have subagents that lack isolation. Found by dogfooding the
analyze pipeline against linkedin-posts (premise-verifying each finding before
trusting the analyzer-agent's report).

Decided fix = align the two titles with the house "You haven't set up X yet"
absence framing (state-neutral: honest for both the zero-state and the
has-but-unconfigured state), NOT a base-feature presence gate in the scanner —
that would change the frozen v5.0.0 marketplace-medium baseline (zero
rules/agents, freezes these gaps firing) and break the RAW byte contract.
Mirrors M-BUG-14's humanizer-layer, copy-only approach.

- humanizer-data.mjs: title "Your rules all load on every conversation" ->
  "You haven't set up path-scoped rules yet"; title "Your subagents share
  Claude's main work folder" -> "You haven't set up subagent isolation yet".
  description + recommendation unchanged. t3_5/t3_7 ("Your skills don't ...")
  left as-is: their possessive is correct in the common case where skills
  exist; the zero-skills edge is latent, not manifest here.
- RED-first unit test pins both titles existence-neutral (forbids "your rules
  all load" / "your subagents"; feature still named).

Frozen v5.0.0 snapshots untouched (RAW bypasses humanizer); default-output
snapshot regenerated (2 titles only, no collateral). Verified on linkedin-posts:
GAP-004/005/011 now all consistently absence-framed. Suite 1356->1357/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-30 12:57:40 +02:00
eb0b3fd29d fix(acr): size-neutral copy for "CLAUDE.md not modular" GAP (M-BUG-14)
The t2_2 feature-gap check is a pure presence check (rules file OR @import
present) with no length gate, consistent with its t2_3/t2_4/t2_5 siblings.
But the humanized copy claimed the file is "one big block" and that splitting
makes it "easier on the loading time" — an unconditional size/load-cost
overclaim. For a ~625-token non-modular CLAUDE.md the load saving is trivial,
so the description lied. Found by dogfooding feature-gap against linkedin-posts.

Decided fix = copy-softening, NOT a length gate in t2_2 (that would make it the
only size-gated check among the presence-check siblings + risks byte-stability
if a short non-modular CLAUDE.md fixture exists in snapshots).

- humanizer-data.mjs: title "one big block" -> "all live in one file";
  description drops "long"/"loading time", keeps the honest structural
  "split into linked files with @import or .claude/rules/" framing.
  recommendation unchanged.
- RED-first unit test pins the size-neutral copy (no "big"/"long"/"loading
  time" in title/desc, structural split framing kept).

Frozen v5.0.0 snapshots untouched (RAW envelope bypasses humanizer); SC-5
default-output byte-stable (no non-modular GAP finding in those fixtures).
Suite 1355->1356/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-30 11:02:45 +02:00
f4bf3ae2cb fix(acr): feature-gap scopes presence checks to authored config + reads settings cascade (M-BUG-13)
The GAP scanner's 25 presence checks ran over the full includeGlobal discovery, so
this plugin's own examples/optimal-setup (vendored across plugin-cache versions)
satisfied every tier-3 check — masking real feature gaps to GAP=0 on ANY target.
And the real ~/.claude/settings.json is invisible to the settings-key checks
(includeGlobal gotcha + maxFiles cap), which would flip statusLine/autoMode to
false positives once the maskers were removed.

- isAuthoredConfig: exclude plugin-bundled (~/.claude/plugins/) + nested examples/
  and tests/fixtures/ (relPath-relative, so a fixture scanned AS the target keeps
  its own files) from ctx.files + parsedSettings.
- readSettingsCascade: read the user->project->local settings cascade directly and
  merge into parsedSettings — immune to the discovery cap/gotcha.

Empty target: ~0 (masked) -> 18 humanized opportunities; no statusLine/autoMode
false positives. Frozen v5.0.0 snapshots + SC-5/6/7 byte-stable (marketplace-medium
has no nested demo trees; hermetic-HOME cascade adds nothing). Suite 1350->1355/0.
Found by dogfooding feature-gap against the machine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-30 10:09:10 +02:00
b58393099a fix(acr): posture --output-file humanizes findings in default mode (M-BUG-12)
feature-gap.md (Step 3-4) and posture.md (Step 3-4) read findings from
`posture.mjs --output-file` and group on the humanizer fields
(userActionLanguage / userImpactCategory / relevanceContext). But posture.mjs
only humanized the stderr scorecard — its --output-file JSON wrote the raw
v5.0.0-shape `result`, so every finding's humanizer fields were `undefined`.
Both commands silently degraded to the raw tier-fallback: v5.1.0 plain-language
output was dead for feature-gap and for posture's finding-level grouping.

Re-derived on tests/fixtures/marketplace-medium: 17 GAP findings, all three
humanizer fields undefined in the default --output-file JSON.

Fix (posture-CLI-local, surgical): humanize the output-file payload in default
mode, mirroring scan-orchestrator.mjs:277 — but posture nests the scanner
envelope under `result.scannerEnvelope` (its `result` has no top-level
`scanners` array), so humanizeEnvelope is applied to `result.scannerEnvelope`,
not `result` (the latter would no-op). --json / --raw stay raw, so the
explicit-v5.0.0-shape contract and snapshot byte-compat are preserved.

TDD: red-first test in posture-humanizer.test.mjs default-mode block asserts
GAP findings in the output file carry userActionLanguage/userImpactCategory;
a --raw --output-file guard asserts the raw shape is unchanged.

Suite 1350/0 (+2). Frozen v5.0.0 + SC-5/6/7 + default-output snapshots
byte-stable: --json/--raw bypass the humanizer (their snapshot tests use those
flags), and the default --output-file JSON is not snapshot-pinned. Committed,
not released — batches with M-BUG-11 in a later hardening release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-30 09:10:07 +02:00
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
96e32df87b docs(claude-md): trim project CLAUDE.md to invariants (−662 always-tok)
The plugin's own CLAUDE.md is loaded every turn while working in this repo
(measured 2,178 always-loaded tokens via `manifest`, the largest slice of the
2,745-tok project delta). Much of it was reference-grade prose that duplicates
README.md, `/config-audit help`, and docs/ — verbose per-command feature lists,
the full plain-language-output spec, the session-dir ASCII tree — none of which
is invariant for working on the plugin.

Trimmed to what is invariant: command names + one-line purpose, the agent /
hook tables (model/color/tools, script/event), finding-ID format, enforced
.claude/rules conventions, coding style, test command, gotchas. Detail now
points to README / docs/. Also dropped two stale badge-duplicate figures that
had already rotted (the README badge owns them): "18 commands" (now 21) and
"1279 tests / 72 files" (now 1344) — removed rather than re-pinned so they
can't go stale again.

Measured: CLAUDE.md 134->102 lines, 8844->6170 B, 2,178->1,516 tok (-30%);
config-audit project always-delta 2,745->2,083 tok (-24%). Full suite 1344/0;
frozen v5.0.0 + SC-5 + default-output snapshots byte-stable (no scanner/snapshot
reads this repo's CLAUDE.md). Docs-only — no version bump, no catalog ref change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-29 08:32:44 +02:00
1bdaefc268 release: v5.12.5 — "Dogfood denoise" (M-BUG-2/6/7/8/10 scanner false-positive batch)
Version-sync for the Fase-3 scanner false-positive batch (code already shipped in
bfd577a / dd9db60 / 7e94910 / 3cf5c71 / e8afb14):
- plugin.json 5.12.4 -> 5.12.5
- README version badge -> 5.12.5, tests badge 1307 -> 1344, new version-history row
- CHANGELOG [5.12.5] section (per-bug Fixed entries)

Batch theme: five scanners stop counting non-user / non-live config as the user's authored
cascade (plugin-bundled config, frozen backups, doc examples, forward-compatible settings keys).

checkReadmeBadges: passed:true (tests 1344, scanners 16, commands 21, agents 7, hooks 4 — all
match filesystem). Full suite 1344/0. Frozen v5.0.0 + SC-5 + default-output snapshots byte-stable;
no re-seed across all five fixes (each affected fixture's findings are genuinely unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq
2026-06-26 18:04:20 +02:00
e8afb148d3 fix(acr): conflict-detector segregates plugin-bundled configs (M-BUG-2)
CNF compared every discovered settings.json/hooks.json pairwise regardless of
origin, so it treated installed plugins' bundled configs — each plugin's own
settings.json/hooks.json plus its shipped test fixtures and examples under
~/.claude/plugins/ — as if they were the user's authored cascade. A "conflict"
between two plugins' bundled test fixtures is not something a user can resolve,
yet these dominated the count: 339 CNF findings on this machine (315 high-sev
permission allow/deny "conflicts", 18 duplicate-hook, 6 settings-key), almost all
sourced from plugin-internal fixtures. The Conflicts grade was F on pure noise.
Fix: CNF excludes any file whose path is under `.claude/plugins/` from conflict
analysis (new isPluginBundled predicate; absPath marker). Kept CNF-local rather
than a discovery-level skip on purpose: an active plugin's contributed
hooks.json/.mcp.json legitimately lives in plugins/cache and other scanners need
it — only conflict analysis must ignore plugin-bundled files. Same class as
M-BUG-8 (non-live config trees treated as live). Suite 1344/0 (+3: plugin-bundled
exclusion, discovery-side sanity, over-exclusion guard). Frozen v5.0.0 + SC-5
snapshots untouched (marketplace-medium has no plugins/ paths), no re-seed.
Dogfood ~/.claude CNF 339->0 (F-grade was 100% plugin-bundled noise; the ~3
genuine user-scope local settings have no actual conflicting keys, matching the
plan C5 "real surface ~3 files" prediction).

Follow-up (not in this fix): classifyScope tags plugin-bundled files by checking
basePath instead of the file's own path, so scope:'plugin' is effectively dead
for a ~/.claude-rooted scan. Fixing it would let every scanner trust the scope
field, but that is a discovery-layer change beyond this bug's scope.
2026-06-26 17:24:08 +02:00
3cf5c714a2 fix(acr): SET typo-gates unknown-key false positives (M-BUG-10)
The CC settings schema is passthrough (verified against the 2.1.193 binary): it
forwards unrecognized keys unchanged rather than rejecting them, so an arbitrary
unknown key is valid/forward-compatible, not an error — the finding's "silently
ignored" claim was factually wrong. The only real risk is a TYPO of a real key
(the intended setting then silently has no effect). Fix: flag an unknown key only
when it closely matches a known key (new levenshtein helper; edit distance <= 2,
both keys >= 4 chars); severity medium -> low; honest passthrough framing in the
scanner + humanizer. Also refreshed KNOWN_KEYS with 6 binary-verified keys
(agentPushNotifEnabled, remoteControlAtStartup, skipAutoPermissionPrompt,
skipDangerousModePermissionPrompt, skipWorkflowUsageWarning, tui). Suite 1341/0
(+12). Frozen v5.0.0 snapshots untouched (0 CA-SET findings there), no re-seed.
Dogfood ~/.claude/settings.json 6->0 (all 6 keys above were false unknown-key
findings; 0 typo flags introduced across 167 walked files).
2026-06-26 15:15:14 +02:00
7e94910566 fix(acr): token estimator discounts block-level HTML comments (M-BUG-6)
CLAUDE.md token estimates counted block-level <!-- --> HTML comments toward
always-loaded tokens, but CC strips them before injection (preserved only inside
code fences, per code.claude.com/docs/en/memory). Fix: new stripInjectedHtmlComments
+ effectiveMemoryBytes in active-config-reader; the CML cascade (walkClaudeMdCascade)
and token-hotspots now size CLAUDE.md from effective (stripped) bytes, while raw byte
figures stay honest. Block-level only — inline comments retained (conservative,
verified scope). Suite 1329/0 (+13). Frozen v5.0.0 snapshots untouched (no fixture
has <!--), no re-seed. Dogfood ~/.claude CLAUDE.md ~3386->3301 tok (~85 tok discount,
matches worklist prediction).
2026-06-26 14:29:24 +02:00
dd9db60fc9 fix(acr): CPS ignores fenced/inline code + CC-stable path vars (M-BUG-7)
CPS flagged ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR} (CC-provided stable
paths) and {date}/timestamp tokens shown in documentation as cache-busters.
Fix: skip fenced code blocks, strip inline-code spans, and whitelist CC-stable
vars before pattern-matching. Suppress-only — frozen v5.0.0 snapshots untouched
(CPS yields findings:[] there), no re-seed. Suite 1316/0 (+6). Dogfood ~/.claude
5->2 (3 doc false-positives suppressed; 2 remaining = own volatile test fixtures).
2026-06-26 12:45:14 +02:00
bfd577aeee fix(acr): file-discovery skips backups/ dirs — never live config (M-BUG-8)
A directory named `backups` holds backup COPIES, not live config, so walking
it during a config audit produces stale findings. config-audit's own session
backups (~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md) were the
canonical case: a ~/.claude-scope audit walked 36 frozen config copies as if
live, polluting CPS (C3) and HKV/RUL (C6) results. Same non-live-noise family
as M-BUG-2.

- Add `backups` to SKIP_DIRS (broad, name-based — consistent with vendor/dist/
  .cache): the rule is general, a backups/ dir is never live config.

TDD: 2 failing tests (no config under backups/ discovered + backups/ counted
as skipped) -> green; a third asserts live config beside the backups tree is
still discovered. Full suite 1310/0 (+3). Byte-stable: no fixture is named
`backups` and `backups` appears in zero frozen snapshots, so v5.0.0 + SC-5 +
default-output outputs are unchanged. Dogfooding ~/.claude: files under
/backups/ drop from 36 -> 0; 717 live config files retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq
2026-06-26 12:23:43 +02:00
346dfac6fa release: v5.12.4 — "Rooted rules" (M-BUG-9: RUL resolves rule glob against the rule's own project root)
Version-sync for the M-BUG-9 fix (code already in 18af5a2):
- plugin.json 5.12.3 -> 5.12.4
- README version badge -> 5.12.4, tests badge 1305 -> 1307, new version-history row
- CHANGELOG [5.12.4] section

checkReadmeBadges: passed:true (tests 1307, scanners 16, commands 21, agents 7, hooks 4 — all
match filesystem). Full suite 1307/0. Frozen v5.0.0 + default-output snapshots byte-stable
(the fix is a no-op when projectRoot === targetPath; RUL appears in no snapshot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq
2026-06-26 11:04:30 +02:00
18af5a24e9 fix(acr): RUL resolves rule glob against the rule's own project root, not the scan root (M-BUG-9)
A rule's paths:/globs: pattern scopes relative to the directory containing
the rule's .claude/, not the outer scan target. countGlobMatches globbed
against the scan root and collectProjectFiles' depth>4 cutoff never reached
deep matching files, so a live rule in a nested repo (e.g. a marketplace
checkout under ~/.claude) was wrongly flagged "matches no files / never
activates" (high) — a false F-grade for any user with rules in a nested repo.
Same scope-conflation family as M-BUG-1/2/8.

- deriveProjectRoot(ruleAbsPath): parent of the rule's .claude segment.
- collect + glob per project root (cached), relative to that root — so a
  nested repo's rule resolves against its own tree, where its files live.
- user-global rules (root === HOME) skip the no-match check: they scope
  against whatever project is active at runtime, not a fixed tree, so
  "matches 0 files here" is not a dead-rule signal (and avoids a HOME walk).

TDD: 2 failing tests (nested-repo false-positive + HOME guard) -> green.
Full suite 1307/0; frozen v5.0.0 + default-output snapshots unchanged (RUL
appears in none; the fix is a no-op when projectRoot === targetPath, i.e. the
common single-repo scan). Dogfooding C6: clears the 2 ktg-privat false
positives on the real machine and surfaces a previously-hidden genuine dead
rule (false negative) in the bundled optimal-setup example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq
2026-06-26 10:56:39 +02:00
4ad1875b31 release: v5.12.3 — "Phantom agents" (M-BUG-3/4/5: enumerateAgents counts only CC-registered agents)
Releases commit 7f097d5. enumerateAgents now counts only the agents Claude Code
actually registers: recurse into agent subdirs (M-BUG-3), dedupe project==user
path when the scope root is $HOME (M-BUG-4, root cause — also fixed rules and
output-styles), and require valid name+description frontmatter before counting a
file (M-BUG-5). Real-machine verify: user-agent count 13->0 (all 12 user agents
plus REMEMBER.md are frontmatter-less, so CC registers none), HOME project-dup
13->0; corrected always-loaded baseline is ~53, not 66. No count change (scanners
16, agents 7, commands 21); agent enumeration is machine-dependent and absent
from the frozen snapshots, so v5.0.0 + SC-5 + default-output stay byte-stable.
1305 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq
2026-06-26 00:37:40 +02:00
7f097d524f fix(acr): enumerateAgents counts only CC-registered agents — recurse + frontmatter filter + HOME dedup (M-BUG-3/4/5)
enumerateAgents previously counted every .md in an agents dir as an
always-loaded agent. Per the official CC subagents docs, CC registers a
subagent only when its frontmatter declares both name and description, scans
agents dirs recursively, and (at a HOME self-scan) must not count
~/.claude/agents twice.

- M-BUG-5: require valid name+description frontmatter; frontmatter-less files
  are registration no-ops costing 0 always-loaded tokens. Fixes the user-agent
  over-count (this machine: 13 -> 0).
- M-BUG-3: listMarkdownFiles gains opt-in recursion; enumerateAgents recurses
  so agents in subfolders (agents/review/x.md) are counted, matching CC.
- M-BUG-4: configDirs dedupes project==user paths, so a `manifest --global`
  self-scan (repoPath===$HOME) counts ~/.claude once (user scope), killing the
  spurious "project 13" double-count. Benefits rules/agents/output-styles.

TDD: 4 failing tests -> green. Full suite 1305/0; --json/--raw byte-stable,
frozen v5.0.0 + SC-5 + default-output snapshots untouched (no snapshot records
agent enumeration rows).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq
2026-06-26 00:19:27 +02:00
194 changed files with 16460 additions and 1148 deletions

View file

@ -1,7 +1,7 @@
{
"name": "config-audit",
"description": "Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine",
"version": "5.12.2",
"version": "6.0.0",
"author": {
"name": "Kjell Tore Guttormsen"
},

View file

@ -12,7 +12,7 @@ All command files MUST include:
---
name: plugin:command
description: Short description of what this command does
allowed-tools: Read, Write, Bash, Task
allowed-tools: Read, Write, Bash, Agent
model: sonnet
---
```

3
.gitignore vendored
View file

@ -38,5 +38,8 @@ NEXT-SESSION-PROMPT*.local.md
*.local.md
*.local.json
*.local.sh
# Local-only dogfood harnesses: they index the operator's private config by line
# number (see docs/subtraction-fasit.local.md) and must never reach the public mirror.
*.local.mjs
.DS_Store
.claude/

View file

@ -5,6 +5,586 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [6.0.0] - 2026-08-18
### Summary
"Prose is not a contract" — a MAJOR release whose theme is a *class* of defect rather than a feature
area. Across three sweeps (Q1, Q2, Q_AUDIT) the same shape kept surfacing: a command template stated
an invariant in prose, code on the other side depended on it, and nothing checked that the two still
agreed. The write-scope **gate** was policy paraphrased in five templates while exactly one writer
imported it. The **argv** a template built was never checked against the CLI receiving it —
`--stale-after 30` arrived as a single argument, matched no flag, and the command reported success
about a threshold the user had just overridden. And the **data contracts** — backup manifests,
`state.yaml`, `scope.yaml` — are hand-built by the model and parsed by engines that know one frozen
example. The first two are now enforced in code and tests; the third is measured and ranked in
`docs/q-audit-prose-invariants.md`, with the recovery path (`rollback`) at the top as the surface
that runs precisely when the user is already in trouble.
**Breaking — a finding ID's `{NNN}` names the check, not its emission position (`M-BUG-28`).**
IDs are therefore not unique per finding: one check failing in three files emits three findings
sharing an ID, and `(id, file, line)` is the instance key. Any consumer keying on `id` alone must
move to the triple. `scanners/lib/finding-codes.mjs` is now the single authority — an undeclared or
missing code **throws**, with no counter fallback, because a fallback lets a half-converted scanner
ship IDs that look valid. Retired numbers are never reissued. Frozen `v5.0.0` baselines mask IDs
rather than re-deriving them.
**37** commits since 5.13.0. **1752** tests, 0 failing. GAP dimensions **25 → 24** (one `/doctor`
duplicate retired). No component-count change: scanners **16**, agents **7**, commands **21**,
hooks **4**, knowledge entries **8**.
### Fixed
- **`M-BUG-45``/config-audit knowledge-refresh --stale-after N` was silently dead under zsh.** The
command built `STALE_AFTER="--stale-after 30"` and expanded it unquoted, relying on the shell to
split it into two argv entries. bash does; **zsh — the macOS default since Catalina — does not**.
The CLI received one argv entry reading `--stale-after 30`, matched no flag, and fell back to the
90-day default while reporting success: "✓ All 14 register entries were re-verified within the last
90 days" — a true-sounding sentence about a threshold the user had just overridden. Measured:
`set -- $STALE_AFTER; echo $#` prints 1 under zsh, 2 under bash. The threshold is now passed as its
own quoted argument, and a guard rejects any command template that packs a flag and its value into
one variable.
- **`M-BUG-46` — four CLIs accepted unknown flags in silence.** No `else` branch at all in the parse
loop, so an unrecognised flag vanished without a trace: a typo'd `--ledger-file` made
`campaign-cli` report confidently on the *default* ledger instead of the one the caller named, and
a mistyped `--stale-after` reverted to 90 days. This is what made `M-BUG-45` silent rather than
loud. `campaign-cli` and `knowledge-refresh-cli` now fail with exit 3 and name the offending flag;
`optimize-lens-cli` and `token-hotspots-cli` share the defect and are closed together with their
positional-swallow arm in the v5.14 argument-handling work (tracked in the guard's `KNOWN_OPEN`).
- **`M-BUG-47` — the machine-wide token bill counted repos it could not read.** `refresh-tokens`
routed a repo to `skipped[]` only when `readActiveConfig` *threw*, but that function resolves any
path and its sub-readers all tolerate ENOENT, so a repo that does not exist yields an empty config
instead of an error. Measured: a phantom path landed in `swept[]` with a 0-token delta,
`skipped[]` was empty, and the roll-up claimed `reposWithTokens: 3` for a machine with two real
repos — so the command's own honesty clause ("name those repos plainly so the user knows the bill
omits them") could never fire. Readability is now checked before the sweep.
- **`M-BUG-48``campaign add` vouched for paths that do not exist.** `add /finnes/ikke` returned
`added: [...]` and exit 0, and the phantom row then sat in the backlog permanently. Paths are still
tracked (an unmounted volume is a legitimate reason for a repo to be absent today) but are now
reported separately as `addedUnverified`, and `campaign.md` names them instead of glossing over them.
- **`M-BUG-49``posture` reported a crash as a passing grade.** Its top-level catch set
`process.exitCode = 1`, while every command in this plugin is told that "codes 0, 1, 2 are normal
(PASS/WARNING/FAIL). Only 3 is a real error". A fatal error was therefore indistinguishable from a
WARNING, and the command went on to Read a payload file that was never written. Measured as the
single outlier: 1 of 14 scanners. Now exits 3.
- **`M-BUG-50``knowledge-refresh` read one register and wrote another, and the gate saw neither.**
Step 6 said `Edit knowledge/best-practices.json` — an unanchored relative path — while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/knowledge/best-practices.json`, which for a marketplace install is the plugin
cache. So a normal user has no such file in their repo at all; in the plugin's own checkout the
command read the cache and wrote the working tree; and step 6.3's validation gate ran the cached
test against the cached register — **validating the copy that was not edited, and passing no matter
what was written.** Every write step is now anchored, and the command states where the register
actually lives (a marketplace copy is discarded on the next plugin upgrade).
- **No scanner created its `--output-file` parent directory.** `saveLedger` always did; the payload
write never did — an accidental asymmetry across all 13 writers. `commands/campaign.md` writes its
report under `~/.claude/config-audit/sessions/`, so on a fresh machine — precisely the first run
that `campaign-cli` otherwise handles gracefully with `initialized: false` — the write threw ENOENT
and the command's exit-code table reported it as a possibly-corrupt ledger, steering the user away
from the one action that would have helped. All payload writes now go through
`scanners/lib/write-output.mjs`.
- **`M-BUG-40`, fifth arm — `posture` wrote four temp files it could never read back.** #49 closed the
`$$`/cross-block class in four commands, but `posture.md` survived it, and so did the guard written
to prevent exactly this. The guard compared each `$$` path against the block that created it, so a
path written **once** and then read via prose ("Read the JSON output file using the Read tool") had
no second occurrence to flag. Measured live: the scanner wrote `/tmp/config-audit-posture-21614.json`
from PID 21614 while the next Bash call ran as PID 23772, and the read step had no path to hand the
Read tool at all. The invariant is now blanket — **no `$$` in any temp path in any command file**
which also caught `fix.md` and `feature-gap.md`. All five sites now use fixed literal paths, repeated
literally in every step that needs them.
- **`M-BUG-43` — commands leaked whole JSON payloads into the transcript.** Every scanner except
`scan-orchestrator` writes its payload to stdout when `--raw`/`--json` is set, **even when
`--output-file` was given** — and the command templates redirected only stderr. Measured on a real
repo: `posture` 255 182 B, `whats-active` 35 922 B, `drift` 28 316 B, `manifest` 23 825 B, `tokens`
8 768 B. `fix` and `feature-gap` were the worst case: both ran posture with `--json`, never read the
file they wrote, and in practice recovered a single letter grade from a quarter-megabyte dump — in
the plugin that exists to cut token cost. 13 invocations across 10 command files now redirect stdout,
and the two commands that needed the data read it from their output file instead.
- **`tokens` swallowed two documented flags.** `--json` and `--with-telemetry-recipe` were listed as
recognized flags but never threaded into the CLI call, so `--json` returned the *humanized* payload
where the docs promised byte-stable v5.0.0 output (measured: 4/4 findings carried humanizer fields,
and the title read "Your file starts with content that changes between turns" instead of
"Cache-breaking volatile content at top of CLAUDE.md"), while `--with-telemetry-recipe` silently
produced no `telemetry_recipe_path` — the very flag the command's own closing tip recommends.
- **`M-BUG-42``manifest` asked for a field the scanner never emits.** The render contract used
`{load}`; the payload carries `loadPattern`. The Load column — which the command's own prose calls
the whole point of the view — would render blank for all 96 rows. `posture`'s headline had the same
shape (`{qualityAreaCount}`, never emitted) and now takes its count from the humanized scorecard
rather than `areas.length`, which counts a Feature Coverage row the table below deliberately excludes.
### Removed
- **GAP dimension `No autoMode classifier` (D1) — retired as a `/doctor` duplicate.** CC 2.1.226's
`/doctor` Check 8 covers auto mode with usage-weighted judgement, and the binding positioning
(README «config-audit vs. the built-in /doctor») forbids carrying a feature whose whole value is
duplicating a `/doctor` check. What is retired is only the *"adopt this feature"* nudge; the
deterministic side stays untouched — SET still validates `autoMode` structure and still flags it
as dead config in shared project settings. GAP dimensions: **25 → 24**.
The title lived in **four** tables, not the two the removal was scoped against: the dimension
list, the scoring `TITLE_TO_ID` map, the humanizer's static translations, and — the one that
moves a user-visible number — the scoring denominators (`TIER_COUNTS` t3 8→7,
`TOTAL_DIMENSIONS` 25→24, `MAX_WEIGHTED` 42→41). `findGapId` falls back to `'unknown'` silently,
so a partial removal would have degraded without failing. A blanket sync invariant now asserts
all four against `GAP_CHECKS` rather than checking occurrences pairwise, and each arm was
verified red against its own defect.
Utilization shifts accordingly (fixture: 43 → 44). `risk_score`, `risk_band`, `verdict`,
`overallGrade`, `maturity` and `segment` are byte-identical across the change — the dimension was
severity `info` (zero risk weight) and GAP is excluded from the overall grade.
**Frozen `tests/snapshots/v5.0.0/` stays untouched.** The removal-twin normalizer
(`tests/helpers/strip-retired-gap.mjs`, mirroring `strip-added-scanner.mjs`) strips the retired
dimension from whichever side still carries it and re-derives GAP IDs — retiring a dimension from
mid-list shifts every later ID by one. The derived utilization figures are dropped from
comparison rather than recomputed, since recomputing them in a test helper would assert the new
arithmetic against itself; they are covered exactly in `tests/lib/scoring.test.mjs`. Re-seeding
the baselines was rejected: it would silently bake in any other drift accumulated across every
scanner those four files cover.
### Added
- Four command-template shape tests (1449 → 1453), each verified to fail before the fix: a blanket
`$$` ban, stdout-redirect discipline for any scanner invoked with `--output-file` in raw/json mode,
flag threading from prose to shell, and a render-contract test that checks every `{field}` against a
**live payload generated from a fixture** rather than a hardcoded key list, which would drift.
- **`M-BUG-40` — command templates assumed shell state survives between fenced blocks.** It does not:
every ```` ```bash ```` fence is executed as its own Bash call, in its own process. A variable
assigned in one block is empty in the next, and `$$` (the PID) differs between calls, so a
`/tmp/foo-$$.json` path created in one block can never be reconstructed in a later one. The defect
was surfaced by dogfooding `plan` + `implement`, and **confirmed at runtime by the planner agent
itself**, which reported that `Mode: $RAW_FLAG` "arrived literally unsubstituted" — `--raw` was
documented in both files while being functionally dead. A machine sweep found the same root in
**20 places across 9 files**, far past the two predicted: `$RAW_FLAG` referenced from non-shell
agent prompts (`analyze`, `plan`, `implement`); `$TMPFILE` referenced across blocks in `tokens`,
`manifest`, `whats-active` and `plugin-health`, so each command could not read the file it had just
written; `$GLOBAL_FLAG` in `fix`; `$TODAY` in `campaign`, which was **never assigned in any block**
and passed `--reference-date ""` to a write CLI; and three `$$` temp paths handed to the Read tool
in `fix`, which expands neither `$$` nor variables. All now follow the hardened `drift.md` pattern:
a fixed literal path, or a re-derivation inside each block that needs it.
- **`implement` handed out a rollback ID it never captured.** The backup directory was created with
`mkdir -p .../$(date +%Y%m%d_%H%M%S)/`, so the timestamp existed only inside a command
substitution, while step 6 promised `/config-audit rollback {timestamp}` — the one message a user
reads after a bad run. The step now prints `BACKUP_ID` and substitutes it literally.
- **`plan` reported "No analysis results found" for valid sessions.** Step 1 pointed the Read tool at
`~/.claude/config-audit/sessions/*/state.yaml`; Read takes one literal path and does not expand
`*`, so the lookup failed and the command reported the session as missing. It now uses Glob, and
additionally verifies `analysis-report.md` exists before spawning the planner agent — a session can
carry a valid `state.yaml` and still be missing its report.
- **Phase commands wrote `state.yaml` with two of the four required fields.** `.claude/rules/state-management.md`
mandates `current_phase`, `completed_phases`, `next_phase` and `updated_at`; `analyze`, `discover`,
`implement`, `interview` and `plan` named only a subset. Because the planner agent writes all four,
a follow-up full-file Write naming two **deletes** the other two — the fields that make an
interrupted run resumable.
- **`implement` documented a rollback semantics that does not exist.** Its "## Rollback" section
promised to "delete newly created files", while `rollback.md` deliberately leaves them in place and
lists them under "Left in place" (deletion is unimplemented; `M-BUG-26` remains open). The doc now
mirrors actual behaviour rather than describing a half-restore as clean.
- **`implement` claimed a score delta with no source**, since nothing captured the pre-change grade
before the edits ran, and its implied posture call omitted both `--output-file` and `2>/dev/null`
required by the output rules. It now reports a delta only when a pre-change grade was actually
measured.
- **`verifier-agent` was instructed to write a report it has no tool to write** (`tools: Read, Glob,
Grep`, and "Read-only validation" by design). It now returns findings as its final message and the
command appends them with Bash `>>`, preserving both the read-only design and the shared-log
append discipline.
- **Dead tool name in always-loaded context:** `.claude/rules/command-development.md` taught
`allowed-tools: ... Task` while every command uses `Agent`, and `interview.md` carried two more
`Task` references. `planner-agent.md` also contradicted itself — line 110 forbids inline file
content while its own output template demonstrated exactly that, pushing plans past the 200-line
budget the same file sets.
### Fixed (previously released work)
- **`M-BUG-39` — every scanner CLI could truncate its own output when piped.** `process.exit()`
terminates immediately, but Node writes stdout **asynchronously** when stdout is a pipe, so whatever
is still buffered is discarded. `scan-orchestrator.mjs` measured **246 854 bytes to a file vs
65 536 to a pipe** (and 131 072 on another run — the cut point is a nondeterministic flush race),
handing any machine consumer truncated, unparseable JSON that reads like a corrupt file rather than
a cut-off. Reported by `org-ops`, whose census pipes our envelope. The whole class is closed, not
just the CLI where it was observable: `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 happen to fit the pipe buffer today. All 38 sites across
14 files now set `process.exitCode` and return, letting Node exit once stdout drains — the pattern
`self-audit.mjs` and llm-security's orchestrator already used. `fail()` throws instead of exiting so
it keeps its never-returns contract; exit codes and `Error:`/`Fatal:` stderr text are unchanged.
Guarded by `tests/scanners/cli-pipe-integrity.test.mjs`: one behavioural test that pipes a >128 KB
envelope, one class sweep over `scanners/*.mjs`.
- **`M-BUG-36``/config-audit drift --list` showed nothing.** `drift-cli.mjs` accepted
`--output-file` but list mode ignored it, and the listing itself goes to **stderr**, which
`commands/drift.md` discards with `2>/dev/null` (ux-rules rule 2). The command received **0 bytes**
and could render no baselines at all. List mode now honours `--output-file`; `--raw`/`--json` stdout
is unchanged and byte-stable. Fourth instance of the stderr-only class after `M-BUG-33`.
- **`M-BUG-37``/config-audit feature-gap` promised a backup it never made.** Step 6's
"Create backup" ran `fix-cli.mjs <path> --json`, but fix-cli is **dry-run by default**: no backup was
written and `backupId` came back `null`, after which the command edited the user's configuration
believing it could be restored. Passing `--apply` would have been worse — it executes unrelated
auto-fixes the user never selected. The step now copies the files itself and states plainly that
plain copies are restored by copying them back, not by `/config-audit rollback` (`M-BUG-31` class).
- **`M-BUG-38``fix-cli.mjs` sent users to a script that does not exist.** After applying fixes it
printed `Rollback: node scanners/rollback-cli.mjs <id>`; there is no `rollback-cli.mjs` — only
`rollback-engine.mjs`, driven by `/config-audit rollback`. A dead reference in the one message a
user reaches for after a bad fix. Now points at the command.
- **`M-BUG-21` (fourth arm) — command templates fed bracketed placeholder flags to the arg loops.**
Five templates (`config-audit.md`, `discover.md`, `fix.md`, `tokens.md`, `whats-active.md`) carried
literal `[--global]` / `[--full-machine]` / `[--verbose]` inside executable bash blocks. A bracketed
placeholder does not start with `-`, so every scanner's `else if (!args[i].startsWith('-'))` branch
takes it as the **scan target** — silently scanning a path that does not exist. Replaced with empty
shell variables that expand to nothing when the flag does not apply.
- **`/config-audit interview` and `analyze` never said which session they act on.** Both referenced
`{session-id}` with no resolution rule, while every other session-aware command globs
`sessions/*/state.yaml` and takes the most recent. Two runs could write to two different sessions.
Both now resolve the session explicitly and exit when none exists.
- **`/config-audit interview` could rewind a finished session.** The mandated state write had no bound,
so running the optional interview against a session that had already reached `implement` reset
`current_phase` and re-added phases. It now appends `interview` only if absent and leaves the
furthest phase reached intact.
- **`/config-audit cleanup` interpolated an unvalidated id into `rm -rf`.** An empty or malformed
`{session-id}` expands the path to `sessions//`, deleting **every** session. The id must now match
`^[0-9]{8}_[0-9]{6}$` or come verbatim from the directory listing; anything else is refused and
reported.
- **`/config-audit status` advertised a command that does not exist.** It documented
`/config-audit resume {session-id}`; there is no `resume` command. Replaced with how session
selection actually works. A test now fails on any `/config-audit <word>` reference in `commands/`
without a matching file.
- **`/config-audit status all` was documented but never parsed.** The flag-parse step knew only
`--raw`. It now parses `all` and routes to the all-sessions table.
### Fixed (previously)
- **`M-BUG-21` (third arm) — `plugin-health-scanner.mjs` swallowed unknown flags, and the wrong
target looked *green*.** The same `else if (!args[i].startsWith('-')) targetPath = args[i]` loop:
`--output-file /tmp/x.json` was dropped and `/tmp/x.json` became the scan target. Where `drift`
produced phantom drift, this produced a **reassuring** answer — a non-existent path discovers no
plugins, so the scanner reported `No plugins found` (info) and exit `0`. Unknown options and a
value-less `--output-file` now exit `3`.
- **`M-BUG-33``/config-audit plugin-health` read zero bytes.** The scanner had no `--output-file`
(ux-rules rule 2) and its default-mode report goes to **stderr**, which `commands/plugin-health.md`
discards with `2>/dev/null` before telling the agent to "read stdout output (JSON)". The command's
default path could not produce the report it documents. `--output-file` now writes a humanized
payload; `--raw`/`--json` stdout is unchanged and byte-stable.
- **`M-BUG-34` — the report's per-plugin table and Cross-Plugin section were unbuildable.** Per-plugin
data (`commandCount`, `agentCount`) and the grade formula never left `scan()` — the only grade code,
`formatPluginHealthReport`, had no caller — and cross-plugin findings were flattened into `findings`
behind a `category: 'plugin-hygiene'` they share with per-plugin findings. The command mandated both,
so it had to fabricate them. The payload now carries `plugins[]` (name, declaredName, counts, score,
grade via the shared `pluginGrade`) and `cross_plugin_findings[]` (also marked `crossPlugin: true`),
via a new `scanDetailed()`; `scan()`'s frozen v5.0.0 envelope is untouched.
- **`M-BUG-35``.claude-plugin/marketplace.json` was reported as an unknown file.** It is the
documented, required location for a marketplace catalog, and a marketplace entry with
`"source": "./"` makes the repo root its own plugin — such a repo legitimately carries both files.
Genuinely unexpected files in `.claude-plugin/` are still flagged.
- **`commands/posture.md` discarded both optional scanners' output.** Its `--drift` and
`--plugin-health` sections ran `drift-cli.mjs` / `plugin-health-scanner.mjs` in default mode under
`2>/dev/null` and read stdout, which is empty in that mode. Both calls now use `--output-file`.
- **`M-BUG-21``drift-cli.mjs` had no `--output-file`, and its argument loop turned the missing
flag into a wrong scan target.** The loop ended in `else if (!arg.startsWith('-')) targetPath = arg`
with no unknown-flag branch, so an unrecognised flag was dropped silently and its *value* fell
through to the scan target: `drift-cli.mjs . --output-file /tmp/x.json` scanned `/tmp/x.json`, a
path that does not exist, and reported the resulting near-empty scan as drift — permanently, and
without a warning. The same silence was destructive for `--save --name` with the value omitted:
`--name` was ignored, the name stayed `default`, and an existing baseline was **overwritten**.
Unknown options and value-less `--name`/`--baseline`/`--output-file` now exit `3`.
- **`M-BUG-21` (second arm) — `/config-audit drift` captured nothing at all.** `commands/drift.md`
ran the CLI under `2>/dev/null` and told the agent to "read stdout", but the default-mode report,
the `--save` confirmation, and the `--list` output all go to **stderr**. All three modes returned
empty. `--output-file` now writes the diff (humanized in default mode, raw under `--json`/`--raw`,
matching `posture.mjs`), and the command reads that file; `--save` passes `--json` for its
confirmation.
- **`M-BUG-27``drift` compared against baselines anchored to a different directory and called it
"improving".** `diff-engine` never checked the baseline's stored `target_path` against the current
scan target. Diffing a repo against a baseline saved elsewhere marked every baseline finding
"resolved" and every current finding "new" — a 100% phantom diff surfacing as a *reassuring* trend,
on the **default** baseline. The CLI now warns on stderr in every mode and carries
`_baselineAnchor {matches, baselineTarget, currentTarget}` in the default-mode payload, so a caller
running under `2>/dev/null` can still see it. `--json`/`--raw` stdout stays v5.0.0-shaped and the
frozen `drift.json` snapshot is untouched.
- **`M-BUG-21` (third arm) — `fix-cli.mjs` had the same unvalidated argument loop, where it moves the
*write* target.** An unrecognised flag was dropped and its value became the scan target, so
`fix-cli.mjs <repo> --output-file /tmp/x.json` silently audited `/tmp/x.json`; with `--apply` the
same slip relocates what gets written. Unknown options and value-less `--output-file` now exit `3`.
`--dry-run` — documented in `commands/fix.md`'s `argument-hint` but never implemented — is now
accepted instead of silently dropped, and `--output-file` writes the fix payload to disk so
`commands/fix.md` can read a file rather than parse stdout it runs under `2>/dev/null`.
- **`M-BUG-31``fix` promised a mandatory backup it did not always take.** `fix-cli.mjs` excluded
`file-rename` from the backup set, so a rule file whose only defect was its extension was renamed
with **no** backup entry — while the command told the user "every fix creates a backup first" and
handed back a `backupId` that could not restore it. The source file is now backed up like any other.
- **`M-BUG-32` — verification re-scanned a different scope than the fix run.** `verifyFixes` hardcoded
`includeGlobal: false`. After a `--global` run every user-scope finding fell out of the re-scan and
was therefore counted as *verified*: a clean "fixed" report for files nothing had touched
(reproduced against an untouched `~/.claude/CLAUDE.md`). It now inherits the run's scope, and
`commands/fix.md` passes `--global` to every step instead of only the display scan.
- **`M-BUG-29` — two fixes on one file were applied in an order that guaranteed failure.** A rule file
with both `globs:` and a non-`.md` extension had the rename applied first; the frontmatter fix then
failed with `ENOENT`. Renames now sort after every other fix.
- **`M-BUG-30` — critical fixes sorted last.** `severityOrder[s] || 4` maps `critical` (weight `0`) to
`4`, the opposite of the documented "critical first" contract. The old test used the same falsy
fallback, so it agreed with the bug. Now `?? 4`.
- **A failed fix no longer exits `0`.** `fix-cli.mjs` returns `2` when any planned fix failed, matching
the `0/1/2 = PASS/WARNING/FAIL`, `3 = error` convention the other scanners follow.
**1420** tests (+10). No count change (scanners **16**, agents **7**, commands **21**, hooks **4**).
## [5.13.0] - 2026-07-31
### Summary
"Pipeline hardening" — the batch release of everything found by dogfooding the plugin against the
maintainer's real machine and by walking the `analyze → plan → implement → rollback` pipeline
end-to-end on a throwaway repo copy: **one new lens mode** (`optimize --subtract`) and **14 bugs**
(`M-BUG-11``M-BUG-20`, `M-BUG-22``M-BUG-25`), every one of them a real defect a user could hit.
The minor bump is carried by `--subtract` alone; the other 14 are fixes. Two themes run through them:
**agent-facing commands were scanning config the user cannot act on** (plugin-bundled and vendored
copies masking real findings), and **new finding types kept shipping without their matching humanizer
entry**, so plain-language output contradicted the finding's own evidence. The rollback chunk found the
worst class in the repo: a `restoreBackup` that returned `{restored: [], failed: []}` — a *success-shaped
no-op* — because nothing agreed on where a backup lives or what its manifest looks like.
No count change (scanners **16**, agents **7**, commands **21**, hooks **4**). Frozen `v5.0.0` snapshots
untouched throughout; the SC-5 default-output snapshot was regenerated once, for two humanized titles
only (`M-BUG-15`). **1398** tests (+54).
**Known and deliberately not fixed in this release:** `rollback` still cannot delete files that
`implement` *created* — a backup cannot hold a file that never existed. It no longer fails silently
(manifests carry a `created:` list, `restoreBackup` returns `createdNotRemoved`, and `rollback.md`
requires the report), but automatic deletion of user files is destructive and gets its own design.
`drift-cli.mjs` still lacks `--output-file` (`M-BUG-21`).
### Added
- **`optimize --subtract` — the subtraction axis (`BP-SUB-001`).** Every command so far asked an
addition question: what to add, what to move, what it costs. Nothing asked what no longer earns its
always-loaded rent. `--subtract` adds that as a fourth `lensCheck` on the existing hybrid motor — a
mode, not a 22nd command or a 17th scanner, because the measured payoff (~18% of one file) justifies
a mode and no more. It is **opt-in and proposes only**.
It is also the only lens that proposes *removing* config, so it carries a guarantee the others don't
need: **a load-bearing block is never a candidate.** Precision is asymmetric — a missed dead line
costs a few tokens per turn, a wrongly deleted one costs a broken script or a wrong remote — so the
floor is decided in code (`scanners/lib/floor-exclusion.mjs`) *before* the opus judge sees anything,
never in prose. That ordering is an invariant, not an implementation detail.
Granularity is the leaf block, with two structural exceptions: a paragraph ending in `:` merges with
the list it introduces, and an ordered list is a contract whose steps inherit floor from any sibling.
Unordered lists deliberately do **not** inherit — a load-bearing bullet and a disposable one routinely
share a list.
Verified against a hand-built ground truth written *before* any classifier existed, with the
comparison machine-checked rather than read by eye: **zero load-bearing blocks proposed**, 11/18
deletable groups surfaced, ~756 tok ≈ 18% of a ~4300-token file — inside the pre-registered band. The
gate is re-runnable via `scripts/dogfood-subtraction-gate.local.mjs`. Three bugs the dogfood run
exposed are now covered by fixtures: JS `\b` is ASCII-only so `/\bunngå\b/` never matched (every
Norwegian keyword ending in `æ/ø/å` was silently dead); a bare `word/word` is not a path
("pros/cons" vetoed the largest deletable block); "mid-sentence" must key on a preceding lowercase
letter, or `**bold labels:**` read as entities and cost 4 of 11 groups.
`BP-SUB-001` is grounded entirely in the Anthropic steering blog already cited by
`BP-MECH-001..004` and asserts nothing from the talk that motivated the feature.
### Fixed
- **`rollback` — the backup path contract the engine and the commands disagreed on
(`M-BUG-22`/`M-BUG-23`/`M-BUG-24`/`M-BUG-25`).** Pipeline step 4 dogfood: `/config-audit rollback`
could not see a single one of the four real backups on this machine, and reported "Backup not found"
for one sitting right there. Four defects, one root — nothing agreed on where a backup lives or what
its manifest looks like.
**`M-BUG-22` (high):** `lib/backup.mjs` resolved `~/.config-audit/backups` (pre-v2.2.0) while every
command, agent and doc uses `~/.claude/config-audit/backups`. The auto-backup hook and `fix-cli` wrote
to the first, `implement` to the second, `rollback` read only the first — so `listBackups()` returned
**9 phantom backups from the test suite** and **0 of the 4 real ones**. The canonical root is now
`~/.claude/config-audit/backups`, with the legacy root kept **readable** (`legacy: true`) so older
backups stay listable and restorable.
**`M-BUG-25` (high, the worst failure mode in the file):** `parseManifest` understood only the
engine's quoted `original_path:` spelling, but `implement` hand-builds its manifest with
`- backup:`/`original:`/`sha256:`. Every implement-made backup parsed to zero files and
`restoreBackup` returned `{restored: [], failed: []}` — success-shaped, and silent. Both formats parse
now, and a manifest with unparseable entries **throws** instead of pretending to succeed.
**`M-BUG-23`:** both session hooks watched `~/.config-audit/sessions`, which does not exist — "check
for active sessions" had never fired once. It fires now.
**`M-BUG-24`:** the suite called `createBackup()` against the developer's real home, leaving nine stray
backups there while `cleanupOldBackups()` deletes past ten. The root is overridable via
`CONFIG_AUDIT_BACKUP_ROOT` / `CONFIG_AUDIT_LEGACY_BACKUP_ROOT`, and both test files use it — any new
test touching `createBackup()` must too.
Verified against backup `20260717_032636` on a throwaway copy, through the previously broken engine
path: 3/3 files restored **byte-exact** (sha256 match), zero writes outside the copy, backup dir
unmodified.
- **`rules-validator``globToRegex` corrupted mid-pattern `/**/` globs (`M-BUG-19`).** The
`?``[^/]` replacement ran *after* the `{{GLOBSTAR_SLASH}}` placeholder was restored to `(?:/.+/|/)`,
corrupting the group opener `(?:` into `([^/]:`. Every rule pattern containing a mid-pattern `/**/`
silently matched only the zero-dir branch, so live rules were flagged "matches no files" (`CA-RUL`).
Found by dogfooding `/config-audit implement` on a throwaway repo copy: the implementer agent's
correct `posts/**/post.md` rule was flagged dead. Fixture outcomes byte-identical.
- **`analyze` persists the agent-returned report (`M-BUG-18`).** The Claude Code subagent harness
instructs spawned agents *not* to write report/summary/findings/analysis `.md` files — the parent
reads the final text message. Verified live: `analyzer-agent` skipped `Write` entirely, so
`analysis-report.md` never landed on disk and the plan/interview/status phases found nothing to read.
New orchestrator-writes contract: the agent returns the complete report as its final message and the
`analyze` command saves it verbatim before presenting the summary. The harness note is **file-type
specific** — `plan` was dogfooded afterwards and writes `action-plan.md` without friction, so the same
fix is *not* needed there.
- **`implement` pins `>>` append discipline on the shared log (`M-BUG-20`).** `implement.md` spawns
implementer agents in parallel batches, all appending to the same `implementation-log.md`. Dogfooding
showed agents satisfying "append result to:" with a full-file `Write` — the last writer clobbered 4 of
6 entries. Both contracts now pin the mechanism: append with a Bash `>>` heredoc, never the Write/Edit
tool on a shared log.
- **`optimize` lens scopes out plugin-bundled CLAUDE.md + keys candidates by absolute path
(`M-BUG-11`).** The lens CLI fed its precision-gate agent every CLAUDE.md discovery returned, including
the 256 files under `~/.claude/plugins/` — vendored copies across every cached version plus their
fixtures and examples. `optimize --global` produced 454 candidates across 92 "files", ~250 of them from
plugin-internal files a user cannot act on (the plugin overwrites them on update). Second defect:
candidates were keyed by `relPath || absPath`, and `relPath` collides across scopes — a repo-root
`CLAUDE.md` and `~/.claude/CLAUDE.md` both key to `CLAUDE.md`, so the two files that actually matter
merged into one indistinguishable bucket and the agent's `Read(file)` would resolve the wrong one.
Dogfood: candidates **454→45**, distinct files **92→11**, repo vs user-global now distinct.
- **`feature-gap` scopes presence checks to authored config and reads the settings cascade
(`M-BUG-13`).** The GAP scanner's 25 presence checks ran over the full `includeGlobal` discovery, so
this plugin's own `examples/optimal-setup` (vendored across plugin-cache versions) satisfied every
tier-3 check — masking real feature gaps to **GAP=0 on any target**. And the real
`~/.claude/settings.json` was invisible to the settings-key checks (the `includeGlobal` gotcha plus the
`maxFiles` cap), which would have flipped `statusLine`/`autoMode` into false positives the moment the
maskers were removed. Both halves are fixed together: `isAuthoredConfig` excludes plugin-bundled and
nested `examples/`/`tests/fixtures/` config, and `readSettingsCascade` reads user→project→local
directly. Empty target: ~0 (masked) → **18** humanized opportunities.
- **`posture --output-file` humanizes findings in default mode (`M-BUG-12`).** `feature-gap.md` and
`posture.md` both read findings from `posture.mjs --output-file` and group on the humanizer fields,
but `posture.mjs` only humanized the stderr scorecard — its `--output-file` JSON wrote the raw
v5.0.0-shape result, so every finding's humanizer fields were `undefined` and both commands silently
degraded to the raw tier-fallback. v5.1.0 plain-language output was dead for `feature-gap` and for
`posture`'s finding-level grouping. The payload is now humanized in default mode (applied to
`result.scannerEnvelope`, which is where posture nests it); `--json`/`--raw` stay raw.
- **AGT findings humanize to "Wasted tokens", not "Other" (`M-BUG-17`).** The agent-listing scanner emits
an always-loaded per-turn token cost — "the dominant single always-loaded source" — but
`SCANNER_TO_CATEGORY` had no AGT entry, so its findings fell through to the `Other` fallback, a bucket
that isn't even in the analyzer-agent's category list. All 16 orchestrator scanner prefixes are now
covered by the category map, closing the class.
- **On-demand copy for the oversized skill-body finding (`M-BUG-16`).** The v5.11 B7 finding measures a
skill *body*, which loads only when the skill is invoked — but with no `SKL.static` entry it fell
through to `SKL._default` ("using more of the listing budget than it should"), so the humanized title
claimed a listing-budget cost and directly contradicted its own humanized evidence ("loads on demand
only … NOT every turn").
- **Honest absence-state copy for two GAP enhancement findings (`M-BUG-15`).** The "No path-scoped rules"
and "No subagent isolation" checks fire on an *empty* collection too, but the humanized titles
presupposed the feature exists — "Your subagents share Claude's main work folder" appeared in the same
report as "You haven't set up any specialized helper agents yet". A user cannot simultaneously have no
subagents and have subagents that lack isolation. Both titles now use the house "You haven't set up X
yet" framing, which is honest for the zero-state *and* the has-but-unconfigured state. Fixed in the
humanizer, not by gating the scanner — a presence gate would have moved the frozen v5.0.0
marketplace-medium baseline.
- **Size-neutral copy for "CLAUDE.md not modular" (`M-BUG-14`).** The check is a pure presence check with
no length gate, but the copy claimed the file is "one big block" and that splitting makes it "easier on
the loading time" — an unconditional size overclaim that simply lies for a ~625-token CLAUDE.md. Copy
softened to the honest structural framing; no length gate added, which would have made it the only
size-gated check among its siblings.
## [5.12.5] - 2026-06-26
### Summary
"Dogfood denoise" — a samle-release of the Fase-3 scanner false-positive batch: `M-BUG-2/6/7/8/10`,
all dogfooding finds from running config-audit on the maintainer's real `~/.claude`. The shared theme
is **non-user / non-live config wrongly counted as the user's authored cascade**: installed plugins'
bundled config, frozen backup copies, doc examples, and forward-compatible settings keys all produced
findings the user could neither act on nor was responsible for. No new scanner, command, agent, or hook
(counts stay scanners **16**, agents **7**, commands **21**, hooks **4**); all five fixes are byte-stable
— the frozen v5.0.0 + SC-5 + default-output snapshots are untouched and **no fixture was re-seeded**
(verified per bug: each affected fixture's findings are genuinely unchanged because the snapshot fixtures
contain none of the triggering paths/tokens). **1344** tests (+37).
### Fixed
- **`conflict-detector` segregates plugin-bundled config (`M-BUG-2`).** CNF compared every discovered
`settings.json`/`hooks.json` pairwise regardless of origin, so it treated installed plugins' bundled
configs — each plugin's own settings/hooks plus its shipped fixtures and examples under
`~/.claude/plugins/` — as the user's cascade. A "conflict" between two plugins' bundled test fixtures
is not user-resolvable, yet these dominated the count (dogfood **339** findings: 315 high-sev
allow/deny, 18 duplicate-hook, 6 settings-key — Conflicts grade F on ~100% plugin noise). Fix: a new
`isPluginBundled` predicate excludes any file whose absolute path is under `.claude/plugins/` from
conflict analysis. Kept **CNF-local, not a discovery-level skip** on purpose — an active plugin's
contributed `hooks.json`/`.mcp.json` legitimately lives in `plugins/cache` and other scanners need it;
only conflict analysis must ignore plugin-bundled files. Same class as `M-BUG-8`. Dogfood **339→0**
(the ~3 genuine user-scope local settings have no actually-conflicting keys). +3 tests (plugin-bundled
exclusion, discovery-side sanity, over-exclusion guard).
- **`file-discovery` skips `backups/` (`M-BUG-8`).** A directory named `backups` holds backup COPIES, not
live config, so walking it during an audit produces stale findings. config-audit's own session backups
(`~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md`) were the canonical case: a `~/.claude`-scope
audit walked 36 frozen copies as if live, polluting CPS and HKV/RUL. Fix: add `backups` to `SKIP_DIRS`
(broad, name-based — consistent with `vendor`/`dist`/`.cache`). Dogfood files-under-`/backups/`
**36→0**, 717 live config files retained. +3 tests.
- **token estimator discounts block-level HTML comments (`M-BUG-6`).** CLAUDE.md token estimates counted
block-level `<!-- -->` comments toward always-loaded tokens, but CC strips them before injection
(preserved only inside code fences, per `code.claude.com/docs/en/memory`). Fix: new
`stripInjectedHtmlComments` + `effectiveMemoryBytes` in `active-config-reader`; the CML cascade and
`token-hotspots` now size CLAUDE.md from effective (stripped) bytes while raw byte figures stay honest.
Block-level only — inline comments retained (conservative, verified scope). Dogfood `~/.claude` CLAUDE.md
~3386→3301 tok (~85 tok). +13 tests.
- **`cache-prefix-stability` ignores code + CC-stable path vars (`M-BUG-7`).** CPS flagged
`${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` (CC-provided stable paths) and `{date}`/timestamp tokens
shown in documentation as cache-busters. Fix: skip fenced code blocks, strip inline-code spans, and
whitelist CC-stable vars before pattern-matching. Suppress-only — frozen v5.0.0 snapshots untouched
(CPS yields `findings:[]` there). Dogfood **5→2** (3 doc false-positives suppressed; 2 remaining are
own volatile test fixtures). +6 tests.
- **`settings-validator` typo-gates unknown keys (`M-BUG-10`).** The CC settings schema is passthrough
(verified against the 2.1.193 binary): it forwards unrecognized keys unchanged rather than rejecting
them, so an arbitrary unknown key is forward-compatible, not an error — the finding's "silently ignored"
claim was factually wrong. The only real risk is a TYPO of a real key (the intended setting then
silently has no effect). Fix: flag an unknown key only when it closely matches a known key (new
`levenshtein` helper; edit distance ≤2, both keys ≥4 chars); severity medium→low; honest passthrough
framing in scanner + humanizer. Also refreshed `KNOWN_KEYS` with 6 binary-verified keys
(`agentPushNotifEnabled`, `remoteControlAtStartup`, `skipAutoPermissionPrompt`,
`skipDangerousModePermissionPrompt`, `skipWorkflowUsageWarning`, `tui`). Dogfood
`~/.claude/settings.json` **6→0** (all 6 were false unknown-key findings; 0 typo flags introduced across
167 walked files). +12 tests.
## [5.12.4] - 2026-06-26
### Summary
"Rooted rules" — fixes `M-BUG-9` (dogfooding find) in `scanners/rules-validator.mjs`. The RUL
"Rule path pattern matches no files" check resolved a rule's `paths:`/`globs:` glob against the
outer **scan root** instead of the rule's **own project root** (the directory containing its
`.claude/`), and `collectProjectFiles` carried a `depth>4` cutoff that never reached deep matching
files. As a result, a live rule in a **nested repo** — e.g. a marketplace checkout under
`~/.claude/plugins/marketplaces/<mkt>/.claude/rules/` — was wrongly flagged "never activates" (high
severity), a false F-grade for any user with rules in a nested repo. Same scope-conflation family as
`M-BUG-1/2` (the scanner treats a nested repo's config as scoped to the outer scan root). The fix is a
no-op for the common single-repo scan (`projectRoot === targetPath`), so the frozen v5.0.0 +
default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands
**21**). **1307** tests (+2).
### Fixed
- **`rules-validator` glob base (`M-BUG-9`).** The dead-rule check now resolves each rule against its
own project root:
- `deriveProjectRoot(ruleAbsPath)` returns the parent of the rule's `.claude` segment.
- Project files are collected and globbed **per project root** (cached), relative to that root, so a
nested repo's rule matches against its own tree where its files live. This also sidesteps the old
`depth>4` cutoff, because the walk now starts at the nearby project root.
- User-global rules (`projectRoot === $HOME`, i.e. `~/.claude/rules/`) skip the no-match check: they
scope against whatever project is active at runtime, not a fixed tree, so "matches 0 files here" is
not a dead-rule signal (and this avoids a `$HOME`-wide file walk).
- TDD: 2 failing tests (nested-repo false-positive + HOME guard) → fix → full suite 1307/0, frozen
v5.0.0 + default-output snapshots untouched (RUL findings appear in none). Real-machine verify: the
two `ktg-privat` false positives clear and a previously-hidden genuine dead rule (a false negative)
surfaces in the bundled `optimal-setup` example; zero new false positives.
## [5.12.3] - 2026-06-26
### Summary
"Phantom agents" — fixes `M-BUG-3/4/5` (dogfooding finds) in `scanners/lib/active-config-reader.mjs`
so that `enumerateAgents` counts only the agents Claude Code actually **registers**. Per the official
subagents documentation, an agent file must carry valid `name`+`description` frontmatter, and CC
scans the agents directory **recursively** while silently skipping frontmatter-less files. The reader
violated all three rules: it counted every `.md` regardless of frontmatter (`M-BUG-5`), never recursed
into agent subdirectories (`M-BUG-3`), and double-counted entries when the project directory equals the
user directory — the case when the scope root is `$HOME` (`M-BUG-4`, the root cause, which also affected
rules and output-styles). Real-machine verify: the user-agent count dropped **13→0** (all 12 user
agents plus `REMEMBER.md` are frontmatter-less, so CC registers none of them) and the HOME `project`
duplicate dropped **13→0**; the corrected always-loaded baseline is ≈ **53**, not 66. Agent enumeration
is machine-dependent and therefore absent from the frozen snapshots, so the v5.0.0 + SC-5 +
default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands
**21**). **1305** tests (+4).
### Fixed
- **`active-config-reader` agent enumeration (`M-BUG-3/4/5`).** Three surgical fixes:
- `listMarkdownFiles` gains an opt-in `recursive` flag so agent enumeration descends into
subdirectories the way Claude Code does (`M-BUG-3`).
- `configDirs` now de-duplicates the project and user paths when they resolve to the same directory
(the case when the scope root is `$HOME`), the root cause that also double-counted rules and
output-styles (`M-BUG-4`).
- `enumerateAgents` requires a valid `name`+`description` frontmatter block before counting a file,
matching CC's actual registration rule — frontmatter-less files are silently skipped (`M-BUG-5`).
- Added a `hasText` frontmatter helper. TDD: 4 failing tests (one per bug) → fix → full suite
1305/0, frozen v5.0.0 + SC-5 + default-output snapshots untouched (agent enumeration is
machine-dependent and never seeded into a snapshot).
## [5.12.2] - 2026-06-24
### Summary

260
CLAUDE.md
View file

@ -1,13 +1,10 @@
# Config-Audit Plugin
Claude Code Configuration Intelligence — know if your configuration is correct, find what could improve it, fix it automatically.
Claude Code Configuration Intelligence — know if your config is correct, find what could improve it, fix it automatically. Three pillars: **Health** (deterministic scanners), **Opportunities** (context-aware recommendations), **Action** (auto-fix with backup/rollback).
## What this plugin does
Per-command flags, patterns, and feature lists live in `README.md` and `/config-audit help`. This file carries what's invariant for working on the plugin.
Analyzes and optimizes Claude Code configuration across three pillars:
- **Health** — Deterministic scanners verify correctness, consistency, and completeness
- **Opportunities** — Context-aware recommendations for features that could benefit your project
- **Action** — Auto-fix with backup/rollback
**Positioning vs. built-in `/doctor` (measured 2026-08-03, binding):** we are the deterministic/reproducible/all-scope/zero-quota side; `/doctor` is usage-weighted one-shot judgment. Never build a feature whose whole value is duplicating a `/doctor` check — see README «config-audit vs. the built-in /doctor» and `docs/v5.13-model-routing-effort-deadref-plan.md` §A.
## Commands
@ -15,15 +12,15 @@ Analyzes and optimizes Claude Code configuration across three pillars:
| Command | Description |
|---------|-------------|
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
| `/config-audit posture` | Quick health scorecard (A-F grades, 10 quality areas incl. Token Efficiency, Plugin Hygiene) |
| `/config-audit tokens` | prompt-cache-aware token hotspots (8 patterns: cache-breaking, redundant perms, deep imports, oversized cascade, bloated SKILL.md desc, MCP tool-schema budget, MCP tool-schema deferral, stale plugin-cache disk-cleanup), each ranked hotspot tagged with its load pattern (always / on-demand / external) — **cache-aware** (stale `~/.claude/plugins/cache` versions excluded by default; only each plugin's active version counts; `--no-exclude-cache` for the full walk), optional `--accurate-tokens` API calibration, `--with-telemetry-recipe` cache-hit recipe pointer |
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens, each tagged with its load pattern (always-loaded / on-demand / external) + an always-loaded subtotal ("tokens that enter context every turn") |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact (incl. a conditional `disableBundledSkills` lever when the active skill listing is over budget — remediation companion to SKL `CA-SKL-002`) |
| `/config-audit optimize` | Optimization lens (mechanism-fit) — config that works but fits a better mechanism: procedure→skill (CA-OPT-001, deterministic), lifecycle→hook / unscoped path→rule / "never"→permission (prose-judgment via opus `optimization-lens-agent`). Hybrid motor; every finding cites a best-practices-register rule. Agent-driven, **not byte-stable** |
| `/config-audit` | Full audit with auto-scope detection |
| `/config-audit posture` | A-F health scorecard (10 quality areas) |
| `/config-audit tokens` | Prompt-cache-aware token hotspots, each tagged with its load pattern; cache-aware |
| `/config-audit manifest` | Ranked table of every token source + always-loaded subtotal |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable**. `--subtract` adds the subtraction axis (what no longer earns its always-loaded rent, `BP-SUB-001`) — opt-in, proposes only; `--subtract --apply` executes the removals the operator picks; `--subtract --for-model <name>` annotates the candidates a named model documents as redundant (`BP-PROMPT-001`) |
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
| `/config-audit rollback` | Restore configuration from backup |
| `/config-audit plan` | Create action plan from audit findings |
| `/config-audit plan` | Create action plan from findings |
| `/config-audit implement` | Execute plan with backups + auto-verify |
| `/config-audit help` | Show all commands |
@ -33,9 +30,9 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|---------|-------------|
| `/config-audit drift` | Compare current config against saved baseline |
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — deterministic stale check (sources older than ~90d) + web candidate poll (CC changelog + Anthropic blog); **human-approved writes only** (Verifiseringsplikt). The "living" half of the knowledge base. Web/judgment-driven, **not byte-stable** |
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger ABOVE sessions: per-repo lifecycle (pending→audited→planned→implemented) + machine-wide roll-up by severity + **machine-wide always-loaded token bill** (`refresh-tokens` live cross-repo sweep — shared global layer counted once + per-repo deltas) + cross-repo prioritized backlog + plan **export** (drop a planned repo's plan into its own `docs/`), resumable across sessions. Read-only report (campaign-cli) + **human-approved** writes via deterministic write/export CLIs. THIN: tracks+routes state, reuses existing implement/rollback for execution. Judgment-driven, **not byte-stable** |
| `/config-audit whats-active` | Read-only inventory of active plugins/skills/agents/MCP/hooks/CLAUDE.md (with token estimates, and `model`/`effort` per agent) |
| `/config-audit knowledge-refresh` | Refresh the best-practices register (stale check + web poll). Human-approved writes; **not byte-stable** |
| `/config-audit campaign` | Machine-wide audit ledger + token bill across repos. Human-approved writes; **not byte-stable** |
| `/config-audit discover` | Run discovery phase only |
| `/config-audit analyze` | Run analysis phase only |
| `/config-audit interview` | Gather user preferences (opt-in) |
@ -51,84 +48,227 @@ Analyzes and optimizes Claude Code configuration across three pillars:
| planner-agent | Create action plan | opus | yellow | Read, Glob, Write |
| implementer-agent | Execute changes | sonnet | magenta | Read, Write, Edit, Bash, Glob |
| verifier-agent | Verify results | sonnet | purple | Read, Glob, Grep |
| feature-gap-agent | Context-aware feature recommendations | opus | green | Read, Glob, Grep, Write |
| optimization-lens-agent | Mechanism-fit precision gate (prose-judgment lens cases) | opus | orange | Read, Glob, Grep, Write |
| feature-gap-agent | Feature recommendations | opus | green | Read, Glob, Grep, Write |
| optimization-lens-agent | Mechanism-fit precision gate | opus | orange | Read, Glob, Grep, Write |
## Hooks
| Event | Script | Purpose |
|-------|--------|---------|
| PreToolUse | `auto-backup-config.mjs` | Auto-backup config files before Edit/Write |
| PostToolUse | `post-edit-verify.mjs` | Verify config files after Edit/Write, block on new critical/high |
| SessionStart | `session-start.mjs` | Checks for active (unfinished) sessions |
| Stop | `stop-session-reminder.mjs` | Reminds about current session phase |
| PreToolUse | `auto-backup-config.mjs` | Backup config files before Edit/Write |
| PostToolUse | `post-edit-verify.mjs` | Verify after Edit/Write, block on new critical/high |
| SessionStart | `session-start.mjs` | Check for active (unfinished) sessions |
| Stop | `stop-session-reminder.mjs` | Remind about current session phase |
## Reference docs (read on demand)
- **Scanner inventory, lib modules, action engines, knowledge base, per-scanner/per-block implementation notes:** `docs/scanner-internals.md`
- **Plain-language output (v5.1.0), humanizer vocabularies, output modes:** `docs/humanizer.md`
- `docs/scanner-internals.md` — scanner inventory, lib modules, action engines, knowledge base, per-scanner/per-block implementation notes (design rationale, primary-source verification, byte-stability lessons)
- `docs/humanizer.md` — plain-language output (v5.1.0), humanizer vocabularies, output modes
## Plain-Language Output (v5.1.0) — summary
## Plain-Language Output (v5.1.0)
Default output of all 18 commands routes through `humanizeEnvelope` from `lib/humanizer.mjs`. Findings get three decorated fields:
- `userImpactCategory` — Configuration mistake / Conflict / Wasted tokens / Dead config / Missed opportunity
- `userActionLanguage` — Fix this now / Fix soon / Fix when convenient / Optional cleanup / FYI (derived from severity)
- `relevanceContext``affects-everyone` (default) / `affects-this-machine-only` (`*.local.*` files) / `test-fixture-no-impact`
`--raw` bypasses the humanizer for byte-stable v5.0.0 output. `--json` is also byte-stable. Full detail and Wave 5 lessons: `docs/humanizer.md`.
Default output of all commands routes through `humanizeEnvelope` (`scanners/lib/humanizer.mjs`), decorating each finding with `userImpactCategory`, `userActionLanguage`, and `relevanceContext`. `--raw` and `--json` bypass the humanizer for byte-stable v5.0.0 output. Full detail: `docs/humanizer.md`.
## Suppressions
Create `.config-audit-ignore` at project root to suppress known findings:
```
CA-SET-003 # Exact ID
CA-GAP-* # Glob pattern (all GAP findings)
```
Suppressed findings tracked in envelope's `suppressed_findings` for audit trail. Disable with `--no-suppress`.
Create `.config-audit-ignore` at project root — one exact ID or glob per line (`CA-SET-003`, `CA-GAP-*`). Suppressed findings are tracked in the envelope's `suppressed_findings` for audit trail. Disable with `--no-suppress`.
## Architecture
### Workflow
```
/config-audit → discover + analyze (auto) → plan → implement → verify
```
Default: auto-detects scope from git context. Override with `/config-audit full|repo|home|current`. Delta mode: `--delta` (incremental).
Workflow: `/config-audit → discover + analyze (auto) → plan → implement → verify`. Auto-detects scope from git context; override with `full|repo|home|current`; `--delta` for incremental. Session state lives under `~/.claude/config-audit/sessions/{id}/` (scope.yaml, discovery.json, state.yaml, findings/, analysis-report.md, action-plan.md, backups/, implementation-log.md).
### Session Directory
```
~/.claude/config-audit/sessions/{session-id}/
├── scope.yaml, discovery.json, state.yaml
├── findings/, analysis-report.md, action-plan.md
├── backups/, implementation-log.md
└── interview.md (if interview run)
```
Finding ID format: `CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`, `CA-TOK-005`, `CA-CPS-001`, `CA-SKL-001`, `CA-OST-001`, `CA-OPT-001`, `CA-AGT-001`.
### Finding ID Format
`CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`, `CA-TOK-005`, `CA-CPS-001`, `CA-DIS-001`, `CA-COL-001`, `CA-SKL-001`, `CA-OST-001`, `CA-OPT-001`, `CA-AGT-001`
**GAP dimensions vs. levers (invariant).** `GAP_CHECKS` holds the 24 *dimensions* — always evaluated, always counted in the utilization denominators (`TIER_COUNTS` / `TOTAL_DIMENSIONS` in `scoring.mjs`, and `TITLE_TO_ID` there). A *lever* is a finding the scanner emits after the loop and only under a measured condition; it carries no tier, never enters those denominators, and is registered in the exported `LEVERS` object (code + title in one place, because the finding-code guard needs the code and the humanizer-coverage guard needs the title). Adding a dimension moves every user's utilization score and can flip the reported `segment` in a frozen baseline — adding a lever cannot. When a check is only meaningful for configs that already have some feature, it is a lever.
**`{NNN}` names the CHECK, never the emission position (invariant).** `scanners/lib/finding-codes.mjs` is the single authority: every `finding()` call passes a `code`, and an undeclared or missing one **throws** — there is no counter fallback, because a fallback lets a half-converted scanner ship IDs that look valid. Adding a check takes the next free number for that scanner, never the next source-order position; removing one moves its key to `RETIRED_CODES` and its number is never reissued. IDs are therefore **not unique per finding** — one check failing in three files emits three findings sharing an ID, and `(id, file, line)` is the instance key that `fix-engine` verification uses. Frozen `v5.0.0` baselines mask IDs (`tests/helpers/mask-finding-ids.mjs`) instead of re-deriving them; the check→number pairs are pinned exhaustively in `tests/lib/finding-codes.test.mjs`.
**A file's contract cannot exceed its tools (invariant).** `agents/verifier-agent.md` said both
"Append to: implementation-log.md" (§Output Format) and "never modifies any files" (§Read-Only
Guarantee) while granting only `Read, Glob, Grep`. The failure mode is not a blocked write — it is
the agent improvising a full-file `Write` on the log it *shares* with the parallel implementer
agents, which is the exact defect `implement-log-append.test.mjs` exists to prevent, entering
through the one file that test does not read. `tests/agents/agent-write-contract.test.mjs` asserts
the blanket invariant over the whole catalogue rather than a fact about one file: an agent whose
`tools:` grant no write capability (`Write`/`Edit`/`NotebookEdit`/`Bash`) must instruct no file
write **and** state positively that it returns its findings inline. Both sides are read from the
file, so stripping `Write` from any agent whose body still instructs a write turns it red, and the
sweep asserts a write-tool-less agent exists so the invariant cannot pass vacuously. Measured
2026-08-20: **1 of 7** agents carried the defect; the other six all hold `Write`. The orchestrator
half was already correct — `implement.md` Step 5 appends with Bash `>>` and tells the agent not to
write — so this was a one-way fix in the agent file, not a two-sided one.
**Frontmatter contracts are derived, never listed (invariant).** `.claude/rules/agent-development.md`
and `.claude/rules/command-development.md` state which keys an agent/command MUST carry and that
agent colors are unique; nothing enforced any of it, and the only test that read agent frontmatter
checked `name:` against a hand-written 3-of-7 list. `tests/agents/frontmatter-contract.test.mjs`
takes nothing by hand: required keys are parsed from each rule's own fenced `yaml` block, the files swept
are resolved from each rule's own `paths:`, and the plugin name is read from
`.claude-plugin/plugin.json` — add a key to a rule and it is enforced on the next run with no test
edit, because a hand-kept list of what to sweep is a premise, not a measurement. The repo was
already compliant (7/7 agents, 21/21 commands, 0 duplicate colors), so a green first run proves
nothing: every arm was seen red against a temporarily introduced defect **one at a time**,
including emptying a rule's yaml block to show the derivation is not vacuous. The color **enum** is
deliberately *not* guarded — the official subagent docs list `red/blue/green/yellow/purple/orange/
pink/cyan` (no `magenta`) while issue #19292 lists `magenta` but neither `purple` nor `orange`, and
this plugin ships both `magenta` and `orange`; pinning an unsettled set in a guard would encode an
unverified premise rather than measure one.
## Conventions
Enforced project conventions live in `.claude/rules/` (auto-loaded as project instructions):
- `ux-rules.md` — output/narration/formatting rules for all commands (never dump raw JSON, narrate before each step, space-separated command suggestions)
Enforced conventions live in `.claude/rules/` (auto-loaded as project instructions):
- `ux-rules.md` — output/narration/formatting for all commands (never dump raw JSON, narrate before each step, space-separated command suggestions)
- `command-development.md` — required command frontmatter + `plugin:action` naming
- `agent-development.md` — agent frontmatter + "when to use" conventions
- `state-management.md` — update `state.yaml` after every workflow phase
Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{SCANNER}-{NNN}` ID format; byte-stable CLIs are verified against frozen `tests/snapshots/v5.0.0/` baselines.
**Write-scope gate (invariant).** Every write target is classified by `scanners/lib/write-scope.mjs` before it reaches an approval surface, and the **scope class decides the gate's strength — never the command asking**. Five command-owned policies would drift apart the way five copies of the lever table did. `SCOPE_CLASSES` is the single source for class, gate (`silent`/`disclose`/`require-ok`), wording and predicate; templates render `disclosures[]` from `write-scope-cli.mjs` rather than restating what a class means. Two orderings in that object are load-bearing and were measured, not reasoned about: `plugin-managed` before `user-scope` (both `~/.claude/config-audit/` and legacy `~/.config-audit/` are live, so the other order fires the gate on every session write and gets it switched off), and `user-scope` before `cross-repo` (`~/.claude/.git` exists, so a plain `.git`-upward walk calls `~/.claude/CLAUDE.md` merely "another repo" and silently downgrades the strongest gate). `disclose``require-ok`: `campaign export` is cross-repo *by design*, so tightening it into a refusal breaks the feature. Distinct from the `require-target-dir.mjs` guard, which asks whether a scan **root** is readable (exit 3) — a different invariant, not to be merged.
**Write-gate coverage (invariant).** The gate above only counts where it is *called*, and for four
releases it was called from prose: `write-scope.mjs` existed, but exactly one writer imported it
(`lib/subtraction-write.mjs`) while five command templates paraphrased the policy. Measured
2026-08-12: 9 files under `scanners/` write to disk, 1 imported the gate. The defect was never
"8 ungated writers = 8 bugs" — four of them write the plugin's own bookkeeping and MUST stay
ungated, because a gate that fires on every run gets switched off. The defect is that **nothing
declared which**, so the question was answered by reading, and answered differently each time.
`tests/lib/write-gate-coverage.test.mjs` is now the authority: every writer must either import
the gate or hold an `EXEMPT` entry naming **where the bytes land**. Three properties are
load-bearing. (1) **A rationale is a claim, not a label**`scan-orchestrator` was carried in
the plan text as exempt while `--save-baseline` derived its path from the *scan target*, so
`--global` landed `~/.claude/.config-audit-baseline.json` (`user-scope`/`require-ok`); it is
gated, and `lib/baseline.mjs` — which writes only under `~/.config-audit/baselines` — is the
genuinely exempt one. (2) **Sync variants count**: `writeFile(` does not match `writeFileSync(`,
and `lib/backup.mjs` uses only the sync forms, so the first sweep scored a real writer as clean
and was green on its own subject. (3) **The sweep asserts non-emptiness** — a regex that stops
matching makes every other assertion here vacuously green. The exemption table is stale-checked
in both directions: an entry naming a file that no longer writes, or one that has since been
gated, fails. `evaluateWriteTargets` in `write-scope.mjs` is the one reduction (classify →
`strongestGate` → dedup disclosures) that all five call sites share; four copies of those four
lines is the drift shape `SCOPE_CLASSES` exists to prevent one level down. Approval is carried by
`--approve-scope`, and **classifying is not approving**: a template that sets the flag because it
already ran `write-scope-cli` has rebuilt the prose contract this guard replaced.
**Command→CLI flag contract (invariant).** A command template is a caller with no compiler
behind it: it names a scanner and an argv, and nothing used to check that the scanner still
accepts them. The measured cost is M-BUG-45 — `--stale-after` reached its CLI malformed, was
ignored, and the command reported "✓ all 14 entries re-verified within the last 90 days" about
a threshold the user had just overridden. `tests/commands/command-cli-contract.test.mjs` closes
that seam, and four properties are load-bearing. (1) **The argv is built from the template's own
text** (`tests/helpers/command-invocations.mjs`), never hand-typed — a hand-written call is a
path no user takes (#63). Flags appear in *three* forms and all three are read: literal,
`if …; then RAW_FLAG="--raw"; fi`, and **comment-only** (`GLOBAL_FLAG="" # --global`); the third
is the class that dies unobserved, because the default path leaves the variable empty. (2) **The
probe proves itself per CLI** — each must first be seen rejecting a flag that certainly does not
exist, or a CLI that exits on a required-arg check before reaching flag parsing passes every pair
vacuously. Measured 15/15 report the unknown flag first, so no prefix-argv table is needed, and
the second copy of `cli-unknown-flag-rejection`'s `GUARDED` table was therefore never created.
(3) **"Unknown" is told from "needs a value" by the CLI's own words**, which is only sound because
every CLI classifies the two correctly — measured 14/15, and the fifteenth
(`campaign-export-cli`, the last hand-rolled parser, whose `argv[i+1] !== undefined` guards let a
trailing `--repo` fall through to the catch-all and be reported as an unknown flag) was moved onto
the shared `requireValidArgs` gate rather than special-cased in the test. (4) **Probing a flag
runs the CLI, and some flags are writers** — the first run of this guard let `drift-cli --save`
default its target to the cwd and overwrite the operator's real
`~/.config-audit/baselines/default.json`. Every probe now runs under `hermeticEnv()` with its own
empty cwd, and the cwd is *asserted* empty afterwards: isolation that is only a convention is not
isolation. Not asserted here: that a template calling a gated writer also calls `write-scope-cli`
— measured false-red (`discover`/`config-audit` invoke `scan-orchestrator` without reaching its
`--save-baseline` write), so that arm stays in `write-scope-gate-shape.test.mjs`.
**Dead-prose-reference silence list (invariant).** `CA-CML-013` is a precision-first check, so its
design lives in what it *declines* to flag, and that list is measured (407 real CLAUDE.md files),
never argued. Three rules are load-bearing and each has a guard seen red against its own defect.
(1) **Containment is checked against the scan root, not the file's own directory** — a `../` chain
that leaves the tree is silenced (`outside-scan-tree`) rather than resolved, because a base a `..`
chain can escape is not a base: measured, `../../../../etc/passwd` resolved to the real file and
silenced its own finding. A legitimate `../docs/x.md` inside the same repo still resolves.
(2) **A bare token is a concept, not a reference**`README.md` (no separator) and `docs/`
(single segment) are excluded on the same reasoning one level apart; admitting bare filenames
tripled the output with name-drops of tools living elsewhere, and single-segment folders are 26 %
of the remainder, led by a remote namespace prefix. (3) **Rule ORDER is the reported reason**
first match wins, so `npm test` is silenced as `whitespace` (a command), not as `no-separator`,
and two silences with different causes keep their own fixtures. The check emits **one finding per
file** (the `todo-markers` / `repeated-content` idiom), because per-token emission measured 699
findings where per-file measured 128. Silence is the safe failure direction here: a path carrying
a trailing `:54-56` locator is a recorded v1 miss, not a bug to fix by loosening a rule.
**Subtraction floor (invariant).** `optimize --subtract` is the only lens that proposes removing config, so `scanners/lib/floor-exclusion.mjs` runs as a deterministic pre-step *before* the judge — a load-bearing block is never a candidate, and that guarantee must not be moved into the agent prompt. Two rules follow from it: (1) **staleness is not a deletion signal** — an outdated version pin inside a floor block is a `drift`/`CA-CML` dead-reference concern; (2) **tier 2 ≠ tier 3** — a compensatory block that keeps earning its place returns, and reporting it as dead weight is wrong even when the label matches. Norwegian keywords need the Unicode boundaries in `subtraction-prefilter.mjs`; JS `\b` is ASCII-only, so `/\bunngå\b/` silently never matches.
**Model-scoped annotation (invariant).** `--for-model <name>` (`BP-PROMPT-001`,
`scanners/lib/prompting-model-scope.mjs`) is an **annotation on existing
`BP-SUB-001` candidates, never a detector** — it tags a subset of what
`--subtract` already found and can never widen the candidate set. Four
properties are load-bearing. (1) **The model must be named.** There is no
auto-detection and there cannot be: a CLAUDE.md has no frontmatter and no
resolvable target model, and the operator's own `route` skill deliberately runs
a different model per session — so the same file is read by whichever model
comes next. (2) **Precision is the TARGET, not the verb list.** The verb set is
as broad as `subtraction-prefilter`'s `IMPERATIVE_RE`; what narrows it is
requiring a co-occurring reflexive ("your own work", "før du svarer") or
delegated ("subagent") target. Measured across the 409-file corpus: 392
`BP-SUB-001` candidates, **31 carry a verify verb, 0 also carry a target**, and
two independent raw-text greps also found 0 — so the zero is the corpus, not an
over-narrow regex. Those 31 (`sjekk relevante config-filer`, `Type-sjekk:
pyright`) are exactly the false positives a verb-only version would have
produced, which is `BP-JUDG-001`'s 7/7 failure arriving one lens over. The
numbers live in the register entry's `note` and are pinned by
`best-practices-register.test.mjs`, because a later session that cannot see the
measurement reads the zero as a broken detector and loosens it. (3) **`recognized`
is not `matchedCount`.** A typo'd model name and a genuinely clean config both
yield zero matches; without the separate flag the CLI would report a silent
no-op as good news. Dogfooded on the real machine: `opus-5` → `recognized:true,
matchedCount:0`; `oppus5` → `recognized:false`. (4) **The citation is
conditional wherever a human sees it** — agent report copy and the Step 7a
approval listing both say so. `--for-model` and `--apply` are separate CLIs that
never see each other's flags, so a CLI-level refusal is impossible; the
safeguard has to live in the copy. The payload stays additive: `forModel` and
per-candidate `modelScope` appear **only** when the flag is passed, so a plain
`--subtract` run is byte-identical to the pre-flag payload (asserted on the
serialized bytes, since a key set to `undefined` passes a shallow key check).
**Backup/restore is one code path (invariant).** `scanners/rollback-cli.mjs` is the only entry to
the backup engine, and BOTH pipelines use it: `fix` backs up through `createBackup` directly,
`implement` Step 3 through `--create`. Before R1/R2 the two halves each hid the other's failure.
The engine verified every checksum before and after each write, but had no `process.argv` (16 CLIs
under `scanners/` had one, it did not), so `commands/rollback.md` restored as model prose — an ESM
`import` block a template cannot execute, `cp` offered underneath as the runnable path, and
"(checksum verified)" pre-rendered in the success output. `cp` establishes no checksum, so the
verification was a property of the template. Meanwhile `implement` hand-built its manifest in the
template while `parseManifest` knew one frozen sample of that format, pinned by a HAND-WRITTEN
fixture rather than by the template's own text — the #63 shape on the data side, where a renamed
key yields zero parsed files and a rollback that reports success having restored nothing. Four
properties are load-bearing. (1) **Neither half fixes alone**: a CLI over a prose format still
parses prose; a clean format with no runnable entry still cannot restore. (2) **A gated restore is
exit 1, not 3** — "this write leaves your project" is a verdict about a write that WAS examined and
rides in the payload, where a command running under `2>/dev/null` can act on it (F3's class); 3
stays reserved for argv errors and a backup id that resolves in neither root. (3) **`--created`
records, it does not copy** — no backup can hold a file that does not exist yet, so those paths go
into the manifest for `rollback` to report as left in place; `serializeManifest` emits the bare
`created:` key, which is why the pre-R2 `created: <timestamp>` (a VALUE, meaning the backup id)
never collides with it. (4) **`parseManifest`'s implement-format branch stays** even though nothing
writes that shape now — backups already on disk in it must remain restorable, the same reason
`getLegacyBackupDir()` is still read; its fixture changed meaning from "a stand-in for the
template's text" to "a golden sample of historical bytes". The output contract is guarded by
running the CLI: `tests/commands/backup-restore-contract.test.mjs` checks every field
`rollback.md` renders against a real payload, so a renamed key fails there instead of becoming a
confident sentence in front of a user who is already in trouble. Distinct from the scope gate,
which classifies *where* a restore lands — `rollback.md` still calls `write-scope-cli` before its
approval surface, and classifying is still not approving.
**Subtraction write path (invariant).** `--apply` routes through `scanners/lib/subtraction-write.mjs`, never through `fix-engine` or the `plan`/`implement` pipeline, and both exclusions are **measured**: the subtraction axis is absent from the orchestrated envelope, so `verifyFixes`' re-scan would mark every removal `verified` whether or not it happened (a success-shaped no-op), and the findings pipeline needs a finding code — which names a deterministic check, not a prose judgement. Three properties are load-bearing and each has a guard seen red against its own defect: removals are validated against the ORIGINAL content and applied in **descending** line order (an ascending pass shifts later spans out from under themselves); 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 coverage of every file about to be written is **asserted from the manifest** before a byte changes. The floor is *repeated* here, 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. The archive rule (`mv` to `_archive/`) is file-level and does not apply to a block excision — the timestamped backup is the recovery artifact, and inventing a second copy with no restorer behind it would be worse than none.
## Testing
```bash
node --test 'tests/**/*.test.mjs'
```
1279 tests across 72 test files (23 lib + 39 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
Per-scanner and per-build-block implementation notes (design rationale, primary-source verification, byte-stability lessons) live in `docs/scanner-internals.md`**Implementation notes**.
Test fixtures in `tests/fixtures/`. Per-scanner and per-build-block implementation notes (design rationale, primary-source verification, byte-stability lessons) live in `docs/scanner-internals.md`**Implementation notes**.
## Gotchas
- Session directories accumulate — use `/config-audit cleanup` to manage
- Scanners run on Node.js >= 18 (uses node:test, node:fs/promises)
- Scanners run on Node.js 18 (uses node:test, node:fs/promises)
- Plugin CLAUDE.md files in node_modules should be excluded via scope

View file

@ -1,131 +0,0 @@
# Governance
How this marketplace is maintained, what you can expect from upstream, and how it's meant to be used.
## TL;DR
- Solo-maintained, AI-assisted development, MIT licensed.
- **Fork-and-own is the default model.** Upstream is a starting point, not a vendor.
- Issues welcome as signals. Pull requests are not accepted — see [Why no PRs](#pull-requests--no).
- No SLA. Best-effort bug fixes and security advisories. Breaking changes happen and are noted in each plugin's CHANGELOG.
---
## Can I trust this?
Be honest with yourself about what you're adopting:
- **One maintainer.** If I get hit by a bus, the bus wins. The repos stay up under MIT, but no one owes you a fix.
- **AI-generated code with human review.** Every plugin is built through dialog-driven development with Claude Code. I read, test, and judge the output before it ships, but I'm not auditing every line the way a security firm would. Treat it accordingly.
- **No commercial interests.** I'm not selling a SaaS, not steering you toward a paid tier, not collecting telemetry. The plugins run locally in your Claude Code installation.
- **MIT licensed.** Fork it, modify it, ship it under your own name.
If you work somewhere that needs vendor accountability, support contracts, or signed assurances — **this isn't that.** Use it as a reference implementation, fork it into your own organization, and own the result.
---
## How this is meant to be used
### Fork-and-own
The intended workflow:
1. **Fork** the marketplace (or a single plugin) into your own organization or namespace.
2. **Tailor** it to your context — terminology, integrations, cycle lengths, regulatory framing, whatever doesn't fit out of the box.
3. **Maintain it yourself.** Treat your fork as the canonical version for your team.
4. **Watch upstream selectively.** Cherry-pick changes that help, ignore changes that don't. There's no obligation to stay in sync.
This isn't a workaround for not accepting PRs. It's the actual recommended adoption pattern, especially for plugins like `okr` and `ms-ai-architect` where every Norwegian public sector organization will need its own tildelingsbrev mappings, terminology, and integrations. A central "one true plugin" would be wrong for everyone.
### What to change first when you fork
Each plugin differs, but the common edits are:
- **Identity** — rename the plugin, replace authorship, update README.
- **External integrations** — issue trackers, knowledge bases, dashboards, observability backends. The plugins ship as starting points, not pre-wired. Every organization must configure its own integrations.
- **Norwegian-specific framing** — relevant for `okr` and `ms-ai-architect`. Other plugins are jurisdiction-neutral. Rewrite for your jurisdiction if you're outside Norway.
- **Reference docs** — the knowledge base in each plugin reflects my reading. Replace with your organization's authoritative sources.
- **Hooks and policies** — security thresholds, blocked commands, and audit gates are tuned to my taste. Tune them to yours.
### Staying current with upstream
If you want to pull in upstream changes later:
- **Cherry-pick, don't merge.** Each plugin moves independently and breaking changes land without ceremony.
- **Read the CHANGELOG first.** Every plugin has one.
- **Keep your customizations in clearly-named files.** The harder upstream is to merge cleanly, the more painful staying current becomes. A `local/` directory or `*.local.md` convention helps.
---
## What upstream provides
| | What I do | What I don't |
|---|---|---|
| **Bug fixes** | Best-effort when I notice or get a clear report | No SLA, no triage commitment |
| **Security issues** | Investigate within reasonable time, document in CHANGELOG | No CVE process, no embargo coordination |
| **New features** | When they fit my own usage | Not on request |
| **Norwegian public sector context** | Kept current as long as the project lives | If I lose interest or change jobs, the framing freezes |
| **Breaking changes** | Documented in CHANGELOG | They happen — version pin if you need stability |
| **Compatibility** | Tracked against current Claude Code releases | No long-term support branches |
If any of this is a dealbreaker — fork now, version-pin, and stop reading upstream.
---
## How to contribute
### Issues — yes, please
Issues are the most valuable thing you can send me:
- **Bug reports** with reproduction steps. Even a screenshot helps.
- **Use-case feedback.** "I tried to use this in my organization and X didn't fit" is genuinely useful, even if I can't fix it for you.
- **Pointers to better sources.** If you know a DFØ veileder, an NSM guideline, or an academic paper that contradicts what's in a knowledge base, tell me.
- **Security findings.** See each plugin's `SECURITY.md` for disclosure preference where one exists; otherwise email rather than open a public issue.
### Pull requests — no
This is deliberate, not laziness:
- **Solo review is a bottleneck.** Honest PR review takes me longer than rewriting from scratch. The math doesn't work.
- **Forks are where the value is.** The fork-and-own model means upstream consolidation isn't the point. Your organization's adaptations belong in your fork, not mine.
- **AI-generated code complicates provenance.** Every line here is produced through dialog with Claude Code, with me as the judge. Mixing in PRs from contributors with different processes and licensing assumptions creates a mess I'd rather not untangle.
If you've built something useful on top of a fork, **publish it under your own name and link back.** I'll happily list notable forks here once they exist.
### Notable forks
*(To be populated as forks emerge. If you've forked one of these plugins for production use, open an issue and I'll add a link.)*
---
## Relationship between plugins
These plugins are **independent**. Install one without the others, fork one without the others. They share conventions (slash command naming, hook patterns, AI-generated disclosure) but no runtime dependencies.
The marketplace is a **catalog**, not a suite. Don't fork the whole repo unless you actually want to maintain everything.
---
## Versioning and stability
- **Semantic versioning per plugin.** Each plugin has its own `CHANGELOG.md` and version number.
- **Breaking changes happen.** I bump the major version when they do, but I don't run an LTS branch.
- **Pin your version.** If stability matters more than features, install a specific version and stay there until you choose to upgrade.
---
## Public sector adoption notes
For Norwegian etater specifically:
- **DPIA-relevant data flows are documented in the relevant plugin README where applicable.** Read them before installation.
- **No data leaves your machine** beyond what Claude Code itself sends to Anthropic. The plugins themselves do not call external services unless you configure an integration.
- **Drøftingsplikt and ledelsesansvar** are not replaced by these tools. The `okr` plugin coaches; it does not decide. The `ms-ai-architect` plugin advises; it does not approve.
- **Choose your Claude deployment carefully.** claude.ai vs. API direct vs. Bedrock in EU region have different data residency profiles. The plugins don't choose for you.
---
## License
MIT for all plugins in this marketplace. See each plugin's `LICENSE` file.

295
README.md
View file

@ -1,27 +1,46 @@
# Config-Audit Plugin for Claude Code
# config-audit
> Know if your configuration is correct. Find what could improve it. Fix it automatically.
Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](GOVERNANCE.md) for the full model and what upstream provides.
Know if your configuration is correct. Find what could improve it. Fix it automatically.
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md) for the full model and what upstream provides.
![Version](https://img.shields.io/badge/version-5.12.2-blue)
*AI-generated: all code produced by Claude Code through dialog-driven development. Every change is human-directed, reviewed, and validated before commit. Per Anthropic Consumer Terms §4, ownership of outputs is assigned to the user; this plugin is licensed MIT.*
![Version](https://img.shields.io/badge/version-6.0.0-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Scanners](https://img.shields.io/badge/scanners-16-cyan)
![Commands](https://img.shields.io/badge/commands-21-green)
![Agents](https://img.shields.io/badge/agents-7-orange)
![Hooks](https://img.shields.io/badge/hooks-4-red)
![Tests](https://img.shields.io/badge/tests-1301-brightgreen)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 16 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, output-style, and always-loaded agent-listing-budget detection. Zero external dependencies.
## Install
```bash
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
claude plugin install config-audit@ktg-plugin-marketplace
```
Or enable directly in `~/.claude/settings.json`:
```json
{
"enabledPlugins": {
"config-audit@ktg-plugin-marketplace": true
}
}
```
## Requirements
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed
- Node.js 18+ — the scanners also run standalone from a clone, with no other dependencies
---
## Table of Contents
- [What's New in v5.4.0](#whats-new-in-v540)
- [What Is This?](#what-is-this)
- [The Configuration Problem](#the-configuration-problem)
- [Quick Start](#quick-start)
@ -39,37 +58,13 @@ A Claude Code plugin that checks configuration health, suggests context-aware im
- [Testing](#testing)
- [Gotchas](#gotchas)
- [Data Storage & Safety Guarantees](#data-storage--safety-guarantees)
- [What This Plugin Does Not Cover](#what-this-plugin-does-not-cover)
- [Version History](#version-history)
- [Non-goals](#non-goals)
- [config-audit vs. the built-in /doctor](#config-audit-vs-the-built-in-doctor)
- [Changelog](#changelog)
- [License](#license)
---
## What's New in v5.4.0
**Plugin-hygiene & settings-validation hardening.** Three additive findings extend the plugin and
settings surfaces — no new scanner, so the count stays **13**:
- **PLH plugin-folder shadowing** (`CA-PLH-015`) — flags a `plugin.json` component-path key in the
*replaces* set (`commands`/`agents`/`outputStyles`) that points at a custom path while the
default folder of that name still exists, so the folder is silently ignored (dead config).
Mirrors Claude Code's own warning in `/doctor`, `claude plugin list`, and the `/plugin` detail
view. `skills` is excluded (it *adds to* the default scan, never shadows), as are
`hooks`/`mcpServers`/`lspServers` (own merge rules); a custom path resolving *into* the default
folder is not flagged.
- **PLH `skills:`-array validation** (`CA-PLH-016`) — validates each `plugin.json` `skills` entry
(string or array) resolves to an existing directory inside the plugin root; flags `non-string`,
`escapes-root`, `not-found`, and `not-a-directory` entries. Mirrors `claude plugin validate`.
- **SET `autoMode` structure + dead-config** — checks that `autoMode` is an object whose only keys
are `environment`/`allow`/`soft_deny`/`hard_deny`, each a string array (the literal `"$defaults"`
is valid); unknown sub-keys and wrong types are flagged (medium). Separately, `autoMode` placed
in **shared** project settings (`.claude/settings.json`) is flagged as dead config (low) —
Claude Code's classifier does not read it there.
All three extend existing PLH and SET scanners. `--json` and `--raw` output remain byte-stable.
---
## What Is This?
Claude Code reads instructions from at least 7 different file types across multiple scopes: `CLAUDE.md`, `settings.json`, `.claude/rules/`, `hooks.json`, `.mcp.json`, `.claudeignore`, and `settings.local.json`. Each can exist at project level, user level, or both. Plugins add more. The system is powerful — but nobody tells you what you're using wrong, what you're missing, or what's silently conflicting.
@ -154,28 +149,7 @@ Also **Grade A** — with only 3 opportunities remaining. This project has CLAUD
## Quick Start
### Prerequisites
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed
- Node.js 18+ (for standalone CLI tools)
### Installation
Add the marketplace and browse plugins with `/plugin`:
```bash
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
```
Or enable directly in `~/.claude/settings.json`:
```json
{
"enabledPlugins": {
"config-audit@ktg-plugin-marketplace": true
}
}
```
Install first — see [Install](#install) above.
### First Scan
@ -198,7 +172,7 @@ The CLI tools work standalone — no Claude Code session needed, just Node.js 18
Most configuration tools stop at "is it valid?" Config-audit goes further: **what could improve your setup, and is it relevant to your project?**
The feature opportunity scanner checks 25 dimensions and groups recommendations by impact:
The feature opportunity scanner checks 24 dimensions and groups recommendations by impact:
| Impact Level | Focus | Examples |
|--------------|-------|---------|
@ -206,9 +180,20 @@ The feature opportunity scanner checks 25 dimensions and groups recommendations
| **Worth Considering** | Workflow efficiency | Path-scoped rules, modular `@imports`, custom agents |
| **Explore** | Nice-to-have | Keybindings, status line, output styles, agent teams |
Alongside the dimensions sit four **conditional levers** — recommendations that only make
sense under a measured condition, so they stay silent otherwise. One of them is model/effort
routing (`CA-GAP-028`): when you have your own subagents and *not one* of them names a
`model:` or an `effort:`, every delegated task runs on the main conversation's model, because
`model` defaults to `inherit`. It fires only when you actually have agents, and goes quiet the
moment any of them routes either axis — so a deliberate everything-on-one-model setup is not
nagged. Writing `model: inherit` out in full does not count as routing; it is the default
spelled out.
Each recommendation is **context-aware** — it considers what your project actually contains. A solo TypeScript project gets different suggestions than a team Python monorepo. Recommendations include *why* (backed by Anthropic's official guidance) and *how* (concrete steps).
Run `/config-audit feature-gap` to see what's relevant to your project.
Run `/config-audit feature-gap` to see what's relevant to your project. To see what each agent
currently runs on, `/config-audit whats-active` lists `model` and `effort` per agent, and
`/config-audit manifest` shows them on the agent rows.
---
@ -279,6 +264,9 @@ Your team configuration changes over time. Track it:
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens — each tagged with its **load pattern** (always-loaded / on-demand / external) plus an **always-loaded subtotal** ("≈X tokens enter context every turn before you type"). Component-level: no coarse plugin roll-up (it would double-count) |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
| `/config-audit optimize` | Optimization lens (mechanism-fit): config that works but fits a better mechanism — procedure→skill, lifecycle→hook, unscoped path→rule, "never"→permission. Hybrid motor (deterministic pre-filter + opus precision gate), every finding cites a best-practices-register rule |
| `/config-audit optimize --subtract` | **Subtraction lens** — the inverse question no other command asks: what no longer earns its always-loaded rent? Ranks CLAUDE.md blocks that correct general model *behaviour* rather than stating a local fact, split into **dead** (never missed) and **earned** (returns if the model stumbles), with the token payoff (`BP-SUB-001`). **Load-bearing local facts are excluded deterministically before the judge sees anything** — remotes, versions, paths, filenames, policy invariants and unresolvable entity names are never candidates, and an ordered list is treated as a contract. Opt-in and proposes only; add `--apply` to execute the removals you pick. Pair with `--global` to reach the user-level CLAUDE.md, where the always-loaded cost actually sits |
| `/config-audit optimize --subtract --apply` | **Execute approved removals.** You pick which blocks go by number; nothing is inferred. Every removal is checked against the file as it reads *now* — an approval that no longer matches is refused rather than applied to whatever moved into those lines — and the floor is re-asserted at write time, so a load-bearing block cannot be removed even by a hand-built approval. A dry run always precedes the write, the backup's manifest is verified to cover the file being written before a byte changes, and `/config-audit rollback` restores it. A removal targeting your machine-wide `~/.claude/CLAUDE.md` is **refused until you approve that scope explicitly** — it costs, and saves, in every project on every turn |
| `/config-audit optimize --subtract --for-model <name>` | **Model-scoped subtraction.** Some instructions are dead weight only for a *particular* model: Anthropic documents that Claude Opus 5 verifies its own work and **over-verifies** when told to double-check or to delegate verification to a subagent, adding token cost with no quality gain (`BP-PROMPT-001`). This flag annotates the `--subtract` candidates that carry a reflexive or delegated verification target — "double-check your own work", "use a subagent to verify" — while leaving *external* verification ("check the CI status") untagged. It **never widens the candidate set**, and there is deliberately no auto-detection: a CLAUDE.md has no frontmatter and no resolvable target model, and the same file is read by whichever model the next session runs — so the citation is reported as **conditional**, and an unrecognized model name is reported as unrecognized rather than as a silent zero |
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
| `/config-audit rollback` | Restore configuration from a previous backup |
| `/config-audit plan` | Generate prioritized action plan from audit findings |
@ -304,6 +292,28 @@ Your team configuration changes over time. Track it:
By default, `/config-audit` auto-detects scope from your git context. Override with: `/config-audit current`, `/config-audit repo`, `/config-audit home`, `/config-audit full`. Use `--delta` for incremental scanning (only new/changed findings).
### Where a write is allowed to land
Reading is machine-wide; **writing is not**. Every write target is classified against the project
you are standing in, and the classification — not the command doing the asking — decides how
strong the gate is. A change inside your project applies normally. A write into a *different*
project is disclosed and then applied, because some commands (like `campaign export`) are
cross-repo by design. A write to your machine-wide `~/.claude` configuration, or to a path in no
project at all, is **withheld until you approve that scope explicitly** — it costs, and saves, in
every project on every turn.
That gate now runs inside the engines, not only in the command prose that wraps them. It covers
`/config-audit fix`, `/config-audit rollback`, `campaign export`, `--save-baseline`, and
`optimize --subtract --apply`. For rollback that is now literal: the restore runs through
`scanners/rollback-cli.mjs`, which verifies each file's checksum before and after writing it, so
"restored and verified" is something the run reports rather than something the output template
says. A restore that would land outside your project is refused with nothing written. When a run is withheld, nothing has been written: you get the
reason and the affected paths, and you re-run with your approval (`--approve-scope` on the CLIs).
Approval is always a separate act — classifying a target is not approving it. The plugin's own
bookkeeping (backups, session state, ledgers, and the report file you named with `--output-file`)
is deliberately exempt: a gate that fired on every run would be switched off, and then it would
guard nothing.
---
## Deterministic Scanners
@ -314,14 +324,14 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
| Scanner | Prefix | What It Catches |
|---------|--------|-----------------|
| `claude-md-linter.mjs` | CML | Oversized files (line count **plus** a context-window-scaled char budget mirroring Claude Code's ~40.0k-char startup warning), missing sections, broken @imports, duplicates, stale TODOs |
| `claude-md-linter.mjs` | CML | Oversized files (line count **plus** a context-window-scaled char budget mirroring Claude Code's ~40.0k-char startup warning), missing sections, broken @imports, duplicates, stale TODOs, and **dead prose references** (`CA-CML-013`) — backtick-quoted relative paths in prose that resolve to nothing, next to the file or from the scan root |
| `settings-validator.mjs` | SET | Schema violations, unknown/deprecated keys, type mismatches, permission issues |
| `hook-validator.mjs` | HKV | Invalid format, missing scripts, wrong event names, timeout risks, verbose-stdout scripts, and a low-precision **advisory** (info) when a hook injects un-grepped command output into `hookSpecificOutput.additionalContext` — that payload enters context on every fire (plain stdout does not) |
| `rules-validator.mjs` | RUL | Bad glob patterns, orphaned rules, deprecated fields, unscoped rules |
| `mcp-config-validator.mjs` | MCP | Invalid server types, exposed env vars, unknown fields |
| `import-resolver.mjs` | IMP | Broken @imports, circular references, deep chains, tilde path issues |
| `conflict-detector.mjs` | CNF | Settings contradictions across scopes, permission conflicts, hook duplicates |
| `feature-gap-scanner.mjs` | GAP | 25 feature checks shown as opportunities, not grades — plus a conditional `disableBundledSkills` recommendation when the active skill listing is over budget, and a conditional **filter-before-Claude-reads** lever when a hook injects unfiltered output into `additionalContext` (companion to the HKV advisory; cites the documented `filter-test-output.sh` pattern) |
| `feature-gap-scanner.mjs` | GAP | 24 feature checks shown as opportunities, not grades — plus four conditional levers: a `disableBundledSkills` recommendation when the active skill listing is over budget, a **CLI-over-MCP** lever when tool schemas are forced upfront, a **filter-before-Claude-reads** lever when a hook injects unfiltered output into `additionalContext` (companion to the HKV advisory; cites the documented `filter-test-output.sh` pattern), and **agent model/effort routing** (`CA-GAP-028`) when authored subagents exist and none pins either axis (cites `BP-MODEL-001/002`). Levers are not dimensions: they stay out of the utilization denominators |
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascades, bloated skill descriptions, MCP tool-schema budget, and stale `~/.claude/plugins/cache` versions (disk-cleanup, zero live-context impact) — cache-aware ranking excludes superseded plugin versions by default (`--no-exclude-cache` to include) |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of the CLAUDE.md cascade — beyond Pattern A's top-30 window but still re-loaded every turn — **plus** volatile content inside `@import`-ed files (inlined into the cached prefix, one hop, otherwise invisible to per-file scans) |
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries: (1) tools in BOTH `permissions.deny` and `permissions.allow` — deny wins (incl. the `Tool(*)` deny-all glob, equivalent to a bare deny); (2) unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — valid only as `mcp__<server>__*`; (3) `Tool(param:value)` rules whose key is the tool's own canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/`url`) — CC ignores these and emits a startup warning |
@ -331,6 +341,27 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
| `optimization-lens-scanner.mjs` | OPT | Optimization lens (mechanism-fit): a multi-step procedure in CLAUDE.md that would fit better as a skill (`CA-OPT-001`) — reads the machine-readable best-practices register, framed as an opportunity, not a failure. The deterministic half of the lens; prose-judgment cases (lifecycle→hook, unscoped path→rule, "never"→permission) are judged by the opus `optimization-lens-agent` via `/config-audit optimize` |
| `agent-listing-scanner.mjs` | AGT | Always-loaded agent-listing budget: a per-agent description over the soft bloat cap (`CA-AGT-001`, advisory) and the summed active-agent name+description listing — re-sent every turn — exceeding the listing budget (`CA-AGT-002`). Both LOW and explicitly **inferred / upper-bound**: the agent-listing mechanism is undocumented, so the evidence discloses the estimate and heuristic budget rather than overstating certainty |
> **Dead prose references — a check designed around what it stays silent about.**
> `import-resolver` follows `@import` targets; a path written in ordinary prose was
> checked by nothing, so `CA-CML-013` (low) resolves those too. A reference has to be
> unambiguously path-shaped to qualify: a separator, plus either a trailing `/` or a
> known file extension, resolved both next to the CLAUDE.md and from the scan root —
> a nested file may legitimately write repo-root-relative paths. One finding per file,
> carrying the count and the first few paths.
>
> The design work is the silence list, and every entry on it was measured against 407
> real CLAUDE.md files rather than argued for. Left alone: commands (`npm test`), URLs,
> globs and placeholders (`CA-GAP-*`, `${CLAUDE_PLUGIN_ROOT}/…`), absolute and `~/`
> paths, config keys and flags (`model:`, `--raw`), bare filenames (`README.md` — a
> filename in prose is a concept, and admitting them tripled the output with name-drops
> of tools that exist elsewhere), org/repo slugs and package names
> (`ktg/some-repo`, `@anthropic-ai/claude-agent-sdk`), bare folder names (`docs/`,
> `open/` — the same reasoning one level up), fenced code blocks, and anything resolving
> outside the scanned tree. That last rule is not fussiness: without it a `../../../../etc/passwd`
> resolved to the real file and silenced its own finding. A path carrying a trailing
> `:54-56` locator is a known v1 miss — for a precision-first check, silence is the safe
> failure direction.
> **Cross-scanner remediation — diagnosis meets the fix.** SKL diagnoses an over-budget
> skill listing (`CA-SKL-002`); GAP prescribes the remedy. When the active skill listing
> exceeds its ~2%-of-context budget and `disableBundledSkills` is not already set (in the
@ -419,6 +450,7 @@ All tools work standalone — no Claude Code session needed:
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--no-exclude-cache] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
| **Manifest** | `node scanners/manifest.mjs <path> [--json]` — ranked component-level source table with per-source load pattern + always-loaded subtotal |
| **What's active** | `node scanners/whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` |
| **Backup / restore** | `node scanners/rollback-cli.mjs [--list] [--restore <id>] [--delete <id>] [--create --target <path> …] [--created <path>] [--dry-run] [--approve-scope] [--repo <root>] [--json] [--output-file path]` |
| **Self-audit** | `node scanners/self-audit.mjs [--json] [--fix] [--check-readme]` |
| **Full scan** | `node scanners/scan-orchestrator.mjs <path> [--global] [--full-machine] [--no-suppress]` |
@ -490,7 +522,12 @@ Skills activate automatically when your question matches their trigger patterns.
### Finding ID Format
Every finding has a unique ID: `CA-{SCANNER}-{NNN}` — where `{SCANNER}` is the scanner prefix (see table above) and `{NNN}` is a sequential number. Examples: `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`.
Every finding carries an ID of the form `CA-{SCANNER}-{NNN}` — where `{SCANNER}` is the scanner prefix (see table above) and `{NNN}` **names the check**, not the finding's position in a run. Examples: `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`.
Two consequences worth knowing before you pin one:
- **The same check always has the same ID.** It does not move when you fix an unrelated finding, when a check stops firing, or when a later release adds or retires one. That is what makes an ID safe to write into `.config-audit-ignore`. A number withdrawn from service is never reissued — a suppression naming a retired check goes dead rather than quietly matching a different one.
- **An ID is not unique per finding.** One check failing in three files produces three findings that share an ID; `file` and `line` tell them apart. Suppressing by ID suppresses the check everywhere in scope.
### Suppression
@ -509,6 +546,8 @@ CA-PLH-*
Suppressed findings are tracked in the scan envelope's `suppressed_findings` array for audit trail — nothing is silently hidden. Use `--no-suppress` to see everything.
A pattern that names no known check is reported back in the envelope's `unknown_suppressions` array, so a pin that has gone stale (a typo, or a check retired in a later release) is visible instead of silently protecting nothing. Scanner-wide globs like `CA-GAP-*` are validated only down to the prefix, which is why a glob is the durable way to pin a whole scanner.
---
## Examples & Self-Audit
@ -557,6 +596,7 @@ Shared modules used by all scanners — useful if you're reading the source or e
| `suppression.mjs` | `.config-audit-ignore` parsing, finding suppression, audit trail |
| `active-config-reader.mjs` | Read-only inventory of plugins/skills/MCP/hooks/CLAUDE.md cascade with token estimates |
| `tokenizer-api.mjs` | Anthropic `count_tokens` wrapper for `--accurate-tokens` (v5 N5); 5s timeout, 429 backoff, key masking |
| `write-scope.mjs` | Classifies a write target against the current project (`SCOPE_CLASSES`, `classifyWriteTarget()`); one source for class, gate strength and wording |
### Action Engines
@ -569,6 +609,8 @@ Shared modules used by all scanners — useful if you're reading the source or e
| `manifest.mjs` | CLI: ranked component-level source table w/ load-pattern accounting (v5 N2; v5.6 B) |
| `whats-active.mjs` | CLI: read-only active-config inventory (v3.1.0+) |
| `token-hotspots-cli.mjs` | CLI: token hotspots ranking with optional `--accurate-tokens` |
| `write-scope-cli.mjs` | CLI: classify write targets before an approval surface (`--target`, repeatable) |
| `rollback-cli.mjs` | CLI: the runnable entry to backup and restore (`--list` / `--create` / `--restore` / `--delete`). Both pipelines back up through this one code path, so the manifest format is never written or read by hand |
---
@ -595,6 +637,65 @@ date, and a `confidence`. It is the source of truth for the optimization lens (O
`scanners/lib/best-practices-register.mjs` (zero-dependency, native JSON). See
`docs/v5.7-optimization-lens-plan.md`.
### The subtraction floor
`optimize --subtract` is the only lens that proposes *removing* configuration, so it carries a
guarantee the others do not need: **a load-bearing block is never a candidate.** Precision here
is asymmetric — a missed dead line costs a few tokens per turn, while a deleted load-bearing
line costs a wrong remote or a broken script — so the floor is decided in code
(`scanners/lib/floor-exclusion.mjs`), before the opus judge sees anything, rather than being
left to prose judgement.
A block is floored when it carries an underivable local literal (inline code span, rooted path,
domain, concrete filename, version pin), when it states a policy invariant (secrets,
credentials, production, prompt-injection boundaries — floor *by decision*, not by
classification), or when it names a capitalized entity the mechanism cannot resolve without a
dictionary. That last rule is a deliberate conservative default: it declines to decide and
keeps the block, paying in recall rather than risk.
### Model-scoped candidates (`--for-model`)
Some instructions are dead weight only for a *particular* model. Anthropic documents that
Claude Opus 5 verifies its own work, and that explicit instructions to double-check or to
delegate verification to a subagent cause **over-verification** — token cost with no gain in
quality. `--subtract --for-model opus-5` annotates the subtraction candidates that carry a
reflexive target ("double-check your own work", "før du svarer") or a delegated one ("verify
with a subagent"), while leaving *external* verification ("check the CI status") untagged.
It is an annotation, never a detector: it tags a subset of what `--subtract` already found and
can never widen the candidate set. There is deliberately **no auto-detection** — a CLAUDE.md
has no frontmatter and no resolvable target model, and the same file is read by whichever model
the next session happens to run. That is also why the citation is reported as *conditional*
rather than as a settled fact about the file, both in the report and in the approval listing
shown before anything is written.
Precision comes from requiring the target, not from a narrow verb list. Measured across 409
real CLAUDE.md files: 392 subtraction candidates, **31 carry a verify verb, and 0 also carry a
reflexive or delegated target** — a zero confirmed by two independent raw-text greps, so it is
the corpus rather than an over-narrow rule. Those 31 verb-only blocks are precisely the false
positives a looser version would have produced. A model name the register does not cover is
reported as **unrecognized** rather than as a bare zero, so a typo never reads as "your config
is already clean".
The write half (`--apply`) keeps the same asymmetry. It is not a `fix` action and not a
`plan`/`implement` step, and both exclusions are measurements rather than preferences: the
subtraction axis never enters the orchestrated envelope, so `fix`'s re-scan verification would
report every removal as verified whether or not it happened — a success-shaped no-op — and the
findings pipeline expects a finding code, which by invariant names a deterministic check, not a
prose judgement. `scanners/lib/subtraction-write.mjs` owns the execution instead, and it
re-asserts the floor rather than trusting that the pre-filter already did: the veto stays where
it is, and is simply repeated as the last red line before the delete.
Granularity is the **leaf block** — one list item including its wrapped continuation lines, or
one paragraph — with two structural exceptions: a paragraph ending in `:` merges with the list
it introduces, and an *ordered* list is treated as a contract whose steps inherit floor from
any sibling. Unordered lists deliberately do not inherit, so a load-bearing bullet and a
disposable one can coexist in the same list. Measured against a hand-built ground truth over
a real 250-line CLAUDE.md (48 classified blocks, 19 of them genuinely ambiguous, ~65 % floor):
**zero load-bearing blocks proposed**, 11 of 18 deletable line-ranges surfaced, ≈18 % of an
always-loaded file. On a well-maintained config this axis is mostly a no-op — which is itself
the finding, and the reason precision-over-recall is the only defensible tuning.
---
## Testing
@ -603,7 +704,7 @@ date, and a `confidence`. It is the source of truth for the optimization lens (O
node --test 'tests/**/*.test.mjs'
```
1168 tests across 67 test files (22 lib + 35 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
That one command runs the whole suite from a clean clone: **1477 tests, 370 suites** (measured 2026-08-03, all passing). Nothing runs it automatically — there is no CI in this organisation, so the command above is the verification, not a badge. Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
---
@ -646,22 +747,78 @@ This plugin is cautious by design — configuration files are important, and a b
| **Verification pass** | A separate agent confirms changes actually work |
| **Human-in-the-loop** | You approve the plan before anything is implemented |
| **Post-edit guard** | Hook blocks the session if a new critical/high finding is introduced |
| **Scope disclosed before every write** | Each write target is classified against the project you are in, and the approval surface says when a change leaves it |
### Writes That Leave Your Project
config-audit reads configuration across projects and machine-wide, so some of what
it proposes does not land where you are standing. A count of files cannot tell those
cases apart: a plan that edits `~/.claude/CLAUDE.md` and one that edits your
project's own `CLAUDE.md` are both "1 file".
Every write target is therefore classified before you are asked to approve it, and
the class — not the command — decides how strong the gate is:
| Where the write lands | What happens |
|---|---|
| Inside the project you are working in | No extra gate; the usual confirmation applies |
| config-audit's own session state and backups | No extra gate; this is the plugin's bookkeeping, not your configuration |
| Your machine-wide Claude configuration (`~/.claude`) | Stated plainly, and it needs an explicit go-ahead — a change here affects every project you open |
| A different project | Stated plainly, including that directories will be created there. `campaign export` does this deliberately, so this is disclosure, not refusal |
| Anywhere else | Stated plainly, and it needs an explicit go-ahead |
Where a machine-wide or cross-project write is involved, the safe option is listed
first — the default is never "proceed".
---
## What This Plugin Does Not Cover
## Non-goals
- **Runtime behavior** — this plugin audits configuration files, not what Claude actually does at runtime. For runtime defense, see [claude-code-llm-security](https://git.fromaitochitta.com/open/claude-code-llm-security)
- **Runtime behavior** — this plugin audits configuration files, not what Claude actually does at runtime. For runtime defense, see [llm-security](https://git.fromaitochitta.com/open/llm-security)
- **Secret scanning** — config-audit checks for structural issues, not leaked credentials. Use llm-security for secret detection
- **Custom scanner rules** — scanners check against known Claude Code configuration schemas. Custom rule definitions are not supported
- **Remote/team configuration** — managed settings, SSO-provisioned config, and organization-level policies are detected as gaps but not managed
---
## Version History
## config-audit vs. the built-in /doctor
Claude Code ships `/doctor` (alias `/checkup`, v2.1.205+): an agent-driven setup checkup that
diagnoses and — with your confirmation — fixes issues. The overlap with config-audit was
measured (2026-08-03, CC 2.1.220, prediction-before-measurement protocol), and the two tools
divide cleanly:
| | Built-in `/doctor` | config-audit |
|---|---|---|
| Engine | Model-driven judgment, one machine, one run | Deterministic scanners, byte-stable, reproducible |
| Cost | A full agent session per run (quota) | Free, seconds, scriptable/CI-able |
| Scope | Current setup; trims **checked-in** CLAUDE.md only | All scopes incl. **local/private** files, plus cross-repo `campaign` |
| Evidence | **Usage telemetry** (transcripts, lifetime counters) — its unique edge | Static analysis with provenance-stamped best-practices register |
| Memory | None between runs | Baselines (`drift`), suppressions with audit trail, backup/rollback |
Division of labor per area: `/doctor` parses settings — the SET scanner validates the schema
exhaustively (unknown/deprecated keys, types, whole cascade). `/doctor` measures hook
*latency* — the HKV scanner validates hook *correctness*. `/doctor` judges conflicts per run —
the CNF scanner detects them deterministically. `/doctor` trims derivable content from
checked-in CLAUDE.md — `optimize --subtract` covers **all** scopes with a coded load-bearing
floor (`/doctor` itself refers local-file trimming to `optimize --subtract`). `/doctor`
estimates context weight once — `tokens`/`manifest` measure it deterministically and
cache-aware. Run `/doctor` for a usage-weighted one-shot cleanup; run config-audit for
reproducible, all-scope, zero-quota auditing.
---
## Changelog
Full detail in [CHANGELOG.md](CHANGELOG.md). Highlights per release:
| Version | Date | Highlights |
|---------|------|-----------|
| **6.0.0** | 2026-08-18 | "Prose is not a contract" — a MAJOR release whose subject is a *class* of defect rather than a feature area. Three sweeps (Q1, Q2, Q_AUDIT) kept surfacing the same shape: a command template stated an invariant in prose, code on the other side depended on it, and nothing checked that the two still agreed. **Breaking (`M-BUG-28`):** a finding ID's `{NNN}` names the **check**, not its emission position — so IDs are **not unique per finding** (one check failing in three files emits three findings sharing an ID) and any consumer keying on `id` alone must move to `(id, file, line)`. `finding-codes.mjs` is the single authority and **throws** on an undeclared code, with no counter fallback — a fallback lets a half-converted scanner ship IDs that look valid. **Q1 — the write gate runs in code:** `write-scope.mjs` existed for four releases while exactly *one* writer imported it and five templates paraphrased the policy; measured, 9 files under `scanners/` write to disk and 1 imported the gate. Every writer must now import it or hold an `EXEMPT` entry naming **where the bytes land** — four of them write the plugin's own bookkeeping and must stay ungated, because a gate that fires on every run gets switched off. **Q2 — the argv is checked against the CLI that receives it:** `--stale-after 30` reached its CLI as one argument under zsh, matched no flag, and the command reported "✓ all 14 entries re-verified within the last 90 days" about a threshold the user had just overridden. The probe builds argv from each template's **own text**, and proves itself per CLI by first being seen rejecting a flag that cannot exist. **Q_AUDIT — the third instance, measured not fixed:** data contracts (`state.yaml`, backup manifests) are hand-built by the model and parsed by engines knowing one frozen example; ranked R1R9 with the **recovery path** on top — `rollback` has no CLI entry at all and runs as model prose that pre-renders "(checksum verified)". **Added:** `optimize --subtract --apply` (the subtraction axis can now remove what it proposes — validated against the ORIGINAL content, applied in **descending** line order, coverage asserted from the backup manifest before a byte changes, and the load-bearing floor re-checked in the engine so a hand-built approval cannot route around it); `--for-model <name>` (`BP-PROMPT-001`), an annotation on existing candidates that can never widen the set, reporting `recognized` separately from `matchedCount` so a typo'd model name cannot read as a clean config; a cross-repo write disclosure before approval (`M-BUG-41`); and model/effort routing as a **lever**, not a 25th GAP dimension — a dimension would move every user's utilization score. **Removed:** the `No autoMode classifier` GAP dimension as a `/doctor` duplicate (**25 → 24**); its title lived in **four** tables, not the two the removal was scoped against. **Fixed:** the CLI-argument class across all 14 CLIs, shell state assumed to survive between fenced blocks (**20 places across 9 files**), stdout discarded against a pipe, a nonexistent target path graded instead of erroring, and `drift` crashing on a moved finding humanized as if it were flat. **1752** tests, 0 failing; frozen `v5.0.0` untouched. No component-count change (scanners **16**, agents **7**, commands **21**, hooks **4**). |
| **5.13.0** | 2026-07-31 | "Pipeline hardening" — the batch release of everything found by dogfooding the plugin against the maintainer's real machine and by walking the `analyze → plan → implement → rollback` pipeline end-to-end on a throwaway repo copy: one new lens mode plus **14 bugs** (`M-BUG-11``M-BUG-20`, `M-BUG-22``M-BUG-25`). **Added — `optimize --subtract` (`BP-SUB-001`):** the subtraction axis, asking what no longer earns its always-loaded rent. Opt-in, proposes only, and the only lens that removes config — so a **load-bearing block is never a candidate**, decided in code (`scanners/lib/floor-exclusion.mjs`) *before* the judge runs, never in prose. Verified against a hand-built ground truth written before any classifier existed: **zero load-bearing blocks proposed**, 11/18 groups, ~756 tok ≈ 18% of a ~4300-token file. **Fixed — `rollback` (`M-BUG-22/23/24/25`):** nothing agreed on where a backup lives; `listBackups()` returned 9 phantom test backups and 0 of 4 real ones, and `restoreBackup` returned `{restored:[],failed:[]}` — a **success-shaped no-op** — because `parseManifest` knew only one of the two manifest spellings in use. Canonical root now, legacy kept readable, unparseable manifests **throw**. **`M-BUG-19`:** `globToRegex` corrupted mid-pattern `/**/`, flagging live rules dead. **`M-BUG-18`/`M-BUG-20`:** the subagent harness won't write report-shaped `.md` (analyze now persists the returned report), and parallel agents clobbered the shared log with `Write` (pinned to Bash `>>`). **`M-BUG-11`/`M-BUG-13`:** `optimize` and `feature-gap` scanned vendored plugin config a user cannot act on — `optimize` candidates **454→45**, `feature-gap` **~0 (masked) → 18** opportunities. **`M-BUG-12`/`M-BUG-14`/`M-BUG-15`/`M-BUG-16`/`M-BUG-17`:** plain-language output that contradicted its own evidence (posture's `--output-file` never humanized; four finding types with no humanizer entry). Known, deliberately unfixed: `rollback` cannot delete files `implement` *created* — it now reports them (`createdNotRemoved`) instead of failing silently; automatic deletion of user files gets its own design. No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); frozen v5.0.0 untouched, SC-5 regenerated once for two humanized titles. **1398** tests (+54). |
| **5.12.5** | 2026-06-26 | "Dogfood denoise" — a samle-release of the Fase-3 scanner false-positive batch (`M-BUG-2/6/7/8/10`, all dogfooding finds on the maintainer's real machine). Five scanners stop counting non-user / non-live config as the user's: **CNF** (`M-BUG-2`) excludes files under `.claude/plugins/` from conflict analysis (`isPluginBundled`) — installed plugins' bundled settings/hooks/fixtures are not a user-resolvable cascade (dogfood **339→0**, Conflicts was an F on pure plugin noise). **file-discovery** (`M-BUG-8`) adds `backups` to `SKIP_DIRS` — a `backups/` tree holds frozen copies, never live config (dogfood files-under-`/backups/` **36→0**, 717 live retained). **token estimator** (`M-BUG-6`) strips block-level `<!-- -->` HTML comments from CLAUDE.md sizing — CC strips them before injection, so they were never always-loaded tokens (dogfood ~3386→3301, ~85 tok). **CPS** (`M-BUG-7`) skips fenced/inline code and whitelists CC-stable path vars (`${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}`) before cache-buster matching (dogfood **5→2**). **SET** (`M-BUG-10`) typo-gates the unknown-settings-key finding — the CC schema is passthrough (verified against the 2.1.193 binary), so an unknown key is forward-compatible, not an error; it now flags only a near-miss of a known key (levenshtein ≤2), severity medium→low, +6 binary-verified `KNOWN_KEYS` (dogfood **6→0**). No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); all five are byte-stable — frozen v5.0.0 + SC-5 + default-output snapshots untouched, no re-seed (each fixture's findings are genuinely unchanged). **1344** tests (+37). |
| **5.12.4** | 2026-06-26 | "Rooted rules" — fixes `M-BUG-9` (dogfooding find) in `scanners/rules-validator.mjs`: the RUL "Rule path pattern matches no files" check now resolves a rule's `paths:`/`globs:` pattern against the rule's **own project root** (the dir containing its `.claude/`), not the outer scan root. Previously `countGlobMatches` globbed against the scan target and `collectProjectFiles`' `depth>4` cutoff never reached deep matching files, so a live rule in a **nested repo** (e.g. a marketplace checkout under `~/.claude`) was wrongly flagged "never activates" (high) — a false F-grade for anyone with rules in a nested repo. The fix derives each rule's project root, collects+globs per root (cached), and skips the check for user-global rules (`root === $HOME`), which scope against the active project at runtime. Same scope-conflation family as `M-BUG-1/2`. No count change (scanners **16**, agents **7**, commands **21**); the fix is a no-op when `projectRoot === targetPath` (the common single-repo scan), so frozen v5.0.0 + default-output snapshots stay byte-stable. **1307** tests (+2 TDD: nested-repo false-positive + HOME guard). |
| **5.12.3** | 2026-06-26 | "Phantom agents" — fixes `M-BUG-3/4/5` (dogfooding finds) in `scanners/lib/active-config-reader.mjs`: `enumerateAgents` now counts only the agents Claude Code actually **registers**. Per the official subagents doc, an agent needs valid `name`+`description` frontmatter, and CC scans recursively and silently skips frontmatter-less files. The reader previously (`M-BUG-5`) counted every `.md` regardless of frontmatter, (`M-BUG-3`) never recursed into agent subdirs, and (`M-BUG-4`) double-counted when the project dir equals the user dir (scanning `$HOME` — root cause, also affecting rules/output-styles). Real-machine verify: user-agent count **13→0** (all 12 user agents + `REMEMBER.md` are frontmatter-less → CC registers none), HOME `project`-dup **13→0**; corrected always-loaded baseline ≈ **53** (was 66). Agent enumeration is machine-dependent and absent from the frozen snapshots, so the v5.0.0 + SC-5 + default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands **21**). **1305** tests. |
| **5.12.2** | 2026-06-24 | "Honest census" — fixes `M-BUG-1` (dogfooding find): `enumeratePlugins` walked `~/.claude/plugins/marketplaces/<mkt>/plugins/` and ignored both enable-state and the polyrepo cache layout, so it **over-counted phantom agents** from disabled plugins while **missing the entire enabled polyrepo set** (whose plugins live under `cache/`). It now gates on `installed_plugins.json` + `enabledPlugins` and enumerates each plugin from its active `installPath`, with the marketplaces walk as fallback. Fixes `manifest`/`whats-active`/AGT/`token-hotspots` for any user with disabled plugins or a polyrepo marketplace. No count change (scanners **16**, agents **7**, commands **21**); `--json`/`--raw` byte-stable, frozen v5.0.0 + SC-5 + default-output snapshots untouched. Real-machine verify: agent listing 114→104, ghosts gone. **1301** tests. |
| **5.12.1** | 2026-06-24 | "Footgun guard" — Pattern H (stale plugin-cache versions, `token-hotspots`) recommended deleting stale version dirs with **no warning** that a currently-running session may still hold one of those versions for its whole lifetime. "Stale" is judged against `installed_plugins.json` (what NEW sessions load), so the recommendation could reproduce the exact failure that breaks a live session: deleting the dir pulls the files out from under the running session, which then must `/exit` + restart. The `plugin-cache-hygiene` recommendation now carries the live-session caveat. **Recommendation string only** — no new finding ID or scanner (count stays **16**, agents **7**, commands **21**), no token figures changed, so `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 + default-output snapshots are untouched. **1297** tests. |
| **5.12.0** | 2026-06-23 | "Auto-calibration" — completes the deferred B8 half (**B8b**): `--context-window auto` now **probes the configured model** instead of always falling back to advisory. New pure `modelToContextWindow()` maps known 1M-tier model IDs (Fable 5, Opus 4.8/4.7/4.6, Sonnet 4.6 — verified June 2026 — plus the explicit `[1m]` tier tag, dated/provider-prefixed IDs, and the `opus`/`sonnet`/`fable` aliases) to the 1M window; new IO helper `lib/active-model.mjs` `resolveActiveModel()` reads the model the way Claude Code resolves it (shell `ANTHROPIC_MODEL` override, then the settings cascade local > project > user). When `auto` resolves a recognized model the budget calibrates to its window (`auto-probed`, not advisory); when no model is pinned or it is unrecognized it keeps the conservative anchor and stays advisory (`auto-unresolved`) — the honest fallback. No new finding ID or scanner (count stays **16**, agents **7**, commands **21**); the default and explicit `--context-window` paths are unchanged, so `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1296** tests. |
@ -694,8 +851,6 @@ This plugin is cautious by design — configuration files are important, and a b
| **1.0.0** | 2026-02-11 | Cross-platform support |
| **0.7.0** | 2026-02-07 | Initial version (version reset from inflated 1.2.0) |
See [CHANGELOG.md](CHANGELOG.md) for full details.
---
## License

34
SECURITY.md Normal file
View file

@ -0,0 +1,34 @@
# Security policy
## Reporting a vulnerability
Report privately to <security@fromaitochitta.com> - do not open a
public issue.
Canonical repository: https://git.fromaitochitta.com/open/config-audit
Please include the affected version or commit, a minimal reproduction,
and the impact you see. We acknowledge every report within 5 working
days, agree a fix and disclosure timeline with the reporter, and aim to
disclose within 90 days of the initial report.
## Response process
1. Acknowledge within 5 working days.
2. Triage and confirm severity within 10 working days.
3. Develop and test a fix.
4. Publish an advisory and credit the reporter unless they prefer
to remain anonymous.
## Supported versions
| Version | Supported |
|---------|-----------|
| 5.13.x | Yes |
| < 5.13 | No |
Only the latest tagged release receives security fixes. There is no
long-term support line.
## Advisories
Security-relevant fixes are noted in [CHANGELOG.md](CHANGELOG.md).

View file

@ -51,11 +51,16 @@ In `--raw` mode, fall back to v5.0.0 severity prefiks and verbatim scanner title
5. **Identify optimizations**: Rules to globalize, missing configs, orphaned files
6. **Security scan**: Aggregate secret warnings, check for insecure patterns
7. **CLAUDE.md quality assessment**: Score each file against rubric, assign letter grades
8. **Generate report**: Write comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
8. **Generate report**: Compose the comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
## Output
Write to: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
Return the complete report as your final message — do not write it to a file
yourself. The Claude Code subagent harness instructs agents not to write
report/analysis files; your text output IS the deliverable. The orchestrating
command saves your returned report verbatim to
`~/.claude/config-audit/sessions/{session-id}/analysis-report.md` for the
downstream plan/interview/status phases.
**Output MUST NOT exceed 300 lines.** Prioritize findings by severity. Use tables, not prose.
@ -183,4 +188,4 @@ Verify report: all findings referenced, recommendations actionable, severity lev
- Process findings in memory (typically < 1MB total)
- Generate report in single pass
- No file modifications (read-only except report output)
- No file modifications (read-only; the report is returned as your final message)

View file

@ -146,6 +146,11 @@ Move content from one file to another.
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
**Append discipline (shared log):** other implementer agents may be writing this
log concurrently. ALWAYS append your entry with a Bash `>>` heredoc;
NEVER use the Write or Edit tool on the log file — a full-file Write silently
clobbers entries other agents appended after you read the file.
### Success
```markdown

View file

@ -33,6 +33,48 @@ whether the line is *really* that kind of instruction:
| `claude-md-lifecycle-phrasing` | BP-MECH-001 | a recurring automation the model is *told* to perform ("after every commit, run X") — something that should happen deterministically, not at the model's discretion | a **hook** (PreToolUse / PostToolUse / Stop) |
| `unscoped-path-specific-instruction` | BP-MECH-002 | a constraint that only applies when a *specific* file/path/glob is touched, sitting in root CLAUDE.md where it loads every turn regardless | a **path-scoped rule** (`.claude/rules/` with `paths:` frontmatter) |
| `never-instruction` | BP-MECH-004 | an *absolute* prohibition — something that must NEVER happen, where relying on the model to remember is the wrong guarantee | a **permission deny rule** or PreToolUse hook |
| `compensatory-instruction` | BP-SUB-001 | **`--subtract` mode only.** an instruction that corrects general model *behaviour* rather than stating a local fact — so it pays an always-loaded token cost without telling the model anything it could not work out | **removal**, re-added only if the model actually stumbles |
## The subtraction lens (`--subtract` only)
Present only when the payload has a `subtract` block. It asks the inverse of
every other lens: *what is no longer earning its always-loaded rent?* Four
things make it different, and all four are non-negotiable.
**1. The floor is not yours to decide.** A deterministic pre-step has already
excluded every block carrying a local fact — a code span, path, domain, version
pin, policy invariant, or an unresolved capitalized entity — plus the steps of
any ordered list whose siblings carry one. You never see those blocks, and you
must not reason about whether some *other* block ought to be deleted. Judge only
what you are given. Precision is asymmetric: a missed dead line costs a few
tokens per turn; a deleted load-bearing line costs a wrong remote, a broken
script, or a lost afternoon.
**2. Staleness is NOT a deletion signal.** A block that pins an outdated version
("use Opus 4.8") is a *dead-reference* problem for `drift` / `CA-CML`, not a
subtraction finding. The instruction is still load-bearing — it encodes a
decision only the operator can make; it is merely out of date. Recommending
deletion because content looks stale is a category error. Say "this looks
outdated" if you must, but never as a removal candidate.
**3. Tier 2 is not tier 3.** Deletable splits into *earned* (compensatory, but
this model still stumbles on it, so it returns) and *dead* (never missed). Sort
every candidate into one of the two and say which. A block that has visibly
earned its place — its subject matter recurs in the repo's own history — is tier
2 even when its classification is "compensatory". Reporting it as dead weight is
wrong even though the label matches.
**4. A model-scoped citation is conditional, never authoritative.** When a
candidate carries `modelScope` (only under `--for-model`), apply the SAME tier-2
/ tier-3 judgement as any other `compensatory-instruction` candidate — the model
tag sharpens the citation, it does not bypass precision rule 1. State it as
conditional in the report copy ("redundant if targeting {model}; this operator
may run other models in other sessions"), never as a settled fact for all future
sessions. The tag is an annotation on a candidate the general detector already
found; it is not evidence that the block is dead.
Rank kept candidates by always-loaded token cost, and state the total payoff.
Frame it as *rent*, never as a mistake: this config was correct when written.
## Input
@ -45,6 +87,16 @@ You receive an `optimize-lens` payload (JSON) with:
`recommendation`, `severity`, `source`). Only CONFIRMED register rules reach
you.
- `register` — the full confirmed prose-judgment entries, for reference.
- `subtract`**present only under `--subtract`.** `{ enabled, candidates,
register, detectors }`. Each candidate spans `line``endLine` (a whole leaf
block, not one line) and carries `signalText` plus the BP-SUB-001 register
block. Everything load-bearing was already removed before you saw this.
Under `--for-model <name>` the block also carries `forModel`
(`{ requested, recognized, matchedCount }`), and a SUBSET of its candidates
carry `modelScope` (`{ registerId, claim, requestedModel }`). If
`recognized` is `false`, the operator named a model the register does not
cover — say so, and do not treat the absence of tags as evidence of a clean
config.
Always **Read the actual CLAUDE.md file(s)** named in the candidates before
judging — `signalText` is one line out of context; the surrounding lines decide
@ -104,6 +156,17 @@ Write `optimization-lens-report.md` to the session directory (≤120 lines).
{Brief, honest: candidates you dropped and why — "line 22 mentions a path but is
a cross-reference, not an instruction." This is the precision gate showing its
work. Keep to a few lines.}
## No longer earning its rent (--subtract only)
{Omit entirely unless the payload has a `subtract` block. Two sub-lists —
**Dead** (tier 3, out and never missed) and **Earned** (tier 2, out but likely
to return) — ranked by token cost, with a payoff total. For each:}
**{file}:{line}-{endLine}** — {what the block says, in one line} · ~{N} tok/turn
Tier: {dead | earned — and why}
Source: {register.source.url}
Model-scoped: {only if the candidate carries `modelScope`} {claim}, for
{requestedModel} (conditional — verify this is still the model you target)
```
Omit any section with zero kept findings (except keep the "left alone" note when

View file

@ -171,18 +171,10 @@ Total backup size: ~6.4 KB
**Rationale**:
Code style rules found in 3 projects are identical. Moving to global reduces duplication.
**Content**:
```markdown
# Code Style Rules
## Language Preferences
- TypeScript > JavaScript
- Explicit > implicit
- Lesbarhet > cleverness
## Commit Format
- Conventional Commits: `type(scope): description`
```
**Content outline** (describe it — do not inline the file):
Language preferences, then commit format. The implementer reads the source
files and writes the content itself; a full file body pasted here is what the
200-line budget above forbids.
**Validation**:
- File exists after creation

View file

@ -140,7 +140,15 @@ Checking for secrets...
## Output Format
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
Return the report below as your final message. Do NOT write it to a file: this
agent is read-only by design (`tools: Read, Glob, Grep`) and has no write tool,
so a write instruction here would be a contract it cannot keep.
The orchestrator appends what you return to
`~/.claude/config-audit/sessions/{session-id}/implementation-log.md` itself,
with Bash `>>` (`commands/implement.md` Step 5) — never the Write tool. That log
is shared with the implementer agents running in parallel, and a full-file Write
on it silently clobbers their entries.
```markdown
## Verification Report
@ -243,8 +251,8 @@ Optional: Generate before/after comparison:
This agent:
- Only uses Read, Glob, Grep tools
- Never modifies any files
- Reports findings without taking action
- Never modifies any files, including the shared implementation log
- Reports findings without taking action — every result is returned inline
- Safe to run multiple times
## Model policy

View file

@ -20,9 +20,16 @@ Generate comprehensive analysis report from discovery findings.
## Implementation
### Step 1: Verify session state
### Step 1: Resolve the session and verify its state
Read `~/.claude/config-audit/sessions/{session-id}/state.yaml` using the Read tool and verify discovery phase completed. If not, tell the user: "Discovery hasn't been run yet. Start with `/config-audit discover` or just run `/config-audit` for a full audit."
Find the session first — never guess which one `{session-id}` refers to:
```
Glob: ~/.claude/config-audit/sessions/*/state.yaml
Sort by modification time — the most recently modified session wins
```
Every `{session-id}` below is that session's id. Read its `state.yaml` using the Read tool and verify discovery phase completed. If the Glob returns nothing, or discovery hasn't completed, tell the user: "Discovery hasn't been run yet. Start with `/config-audit discover` or just run `/config-audit` for a full audit."
### Step 2: Tell the user what's happening
@ -37,17 +44,16 @@ This includes hierarchy mapping, conflict detection, and prioritized recommendat
Tell the user: **"Generating analysis (this takes about 30 seconds)..."**
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
```
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
prompt below is **not** a shell, so a variable assigned in a bash block cannot be
referenced from it. Substitute `{mode}` literally with `--raw` or `humanized`.
```
Agent(subagent_type: "config-audit:analyzer-agent")
model: sonnet
prompt: |
Analyze all findings in: ~/.claude/config-audit/sessions/{session-id}/findings/
Mode: $RAW_FLAG (empty = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
Generate comprehensive report covering:
1. Executive summary with key metrics, grouped by userImpactCategory
2. Hierarchy map visualization
@ -60,12 +66,21 @@ Agent(subagent_type: "config-audit:analyzer-agent")
raw severity. The humanizer already replaced jargon-heavy
title/description/recommendation strings with plain-language
equivalents — render them verbatim, do not paraphrase.
Output to: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
Return the complete report as your final message. Do not write it
to a file — the orchestrating command saves it to the session directory.
```
### Step 4: Present summary
### Step 4: Save the report
After the agent completes, read the generated report and show a brief summary:
The agent returns the complete report as its final message — the Claude Code
subagent harness instructs agents not to write report/analysis files themselves,
so the command must persist it. Write the returned report verbatim (no edits,
no truncation) to `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
using the Write tool. Downstream phases (`plan`, `interview`, `status`) read this file.
### Step 5: Present summary
After saving the report, show a brief summary:
```markdown
### Analysis Complete
@ -84,6 +99,6 @@ Full report: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
- **`/config-audit fix`** — Auto-fix deterministic issues right away
```
### Step 5: Update state
### Step 6: Update state
Update `state.yaml` with `current_phase: "analyze"`, `next_phase: "plan"`.
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires: `current_phase: "analyze"`, `completed_phases` (append `analyze` to the existing array — read it first), `next_phase: "plan"`, and `updated_at`. A write that names only two of the four silently deletes the other two.

View file

@ -62,7 +62,8 @@ From `$ARGUMENTS`, pick the mode:
- `export <path>` → export a planned repo's action plan into that repo's own `docs/`.
- `help` → show this surface and stop.
Set a shared date stamp for any write: `TODAY=$(date +%F)`.
Every write step derives its own date stamp inside its own block — there is no shared one to
set here, because each fenced block runs as a separate process.
### Step 2: Always report current state first
@ -147,6 +148,9 @@ If already initialized, say so and stop (no clobber). Otherwise tell the user wh
then create it:
```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs init \
--reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
@ -170,13 +174,23 @@ at `~/.claude/config-audit/campaign-ledger.json`." Then suggest `add`.
them in one call (idempotent — already-tracked repos are skipped, not reset):
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add <path1> <path2> ... \
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add "<path1>" "<path2>" ... \
--reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
```
(For a single repo with a custom display name, add `--name "<name>"`.) Read the result file and
report what was `added` vs `skipped`, then re-show the repo table.
report what was `added`, `addedUnverified`, and `skipped`, then re-show the repo table.
`addedUnverified[]` holds paths that were tracked but could **not** be read right now (they do
not exist, or are not directories). They are tracked deliberately — an unmounted volume is a
legitimate reason for a repo to be missing today — but they must be named, not glossed over:
"Tracked, but I couldn't read `<path>` — check for a typo, or mount it before the next token
sweep." An unreported phantom row stays in the backlog forever and quietly widens every
machine-wide total.
### Step 5 (mode `set-status`): Transition a repo — propose, approve, write
@ -194,9 +208,12 @@ roll-up stays meaningful. Two honest sources, in order of preference:
On approval:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status <path> <status> \
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status "<path>" "<status>" \
--reference-date "$TODAY" \
[--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session <id>] \
[--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session "<id>"] \
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
```
@ -215,6 +232,9 @@ replaces, never accumulates), and **skips — never aborts on** — any repo tha
the user it will read each tracked repo's live config (a few seconds per repo), then on approval:
```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs refresh-tokens \
--reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
@ -235,6 +255,9 @@ not buried in a session dir. This step copies it there, byte-faithfully.
that carries an `action-plan.md` (i.e. `/config-audit plan` has run there). Run without `--write`:
```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" \
--reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?
@ -252,9 +275,24 @@ no/corrupt ledger). Read `~/.claude/config-audit/sessions/campaign-export.json`
— the first ~12 lines of `document` only, never the whole file, never the raw JSON (UX rules).
Ask for explicit approval to write it.
Showing the path is not the same as saying it leaves this repo. Classify it first:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<targetPath>" --repo "$PWD" --output-file ~/.claude/config-audit/sessions/campaign-export-scope.json 2>/dev/null; echo $?
```
Read that file and render each distinct string in `disclosures[]` verbatim before the
approval question. Exporting into another repo is what this command is *for*, so the
gate here **discloses and does not refuse** — say that the write lands in a different
project and that a `docs/` directory will be created there if it is missing. Do not
turn this into a refusal.
**On approval, write it** (the CLI does the faithful copy — do NOT hand-write the file):
```bash
# Re-derive here: each fenced block is its own Bash call, so a TODAY set
# in an earlier block is empty by the time this one runs.
TODAY=$(date +%F)
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" --write \
--reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?

View file

@ -75,7 +75,13 @@ Manage and clean up accumulated config-audit sessions in `~/.claude/config-audit
- Warn before deleting active sessions: "Session {id} is still active (phase: {phase}). Delete anyway?"
6. **Execute cleanup**:
- For each session to delete: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
- **Validate the id before it ever reaches `rm -rf`.** Each `{session-id}`
must match `^[0-9]{8}_[0-9]{6}$` (the id format `discover` generates) or be
an existing directory name read verbatim from the Glob in step 1. If an id
is empty or fails to match, **refuse to delete it**, report it, and continue
with the rest. An empty id expands the path to
`~/.claude/config-audit/sessions//`, which deletes *every* session.
- For each validated session: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
- Track deleted count and freed space
7. **Output summary**:

View file

@ -83,29 +83,41 @@ This is a silent infrastructure step — do NOT show output to the user.
### Step 3: Run scanners and posture assessment
Tell the user: **"Running 12 configuration scanners..."**
Tell the user: **"Running 16 configuration scanners..."**
Run both scanners and posture in a single Bash command. Default mode runs the humanizer, so each finding in `scan-results.json` carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. If the user passed `--raw`, thread it through to both CLIs to get v5.0.0 verbatim output.
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; echo $?
if echo "$ARGUMENTS" | grep -qE -- '(^| )--raw( |$)'; then RAW_FLAG="--raw"; fi
# Set to the ONE scope flag the detected scope calls for, otherwise leave empty.
# Exactly one token: zsh does not word-split an unquoted expansion, so a variable
# holding "--flag value" would reach argv as a single unrecognised argument.
# A placeholder in square brackets does not start with a dash either, so both
# CLIs' arg loops would take it as the TARGET PATH instead of a flag.
SCOPE_FLAG="" # e.g. SCOPE_FLAG="--full-machine" or SCOPE_FLAG="--global"
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json $SCOPE_FLAG $RAW_FLAG >/dev/null 2>/dev/null; ORCH_STATUS=$?
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $SCOPE_FLAG $RAW_FLAG >/dev/null 2>~/.claude/config-audit/sessions/{session-id}/posture-stderr.txt; POSTURE_STATUS=$?
echo "$ORCH_STATUS $POSTURE_STATUS"
```
Use `--full-machine` for `full` scope, `--global` for `home` scope. For `repo` and `current`, pass the resolved path directly.
Check the echoed exit code:
- `0`, `1`, or `2` → continue normally
- `3` → tell user: "Scanner encountered an unexpected error. Try `/config-audit posture` for a quick check instead." and stop.
Two exit codes are echoed — the orchestrator's first, posture's second. They must be read **independently**; a single trailing `echo $?` would report only the last command, hiding an orchestrator failure behind posture's success.
- both in `0`, `1`, `2` → continue normally
- **either** is `3` → tell user: "Scanner encountered an unexpected error. Try `/config-audit posture` for a quick check instead." and stop.
Posture's stderr goes to a **file**, not `/dev/null`: it carries the humanized scorecard headline that step 6 renders, and that headline exists nowhere in the JSON payload. Writing it to a file keeps UX rule 2 intact (the user still never sees raw scanner output) while leaving the text readable.
### Step 4: Analyze results
Tell the user: **"Scanners complete. Preparing your results..."**
Read BOTH output files using the Read tool:
Read all three output files using the Read tool:
- `~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json`
- `~/.claude/config-audit/sessions/{session-id}/posture.json`
- `~/.claude/config-audit/sessions/{session-id}/posture-stderr.txt` — the humanized scorecard. Take the `Health: {grade} ({score}/100) — {prose}` headline from it; that prose is not in either JSON payload.
Extract these metrics from the JSON:
@ -146,7 +158,7 @@ Present results using this template. The humanizer has already replaced jargon-h
**Health: {overallGrade}** | {qualityAreaCount} areas scanned
{Use the headline line from the humanized stderr scorecard — it carries grade-context prose already. Avoid hardcoding a separate per-grade prose ladder.}
{Use the `Health: …` headline read from `posture-stderr.txt` in step 4 — it carries grade-context prose already. Avoid hardcoding a separate per-grade prose ladder. If that file is missing or empty, say the grade plainly without inventing prose for it.}
Scanned {files_scanned} files | {real_finding_count} findings ({severity_breakdown})
{If test_fixture_count > 0: "({test_fixture_count} additional findings in test fixtures were excluded.)"}
@ -160,9 +172,11 @@ Scanned {files_scanned} files | {real_finding_count} findings ({severity_breakdo
| Settings | {grade} | {count} | {status} |
| Hooks | {grade} | {count} | {status} |
| Rules | {grade} | {count} | {status} |
| MCP Servers | {grade} | {count} | {status} |
| MCP | {grade} | {count} | {status} |
| Imports | {grade} | {count} | {status} |
| Conflicts | {grade} | {count} | {status} |
| Token Efficiency | {grade} | {count} | {status} |
| Plugin Hygiene | {grade} | {count} | {status} |
{For the status column, use the humanized title from the most-severe finding in that area, or a one-phrase plain-language summary. Findings carry userImpactCategory which already groups by impact bucket — use that vocabulary, not raw scanner names.}

View file

@ -72,14 +72,19 @@ Run the scan orchestrator silently to discover and scan files. Default mode emit
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; echo $?
# Set to the flag itself for full/home scope, otherwise leave empty. Never pass
# a placeholder wrapped in square brackets: it does not start with a dash, so
# the orchestrator's arg loop takes it as the SCAN TARGET and silently scans a
# path that does not exist.
SCOPE_FLAGS="" # e.g. SCOPE_FLAGS="--full-machine" or SCOPE_FLAGS="--global"
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json $SCOPE_FLAGS $RAW_FLAG >/dev/null 2>/dev/null; echo $?
```
Check exit code: 0/1/2 → normal. 3 → "Discovery encountered an error. Try a narrower scope."
### Step 6: Save scope and state
Write `scope.yaml` and `state.yaml` to session directory. Update state with `current_phase: "discover"`, `next_phase: "analyze"`.
Write `scope.yaml` and `state.yaml` to session directory. Update state with all four fields `.claude/rules/state-management.md` requires: `current_phase: "discover"`, `completed_phases: [discover]`, `next_phase: "analyze"`, and `updated_at`. The last two are what make an interrupted run resumable.
### Step 7: Present summary

View file

@ -29,10 +29,10 @@ Tell the user: **"Saving current configuration as baseline..."**
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --save --name <baseline-name> $RAW_FLAG 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<path>" --save --name "<baseline-name>" --json $RAW_FLAG 2>/dev/null
```
Read stdout for confirmation. Tell the user:
`--save` writes its human confirmation to **stderr**, which `2>/dev/null` discards — pass `--json` so the `{saved, name, path}` object lands on stdout. Read stdout for confirmation. Tell the user:
```markdown
### Baseline Saved
@ -50,10 +50,16 @@ Tell the user: **"Comparing current configuration against baseline..."**
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --baseline <name> $RAW_FLAG 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<path>" --baseline "<name>" --output-file /tmp/config-audit-drift.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
```
Read stdout. In default mode the diff sections are humanized — finding titles, descriptions, and recommendations have already been replaced with plain-language equivalents. New/resolved/changed finding lists carry `userImpactCategory`, `userActionLanguage`, and `relevanceContext` so you can group and prioritize without re-deriving severity prose. If `--raw` was passed, the v5.0.0 diff is verbatim — present it in a code block as-is.
Exit codes: `0` = stable/improving, `1` = degrading (both normal — present the result either way), `3` = a real error.
Then read `/tmp/config-audit-drift.json` with the **Read tool**. The default-mode report itself goes to stderr, so `--output-file` is the only way this command sees the diff at all.
**Check `_baselineAnchor` first.** If the baseline was saved from a different directory than the one being scanned, the diff is not a drift signal — every baseline finding shows as "resolved" and every current finding as "new", which renders as a falsely reassuring "improving" trend. When the anchor differs, say so plainly and offer to re-anchor with `/config-audit drift --save` instead of presenting the numbers as drift.
In default mode the diff sections are humanized — finding titles, descriptions, and recommendations have already been replaced with plain-language equivalents. New/resolved/changed finding lists carry `userImpactCategory`, `userActionLanguage`, and `relevanceContext` so you can group and prioritize without re-deriving severity prose. If `--raw` was passed, the v5.0.0 diff is verbatim — present it in a code block as-is.
If baseline not found, tell the user:
@ -96,9 +102,14 @@ When iterating new/resolved findings, prefer `userActionLanguage` over raw `seve
If `$ARGUMENTS` contains `--list`:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list --output-file /tmp/config-audit-baselines.json 2>/dev/null; echo $?
```
The human-readable listing goes to stderr, which `2>/dev/null` discards — read
`/tmp/config-audit-baselines.json` with the Read tool and render the `baselines`
array (`name`, `findingCount`, `savedAt`) as a table. If the array is empty, tell
the user no baselines are saved yet and point at `/config-audit drift --save`.
### What's next
After viewing drift:

View file

@ -42,7 +42,7 @@ Generate session ID (`YYYYMMDD_HHmmss`) if no active session exists.
mkdir -p ~/.claude/config-audit/sessions/{session-id}/findings 2>/dev/null
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $RAW_FLAG 2>/dev/null; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
```
If exit code is non-zero: "Assessment couldn't run. Check that the path exists and contains configuration files."
@ -55,8 +55,8 @@ Extract GAP findings from `scannerEnvelope.scanners` (find scanner with `scanner
Detect project context:
```bash
test -f <target-path>/package.json && echo "has_package_json" || echo "no_package_json"
ls <target-path>/*.py <target-path>/requirements.txt <target-path>/pyproject.toml 2>/dev/null | head -3
test -f "<target-path>"/package.json && echo "has_package_json" || echo "no_package_json"
ls "<target-path>"/*.py "<target-path>"/requirements.txt "<target-path>"/pyproject.toml 2>/dev/null | head -3
```
### Step 4: Build numbered recommendations
@ -128,15 +128,23 @@ If the user picks numbers: parse the selection and proceed to Step 6.
For each selected recommendation:
1. **Create backup** of any files that will be modified:
1. **Create backup** of any files that will be modified.
Do **not** reach for `fix-cli.mjs` here. It is dry-run by default, so calling
it without `--apply` creates no backup at all and returns `backupId: null`
and calling it *with* `--apply` would execute unrelated auto-fixes that the
user never selected. Copy the files yourself:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <target-path> --json 2>/dev/null
BACKUP_DIR=~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files
mkdir -p "$BACKUP_DIR" 2>/dev/null
# repeat per file that will be touched:
cp "<file-to-modify>" "$BACKUP_DIR/" 2>/dev/null; echo $?
```
Or create manual backup:
```bash
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
```
Copy each file that will be touched.
Tell the user where the copies landed. These are plain file copies — they are
restored by copying them back, **not** by `/config-audit rollback`, which only
knows about backups written by `fix` and `implement`.
2. **Apply the template** from gap-closure-templates.md. Use the Write or Edit tool to create or modify the relevant configuration file.
@ -151,9 +159,12 @@ Implementing 3 recommendations...
4. **Verify** by re-running posture:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --json --output-file /tmp/config-audit-verify-$$.json 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --json --output-file /tmp/config-audit-verify.json >/dev/null 2>/dev/null
```
Use the Read tool on `/tmp/config-audit-verify.json` for the new `overallGrade`
and score — stdout is discarded on purpose (the same envelope is 255 KB).
### Step 7: Show results
```markdown

View file

@ -15,8 +15,13 @@ Auto-fix deterministic configuration issues. Scans, plans fixes, backs up origin
- `$ARGUMENTS` may contain:
- A target path (default: current working directory)
- `--dry-run`: Show fix plan without applying
- `--global`: Include user-scope config (`~/.claude`) in the scan **and** the fix run
- `--raw`: Pass-through to scanners; produces v5.0.0 verbatim envelope (bypasses the humanizer) for byte-stable diff tooling
`--global` must be passed to **every** step below. The scan that builds the table and
the scan that plans the fixes are two different runs; if only one of them sees the
user scope, the plan and the table describe different config.
## Implementation
### Step 1: Greet and scan
@ -34,7 +39,11 @@ Parse flags and run scanners silently. Default mode emits humanized JSON — eac
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <path> --output-file /tmp/config-audit-fix-scan-$$.json [--global] $RAW_FLAG 2>/dev/null; echo $?
# Set to --global when the user asked for global scope, otherwise leave empty.
# A placeholder in square brackets does not start with a dash, so the arg loop
# would take it as the scan/fix TARGET instead of a flag.
GLOBAL_FLAG=""
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<path>" --output-file /tmp/config-audit-fix-scan.json $GLOBAL_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
```
Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check your configuration."
@ -44,13 +53,31 @@ Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check
Run fix planner silently. The fix-cli emits humanized prose to stderr in default mode and v5.0.0-shape JSON to stdout when `--json` is set; we use `--json` here for structured data and let the humanizer-aware rendering layer (this command's prose output below) supply the plain-language wording from the scan envelope above:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --json 2>/dev/null
# Re-assign here: each fenced block is its own Bash call, so the value
# set in Step 1 is empty by the time this block runs.
GLOBAL_FLAG="" # --global when the user asked for global scope
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" $GLOBAL_FLAG --output-file /tmp/config-audit-fix-plan.json 2>/dev/null; echo $?
```
Read the JSON output using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (`/tmp/config-audit-fix-scan-$$.json`) by finding ID to recover the humanized `title`/`description`/`recommendation` plus `userImpactCategory`/`userActionLanguage` for grouping.
Exit codes: 0 = plan produced, 2 = one or more fixes failed (apply step only), 3 = argument or tool error. On 3, show the stderr message — an unknown flag is rejected by design, not silently ignored.
Read `/tmp/config-audit-fix-plan.json` using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (`/tmp/config-audit-fix-scan.json`) by finding ID to recover the humanized `title`/`description`/`recommendation` plus `userImpactCategory`/`userActionLanguage` for grouping.
### Step 3: Present fix plan
First classify where the auto-fixable entries write. With `--global` the run
takes `~/.claude` into the *fix* pass, so machine-wide and project rows land in
one table; without a marker they read as equally local. Pass one `--target` per
distinct file in the auto-fixable set:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-fix-scope.json 2>/dev/null; echo $?
```
Exit 0 = classified; 3 = argument error (show the stderr message). Read
`/tmp/config-audit-fix-scope.json` and carry each file's `scopeClass` into the
table below.
Show what will be fixed and what needs manual attention. Group by `userActionLanguage` so the urgency phrasing stays consistent with the rest of the toolchain:
```markdown
@ -62,9 +89,9 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
#### {userActionLanguage}
| # | ID | Issue | File |
|---|-----|-------|------|
| 1 | {id} | {humanized title} | {file} |
| # | ID | Issue | File | Scope |
|---|-----|-------|------|-------|
| 1 | {id} | {humanized title} | {file} | {scopeClass, or blank when "in-repo"} |
**Manual ({M} issues — require human judgment), grouped by impact:**
@ -77,7 +104,10 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
### Step 4: Confirm with user
If not `--dry-run`, ask for confirmation:
If not `--dry-run`, ask for confirmation. Render each distinct string in the scope
payload's `disclosures[]` verbatim first.
When `requiresApproval` is false:
```
AskUserQuestion:
@ -88,24 +118,59 @@ AskUserQuestion:
- "Cancel"
```
When `requiresApproval` is true — which is what `--global` produces, since
`~/.claude` is machine-wide — the question MUST say so and the safe option MUST
come first:
```
AskUserQuestion:
question: "{K} of {N} fixes change configuration outside this project. Apply all {N}?"
options:
- "Show dry-run only"
- "Yes — apply all, including outside this project"
- "Cancel"
```
### Step 5: Apply fixes
If confirmed, apply:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --apply --json 2>/dev/null
# Re-assign here: each fenced block is its own Bash call, so the value
# set in Step 1 is empty by the time this block runs.
GLOBAL_FLAG="" # --global when the user asked for global scope
# --approve-scope carries the answer the user just gave to the Step-4 question.
# The engine runs the same scope gate as Step 3 and withholds a `require-ok`
# write on its own, so leaving this empty after the user answered "Yes — apply
# all, including outside this project" makes the run refuse the very fixes they
# approved. Set it ONLY on that answer — never as a default, and never because
# Step 3 already classified the targets: classifying is not approving.
APPROVE_SCOPE="" # --approve-scope when the user approved the outside-project fixes
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" --apply $GLOBAL_FLAG $APPROVE_SCOPE --output-file /tmp/config-audit-fix-applied.json 2>/dev/null; echo $?
```
Read the JSON output to get applied/failed counts and backup location.
Read `/tmp/config-audit-fix-applied.json` with the Read tool to get applied/failed counts and the backup ID. Exit code 2 means at least one fix failed — report it; `failed[]` carries the reason per fix.
The payload also carries the engine's own scope verdict. When it reads
`"status": "refused"` with `"reason": "scope-gate"`, nothing was written: render
each line of `disclosures` verbatim, then ask the Step-4 question again rather
than re-running with the flag on the user's behalf. A refusal is a verdict about
a config that WAS examined, so the exit code stays in the normal 0/1/2 range —
do not report it as a tool error.
### Step 6: Show results
Run a quick posture check to measure improvement:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <path> --json --output-file /tmp/config-audit-fix-posture-$$.json 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<path>" --json --output-file /tmp/config-audit-fix-posture.json >/dev/null 2>/dev/null
```
Use the Read tool on `/tmp/config-audit-fix-posture.json` and take `overallGrade`
and the score from there. That read is the only source for the numbers below:
`--json` prints the same envelope to stdout, but 255 KB of raw JSON in the
transcript to recover one grade is exactly the waste this plugin exists to find.
Present results:
```markdown
@ -139,7 +204,8 @@ Run `/config-audit plan` to get a step-by-step guide for addressing these.
## Safety
- Backup is **mandatory** — every fix creates a backup first
- Backup is **mandatory** — every fix creates a backup first, including file renames (the source file is backed up before the rename, so rollback can restore it at its original path)
- Dry-run by default — user must confirm before changes
- Verify after fix — re-scans to confirm findings resolved
- Verify after fix — re-scans in the **same scope** the fix run used, so a `--global` run is verified against user scope too
- Rollback always available — `/config-audit rollback <backup-id>`
- A failed fix is reported, never swallowed — exit 2 plus a `failed[]` entry

View file

@ -22,24 +22,47 @@ Execute the action plan with full backup, verification, and rollback support.
### Step 1: Parse flags, load and verify
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
prompt in Step 4 is **not** a shell, so a variable assigned in a bash block cannot
be referenced from it. Substitute `{mode}` literally with `--raw` or `humanized`.
Find the most recent session with a plan (use the **Glob tool** for
`~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match — Read
does not expand `*`). If none: "No action plan found. Run `/config-audit plan` first."
Use the Read tool on the action plan and count actions.
Now classify where those actions actually write. A plan whose actions target
`~/.claude/CLAUDE.md` and a plan whose actions target `./CLAUDE.md` are the same
count of actions — presenting only the count made a machine-wide change look
identical to a project-local one. Pass one `--target` per distinct file the plan
touches (absolute paths, as written in the plan):
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-implement-scope.json 2>/dev/null; echo $?
```
Find the most recent session with a plan. If none: "No action plan found. Run `/config-audit plan` first."
Use the Read tool on the action plan and count actions. Tell the user:
Exit 0 = classified; 3 = argument error (show the stderr message). Read
`/tmp/config-audit-implement-scope.json`. Tell the user:
```
## Implementing Action Plan
Found {N} actions to execute across {M} files.
A backup will be created before any changes are made.
{For each target whose `gate` is not "silent", one line:}
- `{target}` — {scopeClass}
```
### Step 2: Get user approval
Render each distinct string in `disclosures[]` verbatim before asking — they are
already plain-language, and the payload carries them so this template never has
to restate what a scope class means.
When `requiresApproval` is false, ask as before:
```
AskUserQuestion:
question: "Ready to implement {N} actions? Backup created automatically — you can roll back with one command."
@ -49,15 +72,47 @@ AskUserQuestion:
- "Cancel"
```
### Step 3: Create backup
When `requiresApproval` is true, the question MUST name the scope, and the
safe option MUST come first — a plan that edits machine-wide configuration
affects every project the user opens, so the default must not be "proceed":
Create backup silently:
```bash
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
```
AskUserQuestion:
question: "This plan changes configuration outside this project ({K} of {M} files). Proceed?"
options:
- "Review plan first" (then show the plan file path)
- "Yes — change files outside this project too"
- "Cancel"
```
Copy each file to be modified. Generate `manifest.yaml` with checksums.
### Step 3: Create backup
Create the backup through the code that owns the format. Pass one `--target` per
pre-existing file the plan will MODIFY, and one `--created` per file the plan
will CREATE — a backup cannot hold a file that does not exist yet, so those are
recorded rather than copied, and `/config-audit rollback` reads them back to tell
the user which files it is leaving behind.
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --create --target "<file-1>" --target "<file-2>" --created "<new-file-1>" --repo "$PWD" --output-file /tmp/config-audit-implement-backup.json 2>/dev/null; echo $?
```
| Exit | Meaning |
|------|---------|
| 0 | every target was backed up |
| 1 | at least one target was not there — read `skipped[]` before continuing |
| 3 | the CLI could not run (show the stderr message); do not edit anything |
Read `/tmp/config-audit-implement-backup.json`. The payload's `backupId` is the
ID to quote from here on — **never re-derive it**. Shell state does not survive
to the next block, and a second `date` call that straddles a second boundary
would hand the user a rollback ID that does not exist. Use it wherever
`{backup-id}` appears below.
On exit 1, name the `skipped[]` paths before doing anything else: the plan is
about to change files that have no backup behind them. If any skipped path is one
the plan MODIFIES (rather than creates), stop and report — that action cannot be
rolled back.
Tell the user: **"Backup created. Implementing actions..."**
@ -71,13 +126,15 @@ Agent(subagent_type: "config-audit:implementer-agent")
prompt: |
Execute action: {action-id}
File: {file-path}, Type: {create|modify|delete}
Mode: $RAW_FLAG (empty = humanized progress prose; "--raw" = v5.0.0 verbatim)
Mode: {mode} ("humanized" = humanized progress prose; "--raw" = v5.0.0 verbatim)
Details: {changes}
Verify backup exists, make change, validate syntax.
When logging progress, use the humanized title/userActionLanguage
fields from the action plan (the planner already rendered them) —
do not re-derive severity prose. Append result to:
~/.claude/config-audit/sessions/{session-id}/implementation-log.md
Append with Bash `>>` (heredoc) — NEVER the Write tool on this log;
parallel agents share it and a full-file Write clobbers their entries.
```
Show progress between groups using the humanized titles already present in the action plan:
@ -100,7 +157,19 @@ Agent(subagent_type: "config-audit:verifier-agent")
1. Modified files exist and are syntactically valid
2. New files created correctly
3. No new conflicts introduced
Report to: ~/.claude/config-audit/sessions/{session-id}/implementation-log.md
Return your findings as your final message. Do NOT write them to a file —
this agent is read-only by design (tools: Read, Glob, Grep) and has no
write tool; instructing it to write a report is a contract it cannot keep.
```
Append the verifier's returned findings to the log yourself, with Bash `>>`
(heredoc) — never the Write tool, for the same reason as Step 4:
```bash
cat >> ~/.claude/config-audit/sessions/{session-id}/implementation-log.md <<'EOF'
## Verification
{verifier findings}
EOF
```
If verifier finds issues: one retry with implementer agent. If still failing: report and suggest rollback.
@ -112,27 +181,51 @@ If verifier finds issues: one retry with implementer agent. If still failing: re
**{succeeded} succeeded** | {failed} failed | {skipped} skipped
{If score improved, run quick posture and show:}
Score impact: {old_grade} → {new_grade} (+{delta} points)
{If failed > 0:}
{failed} action(s) couldn't be completed — see log for details.
**Backup location:** `~/.claude/config-audit/backups/{timestamp}/`
**Rollback:** `/config-audit rollback {timestamp}`
**Backup location:** `~/.claude/config-audit/backups/{backup-id}/`
**Rollback:** `/config-audit rollback {backup-id}`
**Full log:** `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
```
**On reporting a score.** Only quote a grade *change* if the pre-change grade was
actually captured before Step 4 ran. Once the files are edited, only the new grade
is measurable — a delta computed after the fact has no source and must not be
invented. To offer one, measure first in Step 1 and again here:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file /tmp/config-audit-implement-posture.json 2>/dev/null; echo $?
```
Then Read `/tmp/config-audit-implement-posture.json`. Both the `--output-file` and
the `2>/dev/null` are required by the output rules — a bare scanner call would put
diagnostic output in front of the user. If no pre-change grade was captured, report
the new grade alone and say nothing about a delta.
### Step 7: Update state
Update `state.yaml` with `current_phase: "implement"`, `next_phase: null`.
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires:
- `current_phase: "implement"`
- `completed_phases`: append `implement` to the existing array (read it first; never replace it)
- `next_phase: null`
- `updated_at`: current timestamp
A full-file Write that names only two of the four silently deletes the other two.
## Rollback
If the user requests rollback at any point:
1. Read `manifest.yaml` from backup
2. Restore each file and verify checksums
3. Delete newly created files
1. Run `/config-audit rollback {backup-id}` — it drives `scanners/rollback-cli.mjs`,
which verifies each checksum before and after writing. Do not restore by hand:
a copy performs neither check, and the result cannot be reported as verified.
2. Read the restore payload and report the per-file `status` it returns
3. **Report — do not delete — the files this run created.** Rollback restores from
backup, and no backup can exist for a file that did not exist before. Those
paths stay on disk; `/config-audit rollback` lists them under "Left in place"
so the user can remove them deliberately. Promising deletion here would leave a
half-restored config that reads as a clean rollback.
4. Update state to `rolled_back`
## Error Handling

View file

@ -11,8 +11,8 @@ Gather user preferences to inform the action plan.
## IMPORTANT: Inline Execution Only
This command runs AskUserQuestion **directly in the main context** — NOT via a Task subagent.
AskUserQuestion requires synchronous terminal interaction and does not work when delegated to a Task subagent.
This command runs AskUserQuestion **directly in the main context** — NOT via an `Agent` subagent.
AskUserQuestion requires synchronous terminal interaction and does not work when delegated to an `Agent` subagent.
## Prerequisites
@ -32,10 +32,29 @@ AskUserQuestion requires synchronous terminal interaction and does not work when
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
```
1. **Load session state**: Verify analysis phase completed, read analysis report for context
2. **Conduct interview inline**: Use AskUserQuestion tool directly (NOT via Task). Adapt questions based on analysis findings.
1. **Resolve the session, then load its state**:
```
Glob: ~/.claude/config-audit/sessions/*/state.yaml
Sort by modification time — the most recently modified session wins
```
Every path below substitutes that session's id for `{session-id}`. Never guess
it: if the Glob returns nothing, say "No audit session found — run
`/config-audit discover` first" and exit. Read the session's `state.yaml` and
verify `completed_phases` contains `analyze`; if it doesn't, tell the user
analysis hasn't run yet and exit. Then read the analysis report for context.
2. **Conduct interview inline**: Use AskUserQuestion tool directly (never delegate it to a subagent via `Agent` — a subagent cannot hold the interactive turn). Adapt questions based on analysis findings.
3. **Save interview results**: Write to `~/.claude/config-audit/sessions/{session-id}/interview.md`
4. **Update state** (see state-management rule)
4. **Update state** (see state-management rule), with one bound specific to this
command: interview is optional and can be run against a session that already
moved past it. If `completed_phases` already contains a later phase (`plan`,
`implement`, `verify`), do **not** rewind `current_phase` and do not re-add a
phase already in `completed_phases` — append `interview` only if it is absent,
leave `current_phase`/`next_phase` pointing at the furthest phase reached, and
tell the user the preferences will apply the next time `/config-audit plan`
runs. Rewinding a finished session is how its progress gets lost. Always set
`updated_at` to the current timestamp, whichever branch above applies.
5. **Output summary**
## Interview Questions

View file

@ -44,14 +44,21 @@ re-verification) and polling for new Claude Code practices...
### Step 2: Run the stale-check CLI
```bash
# Pass the threshold as its OWN quoted argument. Building "--stale-after 30" into
# one variable and expanding it unquoted only works if the shell word-splits —
# bash does, zsh (the macOS default) does not, and there the flag silently
# reverted to the 90-day default while the command reported success.
TODAY=$(date +%F)
STALE_AFTER=""
if echo "$ARGUMENTS" | grep -qE -- '--stale-after'; then
STALE_AFTER="--stale-after $(echo "$ARGUMENTS" | sed -nE 's/.*--stale-after[ =]+([0-9]+).*/\1/p')"
fi
STALE_AFTER_DAYS=$(echo "$ARGUMENTS" | sed -nE 's/.*--stale-after[ =]+([0-9]+).*/\1/p')
if [ -n "$STALE_AFTER_DAYS" ]; then
node ${CLAUDE_PLUGIN_ROOT}/scanners/knowledge-refresh-cli.mjs \
--reference-date "$TODAY" $STALE_AFTER \
--reference-date "$TODAY" --stale-after "$STALE_AFTER_DAYS" \
--output-file ~/.claude/config-audit/sessions/knowledge-refresh.json 2>/dev/null; echo $?
else
node ${CLAUDE_PLUGIN_ROOT}/scanners/knowledge-refresh-cli.mjs \
--reference-date "$TODAY" \
--output-file ~/.claude/config-audit/sessions/knowledge-refresh.json 2>/dev/null; echo $?
fi
```
Exit code **0** = all fresh, **1** = some stale (advisory, normal), **3** = real error →
@ -100,16 +107,27 @@ Be explicit: **"I will not change any file until you approve specific items."**
### Step 6: Apply approved writes (only the approved ones)
**Where the register lives — say this before writing.** The register is part of the plugin,
and the stale check above read it from `${CLAUDE_PLUGIN_ROOT}/knowledge/best-practices.json`
(the `registerPath` field in the payload names the exact file). For a marketplace install that
is the plugin cache, so **an edit there is discarded by the next plugin upgrade** — the durable
home for an approved change is the plugin's own checkout. Tell the user which of the two they
are about to write to, using the `registerPath` they can see, before asking for approval.
For each approved item:
1. Edit `knowledge/best-practices.json` — bump `source.verified`, update the `claim`/
`recommendation`, or append the new entry. Keep the file's 2-space JSON formatting.
2. If a `knowledge/*.md` mirror states the same fact, update it too so the human-readable
mirror doesn't drift from the register.
1. Edit `${CLAUDE_PLUGIN_ROOT}/knowledge/best-practices.json` — bump `source.verified`, update
the `claim`/`recommendation`, or append the new entry. Keep the file's 2-space JSON
formatting. Use the anchored path, never a bare `knowledge/…` — a relative path resolves
against the user's current repo, which is not the file the CLI read.
2. If a `${CLAUDE_PLUGIN_ROOT}/knowledge/*.md` mirror states the same fact, update it too so
the human-readable mirror doesn't drift from the register.
3. **Validate before declaring done** — re-run the register schema check and confirm zero errors:
```bash
node --test ${CLAUDE_PLUGIN_ROOT}/tests/lib/best-practices-register.test.mjs 2>&1 | tail -5
```
If validation fails, revert that edit and report it — never leave the register invalid.
This test loads the register through the same anchored path, so it validates the file you
just edited — that only holds while step 1 uses the anchored path too. If validation fails,
revert that edit and report it — never leave the register invalid.
Report exactly what changed (ids + fields), and what was deferred to manual review.

View file

@ -39,10 +39,9 @@ First non-flag argument is the path (default `.`). Recognized flags:
Tell the user: **"Building token-source manifest for `<path>`..."**
```bash
TMPFILE="/tmp/ca-manifest-$$.json"
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file "$TMPFILE" $RAW_FLAG 2>/dev/null; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs "<path>" --output-file /tmp/config-audit-manifest.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
```
**Exit code handling:**
@ -52,14 +51,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file "$TMPFILE"
### Step 3: If `--json` was requested, cat the file and stop
```bash
cat "$TMPFILE"
cat /tmp/config-audit-manifest.json
```
Do NOT render the table in JSON mode.
### Step 4: Read JSON and render
Use the Read tool on `$TMPFILE`. Extract `meta.repoPath`, `total`, `summary`, and `sources[]`. Lead with the **always-loaded subtotal** (the headline), then render the top 20 sources (or fewer if the manifest is shorter):
Use the Read tool on `/tmp/config-audit-manifest.json`. Extract `meta.repoPath`, `total`, `summary`, and `sources[]`. Lead with the **always-loaded subtotal** (the headline), then render the top 20 sources (or fewer if the manifest is shorter):
```markdown
**Token-source manifest for `<repoPath>`** — ~{total} tokens total
@ -70,10 +69,11 @@ Use the Read tool on `$TMPFILE`. Extract `meta.repoPath`, `total`, `summary`, an
| Rank | Kind | Name | Source | Tokens | Load |
|------|------|------|--------|--------|------|
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} | {load} |
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} | {loadPattern} |
| ... | ... | ... | ... | ... | ... |
_Load column: **always** / **on-demand** / **external**. Append `°` when `derivationConfidence` is `inferred` (no primary-doc row pins it exactly)._
_Agent rows carry `model` and `effort`. When either is set, append it to the name — `` `reviewer` (haiku/low) `` — using `inherit` / `default` for the unset side. Leave the suffix off entirely when both are null; a row of "inherit/default" on every agent is noise, and `/config-audit feature-gap` is where that becomes a finding._
_Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±15%._
```

View file

@ -24,6 +24,9 @@ is hybrid: a cheap deterministic pre-filter finds candidates, then the opus
- **Lifecycle phrasing → hooks** (BP-MECH-001)
- **Unscoped path-specific instructions → path-scoped rules** (BP-MECH-002)
- **Absolute "never" prohibitions → permissions / hooks** (BP-MECH-004)
- **`--subtract`:** instructions that no longer earn their always-loaded rent (BP-SUB-001)
- **`--subtract --for-model <name>`:** of those, the ones a named model documents
as redundant — e.g. self-verification instructions on Claude Opus 5 (BP-PROMPT-001)
Each finding cites its register rule + source URL. A clean CLAUDE.md returns "no
opportunities" — that is a good result, not a failure.
@ -34,7 +37,48 @@ opportunities" — that is a good result, not a failure.
Split `$ARGUMENTS` into a path (first non-flag argument; default: current working
directory) and flags. Recognized flags: `--global` (include the user `~/.claude`
cascade in discovery).
cascade in discovery), `--subtract` (add the subtraction axis, below),
`--for-model <name>` (annotate model-scoped candidates, below) and `--apply`
(execute approved removals — Step 7).
`--apply` only means anything alongside `--subtract`. If it is present without
it, say so and continue with the ordinary lens run:
```
`--apply` executes approved subtraction removals, so it needs `--subtract` too.
Running the ordinary lens; re-run with `--subtract --apply` to remove anything.
```
`--for-model <name>` has the same dependency. If it is present without
`--subtract`, say so and continue with the ordinary lens run:
```
`--for-model` annotates subtraction candidates, so it needs `--subtract` too.
Running the ordinary lens; re-run with `--subtract --for-model <name>`.
```
**`--subtract` — the inverse question.** Every other lens asks what to *add* or
*move*; this one asks what no longer earns its always-loaded rent. It is opt-in
because it asks something different, and because deleting is not undoable by
reading. Pair it with `--global` to reach the user-level CLAUDE.md, where the
always-loaded cost actually sits (it loads in every repo, every session).
**`--for-model <name>` — whose redundancy?** Some instructions are only dead
weight for a *particular* model. Anthropic documents that Claude Opus 5 verifies
its own work and over-verifies when told to double-check or to delegate
verification to a subagent. That claim is model-scoped, so the model must be
named: a CLAUDE.md carries no frontmatter and no target model, and the same file
is read by whichever model the next session happens to run. The flag never adds
candidates — it annotates ones `--subtract` already found. Example:
`--subtract --for-model opus-5`.
If `--subtract` is present, say so up front:
```
Also running the subtraction axis — instructions that cost tokens every turn
without telling me anything I couldn't work out. Load-bearing local facts
(remotes, versions, paths, policy) are excluded before anything is judged.
```
Tell the user:
@ -52,7 +96,19 @@ Generate a session ID (`YYYYMMDD_HHmmss`) if no active session exists.
mkdir -p ~/.claude/config-audit/sessions/{session-id} 2>/dev/null
GLOBAL_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--global"; then GLOBAL_FLAG="--global"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG 2>/dev/null; echo $?
SUBTRACT_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--subtract"; then SUBTRACT_FLAG="--subtract"; fi
# --for-model takes a VALUE, so flag and value are two separate variables. Never
# pack them into one ("--for-model x"): the shell here is zsh, which does not
# word-split an unquoted expansion, so one variable would reach the CLI as a
# single malformed argv entry and be silently ignored (M-BUG-45).
FOR_MODEL_FLAG=""
FOR_MODEL_VALUE=""
if echo "$ARGUMENTS" | grep -q -- "--for-model "; then
FOR_MODEL_FLAG="--for-model"
FOR_MODEL_VALUE=$(echo "$ARGUMENTS" | sed -n 's/.*--for-model \([^ ]*\).*/\1/p')
fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG $SUBTRACT_FLAG $FOR_MODEL_FLAG $FOR_MODEL_VALUE 2>/dev/null; echo $?
```
Exit code 0 is normal. Only exit code 3 is a real error → "The lens couldn't run.
@ -64,8 +120,24 @@ Read `~/.claude/config-audit/sessions/{session-id}/optimize-lens.json` with the
Read tool. It has `deterministic` (already-confirmed OPT findings), `candidates`
(pre-filter candidates with register provenance), `register`, and `counts`.
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`,
skip the agent and tell the user plainly:
Under `--subtract` it also has a `subtract` block (`candidates`, `register`) and
`counts.subtractCandidates`. Each subtraction candidate spans `line``endLine`
(a whole block). Include the whole `subtract` block when spawning the agent.
Under `--for-model` the `subtract` block additionally has `forModel`
(`{ requested, recognized, matchedCount }`), and a subset of its candidates carry
`modelScope` (`{ registerId, claim, requestedModel }`). If `recognized` is
`false`, the register does not cover that model name — tell the user before
showing results, so a zero is never read as "your config is already clean":
```
I don't have a model-specific rule for "{requested}", so nothing was annotated
for it. The ordinary subtraction results below are unaffected.
```
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`
(and, under `--subtract`, `counts.subtractCandidates === 0`), skip the agent and
tell the user plainly:
```
✓ No mechanism-fit opportunities found.
@ -103,7 +175,88 @@ If the agent kept nothing from the candidates (all dropped) but there were
deterministic findings, show those; if it kept nothing at all, show the clean
result from Step 3.
### Step 6: Next steps
### Step 7: Apply approved removals (`--subtract --apply` only)
Skip this step entirely unless BOTH flags are present and the agent kept at
least one subtraction finding. Removal is the only thing this plugin does that
takes configuration away, so nothing here happens without a named choice.
**7a — show what is on the table, with honest sizing.** List the kept
subtraction findings numbered, each with its file, line span and first line of
text. A finding carrying `modelScope` must show its condition here too — this
is the last point a human sees it before it reaches an approval file, so the
citation must not read as unconditional:
```
{n}. {file}:{line}-{endLine} — {first line of text}
Model-scoped: redundant if you are targeting {requestedModel}. Other
sessions on this config may run a different model.
```
Do not imply a bigger win than there is:
```
Removing all of these saves roughly {n} tokens per turn — on a typical
always-loaded CLAUDE.md that is around a fifth of the file, not most of it.
```
Ask which to remove: numbers, `all`, or `none`. `none` ends the command.
**7b — write the approval file.** With the **Write** tool, write the operator's
choice to `~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json`
(absolute path — a relative one resolves against the user's CWD). Take `file`,
`line`, `endLine` and `signalText` verbatim from the Step 3 payload; `text` must
be the `signalText` byte-for-byte, because the engine refuses a removal whose
text no longer matches the file:
```json
{ "sessionId": "{session-id}",
"removals": [ { "file": "...", "line": 12, "endLine": 15, "text": "..." } ] }
```
**7c — dry run first.** Always. It costs one call and proves the spans still
match before anything is written.
`--repo` is the **session's own root (`$PWD`), never the scanned path**. It is
what the target is classified *against*: pass the scan target and
`~/.claude/CLAUDE.md` classifies as `in-repo`, which drops the gate to `silent`
on the one target that most needs it (measured — the same silent downgrade as a
naive `.git`-upward walk, arriving through a different door).
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/subtraction-write-cli.mjs --approved ~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json --repo "$PWD" --dry-run --output-file ~/.claude/config-audit/sessions/{session-id}/subtraction-dryrun.json 2>/dev/null; echo $?
```
Read the payload. Exit 3 is a real error (bad or unreadable approval file).
Report any `refused` entry with its `reason` before going further —
`block-mismatch` means the file changed since the scan (re-run the lens),
`floor` means the block is load-bearing and will never be removable.
**7d — the scope gate.** If the dry-run payload has `requiresApproval: true`,
show every line in `disclosures` verbatim and ask for an explicit go-ahead. This
is the machine-wide case (`~/.claude/CLAUDE.md`): the change costs — and saves —
in every project, on every turn, so it is not the same decision as editing the
CLAUDE.md in front of you. Without a clear yes, stop here.
**7e — apply.** Same command without `--dry-run`, adding `--approve-scope` only
if the operator gave that go-ahead in 7d:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/subtraction-write-cli.mjs --approved ~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json --repo "$PWD" --output-file ~/.claude/config-audit/sessions/{session-id}/subtraction-result.json 2>/dev/null; echo $?
```
**7f — report.** From the result payload, tell the user: what was removed (file
+ line span + the text, from `applied`), what was refused and why (`refused`
with `reason`), and how to undo it:
```
Backed up as {backupId} — `/config-audit rollback {backupId}` restores every
file exactly as it was.
```
Never report a run as successful when `counts.applied` is 0.
### Step 8: Next steps
End with context-sensitive next steps, explaining WHY each is useful:
@ -117,7 +270,26 @@ End with context-sensitive next steps, explaining WHY each is useful:
- This command is **agent-driven and not byte-stable** — its output is a
human-facing report, deliberately outside the deterministic snapshot suite.
- `--subtract` **proposes; only `--apply` writes**, and only blocks the operator
named. Every removal is preceded by a backup whose manifest is verified to
cover the file being written, and `/config-audit rollback` restores it.
- **`--for-model` annotates; it never detects.** It tags a subset of the
candidates `--subtract` already produced, and there is no auto-detection by
design: a CLAUDE.md has no frontmatter and no resolvable target model, and the
same file is read by whichever model the next session runs. So the citation is
always **conditional** — it must be shown that way in the report and in the
Step 7a approval listing, never as a settled fact about the file.
- **Removal is not a `fix` action and not a `plan`/`implement` step**, by
measurement rather than preference: the subtraction axis never enters the
orchestrated envelope, so `fix`'s re-scan verification would mark every
removal verified whether or not it happened, and the findings pipeline would
require a finding code — which names a deterministic check, not a prose
judgement. `subtraction-write-cli.mjs` owns the execution instead.
- The subtraction floor is deterministic and runs *before* the agent, so a
load-bearing block is never a candidate. It errs toward keeping: on a
well-maintained config this axis is mostly a no-op, and that is a good result.
- The deterministic half (CA-OPT-001) also rides in the normal orchestrated
audit; this command adds the prose-judgment half on top.
- No files are modified. To act on a finding, use `/config-audit plan`
`/config-audit implement` (backup + rollback) or edit by hand.
- Without `--apply`, no files are modified. To act on a mechanism-fit finding,
use `/config-audit plan``/config-audit implement` (backup + rollback) or
edit by hand.

View file

@ -22,7 +22,11 @@ Generate a prioritized action plan based on analysis results.
### Step 1: Verify session state
Find the most recent session with analysis completed using the Read tool on `~/.claude/config-audit/sessions/*/state.yaml`. If none found: "No analysis results found. Run `/config-audit` first to scan your configuration."
Find the most recent session with analysis completed using the **Glob tool** on `~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match. The Read tool takes one literal path and does not expand `*` — pointing it at the glob makes this step report "no analysis results" even when a valid session exists.
If no session is found: "No analysis results found. Run `/config-audit` first to scan your configuration."
Then confirm the report itself exists — a session can carry a valid `state.yaml` and still be missing its report. Read `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`. If it is absent: "Session {session-id} has no analysis report. Run `/config-audit analyze` to generate it." Stop — the planner agent has nothing to read.
### Step 2: Tell the user what's happening
@ -35,10 +39,10 @@ Actions are ordered by impact, with risk assessment and dependency tracking.
### Step 3: Parse flags and spawn planner agent
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
```
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
prompt below is **not** a shell, so a variable assigned in a bash block cannot be
referenced from it. Substitute `{mode}` literally with `--raw` or with `humanized`
when writing the prompt.
Tell the user: **"Generating your action plan (this takes about 30 seconds)..."**
@ -49,7 +53,7 @@ Agent(subagent_type: "config-audit:planner-agent")
Generate action plan based on:
- Analysis: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
- Interview: ~/.claude/config-audit/sessions/{session-id}/interview.md (if exists)
Mode: $RAW_FLAG (empty = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
Create a prioritized plan that consumes the humanized finding fields:
- Group actions by userImpactCategory (e.g., "Configuration mistake",
"Conflict", "Wasted tokens", "Missed opportunity", "Dead config")
@ -69,7 +73,22 @@ Agent(subagent_type: "config-audit:planner-agent")
### Step 4: Present the plan summary
Read the generated plan and show a concise overview:
Read the generated plan, then classify the files its actions target. This summary
IS the approval surface — there is no separate confirmation step here, so a plan
that proposes writing to machine-wide configuration has to say so where the user
reads it. Pass one `--target` per distinct file the plan touches:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-plan-scope.json 2>/dev/null; echo $?
```
Exit 0 = classified; 3 = argument error (show the stderr message). Read
`/tmp/config-audit-plan-scope.json`. If `gate` is not `"silent"`, render each
distinct string in `disclosures[]` verbatim directly under the action table, and
mark the affected rows — not in a footnote further down, where a user scanning the
table would miss it.
Show a concise overview:
```markdown
### Action Plan Ready
@ -94,7 +113,14 @@ You can edit the plan file to remove, reorder, or modify actions before implemen
### Step 5: Update state
Update `state.yaml` with `current_phase: "plan"`, `next_phase: "implement"`.
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires — a partial write drops the fields that make an interrupted run resumable:
- `current_phase: "plan"`
- `completed_phases`: append `plan` to the existing array (read it first; never overwrite it with a fresh list)
- `next_phase: "implement"`
- `updated_at`: current timestamp
The planner agent may already have written these. Read the file before writing and preserve whichever fields it set — a full-file Write that names only two fields silently deletes the other two.
## Plan Modification

View file

@ -32,15 +32,21 @@ Auditing {N} plugin(s) for structure, frontmatter quality, and cross-plugin conf
### Step 2: Run scanner
Run silently for each plugin. Default mode emits a humanized JSON envelope where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present.
Run silently for each plugin. Default mode writes a humanized JSON payload to `--output-file` where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present, and prints the byte-stable v5.0.0 envelope on stdout instead.
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <path> $RAW_FLAG 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs "<path>" --output-file /tmp/config-audit-plugin-health.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
```
Read stdout output (JSON) using the Read tool. Parse findings.
Read `/tmp/config-audit-plugin-health.json` with the Read tool. Exit codes 0, 1 and 2 are normal; only 3 is a real error.
The payload carries three things the report needs:
- `plugins[]` — one row per plugin: `name`, `declaredName`, `commandCount`, `agentCount`, `findingCount`, `score`, `grade`. Use these for the table; never estimate a grade yourself.
- `cross_plugin_findings[]` — the namespace-collision and shared-command-name findings, already separated from the per-plugin ones (they also carry `crossPlugin: true` in `findings`).
- `findings[]` — every finding, humanized.
### Step 3: Present results
@ -49,7 +55,7 @@ Read stdout output (JSON) using the Read tool. Parse findings.
| Plugin | Grade | Commands | Agents | Status |
|--------|-------|----------|--------|--------|
| {name} | {grade} ({score}) | {cmd_count} | {agent_count} | {Good/Issues found} |
| {plugins[].name} | {plugins[].grade} ({plugins[].score}) | {plugins[].commandCount} | {plugins[].agentCount} | {Good/Issues found} |
| ... | ... | ... | ... | ... |
{If cross-plugin issues:}

View file

@ -42,27 +42,35 @@ Run silently — JSON goes to a file, the humanized scorecard prints to stderr (
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file /tmp/config-audit-posture-$$.json $RAW_FLAG 2>/tmp/config-audit-posture-stderr-$$.txt; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file /tmp/config-audit-posture.json $RAW_FLAG >/dev/null 2>/tmp/config-audit-posture-stderr.txt; echo $?
```
Both paths are fixed literals, repeated literally in every later step: each
```bash fence is its own process, so a `$$`-derived path could never be named
again. `>/dev/null` is required, not cosmetic — with `--raw` the scanner writes
the full envelope to stdout *as well as* the file (`posture.mjs:101`), which is
255 KB on a real repo.
If exit code is non-zero, tell the user: "Assessment couldn't complete. Check that the path exists and contains Claude Code configuration files."
If `--raw` was passed, treat the captured stderr as v5.0.0-shape verbatim text and present it as-is in a code block; skip the humanized rendering steps below.
### Step 3: Read and interpret results
Read the JSON output file using the Read tool. Extract:
Use the Read tool on `/tmp/config-audit-posture.json`. Extract:
- `overallGrade`, `opportunityCount`
- `areas[]` — each with `name`, `grade`, `score`, `findingCount`
- `scannerEnvelope.scanners[].findings[]` — when surfacing individual findings, prefer the humanizer-provided fields: `userImpactCategory` (e.g., "Configuration mistake", "Wasted tokens"), `userActionLanguage` (e.g., "Fix this now", "Fix soon", "Optional cleanup"), and `relevanceContext` ("affects-everyone", "affects-this-machine-only", "test-fixture-no-impact"). These let you group and prioritize without hardcoded severity-to-prose mappings.
Also Read the captured stderr file — its body is the humanized scorecard (grade headline, area-score block, opportunity hint). You can present it verbatim or interleave its lines with the JSON-driven table.
Also use the Read tool on `/tmp/config-audit-posture-stderr.txt` — its body is the humanized scorecard (grade headline, area-score block, opportunity hint). You can present it verbatim or interleave its lines with the JSON-driven table.
### Step 4: Present the scorecard
```markdown
**Health: {overallGrade}** | {qualityAreaCount} areas scanned
**Health: {overallGrade}** | (area count: take it from the humanized scorecard's
"N areas reviewed" line — do NOT use `areas.length`, which counts Feature
Coverage; the table below excludes it, so the two would disagree)
{Use the headline line from the humanized stderr scorecard — it carries grade-context prose already (e.g., " Health: A (97/100) — Healthy setup, only minor polish needed"). Do not re-derive an A/B/C/D prose table here; the humanizer owns that vocabulary.}
@ -93,19 +101,19 @@ Avoid hardcoded grade-to-prose ladders here — the humanized scorecard headline
Run drift comparison silently:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <target-path> 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<target-path>" --output-file /tmp/config-audit-posture-drift.json 2>/dev/null; echo $?
```
Read stdout output and append a "Configuration Drift" section showing what changed since the last baseline.
Use the Read tool on `/tmp/config-audit-posture-drift.json` and append a "Configuration Drift" section showing what changed since the last baseline. Both scanners report to stderr in default mode, which `2>/dev/null` discards — the payload is the only readable output.
**If `--plugin-health` flag is present:**
Run plugin health scanner silently:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <target-path> 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs "<target-path>" --output-file /tmp/config-audit-posture-plh.json 2>/dev/null; echo $?
```
Read stdout output and append a "Plugin Health" section.
Use the Read tool on `/tmp/config-audit-posture-plh.json` and append a "Plugin Health" section, using its `plugins[]` rows for per-plugin grades.
**If both flags:** Use `scanners/lib/report-generator.mjs` to produce a unified markdown report.
@ -113,5 +121,9 @@ Read stdout output and append a "Plugin Health" section.
If a config-audit session exists, save results:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --json --output-file ~/.claude/config-audit/sessions/<session-id>/posture.json 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --json --output-file ~/.claude/config-audit/sessions/<session-id>/posture.json >/dev/null 2>/dev/null
```
This is a second scan on purpose: the session file stores the raw v5.0.0 shape,
while step 2 wrote the humanized one. `>/dev/null` matters most here — `--json`
sends the same envelope to stdout regardless of `--output-file`.

View file

@ -10,6 +10,12 @@ model: sonnet
Restore configuration files from a previous backup. Without arguments, lists available backups. With a backup ID, restores files from that backup.
Every step below runs `scanners/rollback-cli.mjs`, which drives the rollback
engine: it verifies each file's checksum before AND after writing it, resolves
backups made under the pre-v2.2.0 root, and reports the files a restore cannot
undo. Never restore by copying files back by hand — a copy performs none of
those checks, and the result cannot honestly be reported as verified.
## Arguments
- `$ARGUMENTS` may contain a backup ID (format: `YYYYMMDD_HHMMSS`)
@ -19,14 +25,18 @@ Restore configuration files from a previous backup. Without arguments, lists ava
### List mode (no argument)
Parse flags and list available backups from `~/.claude/config-audit/backups/`:
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
ls -1 ~/.claude/config-audit/backups/
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --list --output-file /tmp/config-audit-rollback-list.json 2>/dev/null; echo $?
```
Exit 0 = listed; 3 = the CLI could not do its job (show the stderr message).
Read `/tmp/config-audit-rollback-list.json` and render one row per entry in
`backups[]``{id}`, how many `{files}` it holds, and `{createdAt}`. An entry
whose `{legacy}` is true was made under the pre-v2.2.0 backup root; say so, since
its path differs from the one printed below.
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Available Backups
@ -40,12 +50,26 @@ ls -1 ~/.claude/config-audit/backups/
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
Use the Read tool on each backup's `manifest.yaml` (the list of changes captured at backup time) to extract the file list and timestamps.
If `{count}` is 0, say there are no backups yet and stop — do not offer a restore.
### Restore mode (with backup ID)
1. Read the list of changes from `~/.claude/config-audit/backups/{backup-id}/manifest.yaml` using the Read tool
2. Show files that will be restored — ask for confirmation:
1. The `backups[]` entry for that ID (from the list payload above) carries the
`files[]` this restore would write. Classify those `{originalPath}` values
before showing them. A restore writes to the absolute path recorded at backup
time, which may be machine-wide even when the backup was taken from a project
— so the file list must be rendered as the absolute originals, never shortened
to a repo-relative-looking form that implies the write stays local:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<original-1>" --target "<original-2>" --repo "$PWD" --output-file /tmp/config-audit-rollback-scope.json 2>/dev/null; echo $?
```
Exit 0 = classified; 3 = argument error (show the stderr message). Read
`/tmp/config-audit-rollback-scope.json`, render each distinct string in
`disclosures[]` verbatim, then ask for confirmation.
When `requiresApproval` is false:
```
AskUserQuestion:
question: "Restore 3 files from backup 20260403_163045?"
@ -53,38 +77,83 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
- "Yes, restore"
- "Cancel"
```
3. For each file in the list of changes:
a. Read the backup file from `~/.claude/config-audit/backups/{backup-id}/files/{safeName}`
b. Write to the original path
c. Verify the checksum matches the recorded value in the list of changes
4. Show result:
When `requiresApproval` is true, name the scope and put the safe option first:
```
Restored 3 files from backup 20260403_163045
- .claude/settings.json (checksum verified)
- hooks/hooks.json (checksum verified)
- .claude/rules/typescript.md (checksum verified)
AskUserQuestion:
question: "This restores {K} of 3 files to locations outside this project. Restore all 3?"
options:
- "Cancel"
- "Yes — restore, including outside this project"
```
2. Run the restore. Classifying is not approving: add `--approve-scope` only
after the user has answered yes to the question above, and only then.
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --restore "<backup-id>" --repo "$PWD" --output-file /tmp/config-audit-rollback-restore.json 2>/dev/null; echo $?
```
Append ` --approve-scope` to that command when the user approved a restore
that leaves this project. To preview without writing anything, append
` --dry-run` instead — a dry run reports what would happen and touches no file.
| Exit | Meaning |
|------|---------|
| 0 | every file restored |
| 1 | nothing was written — the restore needs approval it was not given |
| 2 | at least one file failed; read `failed[]` before saying anything else |
| 3 | the CLI could not run (bad ID, unreadable manifest) — show the stderr message |
3. Read `/tmp/config-audit-rollback-restore.json` and report what the run
actually did. Render one line per entry in `restored[]` and `failed[]`, each
showing its own `{status}` from the payload — never a fixed verification
phrase, because the outcome differs per file and only the payload knows it:
```
Restored 2 of 3 files from backup 20260403_163045
- /abs/path/.claude/settings.json — {status}
- /abs/path/hooks/hooks.json — {status}
- /abs/path/.claude/rules/typescript.md — {status}
```
When `{requiresApproval}` is true and nothing was restored, the run was
refused: list the `refused[]` paths, say plainly that no file was changed, and
offer to re-run with approval.
4. **Report what rollback cannot undo.** A backup only holds files that already
existed, so files the implement step CREATED survive the restore. When
`createdNotRemoved` is non-empty, list those paths and say plainly that they
remain:
```
Left in place — created by implement, no backup exists:
- .claude/rules/post-quality.md
- guidelines/posting-rhythm.md
Remove them manually if you want the pre-implement state exactly.
```
Never finish a restore without this section when the list is non-empty; a
silently half-restored target reads as a clean rollback.
### Delete mode
If user says "delete" after listing, confirm and remove the backup directory.
If the user says "delete" after listing, confirm, then:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --delete "<backup-id>" --output-file /tmp/config-audit-rollback-delete.json 2>/dev/null; echo $?
```
Exit 0 = removed (`deleted` is true in the payload); 3 = no backup with that ID
in either root — show the stderr message rather than reporting a deletion.
## Implementation
Use the backup and rollback libraries directly:
```javascript
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
import { parseManifest } from '../scanners/lib/backup.mjs';
```
`scanners/rollback-cli.mjs` is the only entry point. It reads
`~/.claude/config-audit/backups` and falls back to the pre-v2.2.0
`~/.config-audit/backups`, so a backup made before the move still resolves; the
list payload flags those with `legacy: true`, and both roots are echoed in
`meta` so a report can name the one it used.
Or via Bash:
```bash
# List backups
ls -1 ~/.claude/config-audit/backups/
# Read manifest
cat ~/.claude/config-audit/backups/{id}/manifest.yaml
# Restore (copy back)
cp ~/.claude/config-audit/backups/{id}/files/{safeName} {originalPath}
```
The same CLI creates backups (`--create --target <path> …`), which is how
`/config-audit implement` records what it is about to change. Backup and restore
therefore share one manifest format, owned by `scanners/lib/backup.mjs` — the
format is never written or read by hand.

View file

@ -37,8 +37,13 @@ When `--raw` is in `$ARGUMENTS`, render the raw `current_phase` field value verb
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
ALL_FLAG=""
if echo "$ARGUMENTS" | grep -qw -- "all"; then ALL_FLAG="all"; fi
```
When `ALL_FLAG` is set, skip steps 24 and render the **List All Sessions**
table below instead of a single session's status.
2. **Find active session**:
```
Glob: ~/.claude/config-audit/sessions/*/state.yaml
@ -128,11 +133,13 @@ All config-audit sessions:
| 20250120_160000 | implement | 2025-01-20 16:00 |
```
## Resume Session
## Resuming a session
If multiple sessions exist:
```
/config-audit resume {session-id}
```
There is no `resume` command. Sessions are selected by recency: every
session-aware command globs `~/.claude/config-audit/sessions/*/state.yaml` and
takes the most recently modified one.
Sets that session as active and continues from last phase.
To continue an older session, run its next phase directly — `/config-audit plan`,
`/config-audit implement`, and so on read `next_phase` from the state file. If
the wrong session keeps winning, delete the stale ones with
`/config-audit cleanup`.

View file

@ -40,12 +40,25 @@ Tell the user: **"Analysing token hotspots for `<path>`..."**
Default mode (no `--json`, no `--raw`) emits a humanized JSON envelope: each finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` in addition to the v5.0.0 fields. Pass `--raw` through verbatim if the user requested it.
```bash
TMPFILE="/tmp/config-audit-tokens-$$.json"
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] [--no-exclude-cache] $RAW_FLAG 2>/dev/null; echo $?
# Set each to the flag itself when the user asked for it, otherwise leave empty.
# A placeholder in square brackets does not start with a dash, so the CLI's arg
# loop would take it as the TARGET PATH instead of a flag.
GLOBAL_FLAG="" # --global
CACHE_FLAG="" # --no-exclude-cache
JSON_FLAG="" # --json
TELEMETRY_FLAG="" # --with-telemetry-recipe
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs "<path>" --output-file /tmp/config-audit-tokens.json $GLOBAL_FLAG $CACHE_FLAG $JSON_FLAG $TELEMETRY_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
```
`--json` and `--with-telemetry-recipe` must be threaded here, not just
documented: the CLI is what turns them on. `--json`/`--raw` make the payload
byte-stable v5.0.0 (the humanizer is skipped, `token-hotspots-cli.mjs:127`), and
`--with-telemetry-recipe` is what adds `telemetry_recipe_path`. `>/dev/null` is
required because those two modes also print the payload to stdout even with
`--output-file` set (`token-hotspots-cli.mjs:137`).
**Exit code handling:**
- `0` → continue
- `3` → tell user: "Couldn't analyse tokens. Check that the path exists and is a directory." Stop.
@ -53,14 +66,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file
### Step 3: If `--json` was requested, cat the file and stop
```bash
cat "$TMPFILE"
cat /tmp/config-audit-tokens.json
```
Do NOT render tables in JSON mode.
### Step 4: Read JSON and render
Use the Read tool on `$TMPFILE`. Extract:
Use the Read tool on `/tmp/config-audit-tokens.json`. Extract:
- `total_estimated_tokens` — top-line number
- `hotspots[]` — top 10 ranked sources; each carries a **load pattern** (`loadPattern` ∈ always / on-demand / external, plus `survivesCompaction` / `derivationConfidence`)
@ -109,7 +122,7 @@ _Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±20
### Step 5: Cleanup and next steps
```bash
rm -f "$TMPFILE"
rm -f /tmp/config-audit-tokens.json
```
```markdown

View file

@ -33,10 +33,14 @@ Split `$ARGUMENTS` into a path and flags. Path is the first non-flag argument. D
Tell the user: **"Reading active configuration for `<path>`..."**
```bash
TMPFILE="/tmp/ca-whats-active-$$.json"
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPFILE" [--verbose] [--suggest-disables] $RAW_FLAG 2>/dev/null; echo $?
# Set each to the flag itself when the user asked for it, otherwise leave empty.
# A placeholder in square brackets does not start with a dash, so the scanner's
# arg loop would take it as the TARGET PATH instead of a flag.
VERBOSE_FLAG="" # --verbose
SUGGEST_FLAG="" # --suggest-disables
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs "<path>" --output-file /tmp/config-audit-whats-active.json $VERBOSE_FLAG $SUGGEST_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
```
**Exit code handling:**
@ -46,14 +50,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPF
### Step 3: If `--json` was requested, cat the file and stop
```bash
cat "$TMPFILE"
cat /tmp/config-audit-whats-active.json
```
Do NOT render tables in JSON mode.
### Step 4: Read JSON and render
Use the Read tool on `$TMPFILE`. Extract:
Use the Read tool on `/tmp/config-audit-whats-active.json`. Extract:
- `meta.repoPath`, `meta.durationMs`, `meta.gitRoot`, `meta.projectKey`
- `totals.estimatedTokens.grandTotal` (and subtotals)
@ -90,7 +94,17 @@ Render as markdown:
|-------|--------|--------|
| {name} | {source}{if pluginName: ` (${pluginName})`} | ~{estimatedTokens} |
### MCP Servers ({mcpServers.length}, ~{mcpServers subtotal} tokens)
### Agents ({agents.length}, ~{agents subtotal} tokens)
| Agent | Source | Model | Effort | Tokens |
|-------|--------|-------|--------|--------|
| {name} | {source}{if pluginName: ` (${pluginName})`} | {model or "inherit"} | {effort or "session default"} | ~{estimatedTokens} |
Skip this section entirely when `agents` is empty. `model: null` is rendered as
*inherit* and `effort: null` as *session default* — both are the documented
defaults, so a blank cell would read as missing data rather than as the choice
it is. If no agent pins either column, say so in one sentence: every delegated
task then costs what the session costs.
| Server | Source | Status | Command |
|--------|--------|--------|---------|
@ -149,7 +163,7 @@ Do NOT suggest items you can't name concrete redundancy for. If you can't find 3
### Step 7: Cleanup and next steps
```bash
rm -f "$TMPFILE"
rm -f /tmp/config-audit-whats-active.json
```
```markdown

View file

@ -0,0 +1,400 @@
# Brief — Delete-and-Rebuild (config subtraction)
**Status:** BRIEF, not a plan. No code, no chunk breakdown, no version number committed.
Written 2026-07-29 so a session starting Thursday evening has a durable starting point.
STATE.md is gitignored in this repo, so this file — not STATE — is the record.
---
## 1. Trigger and its provenance
The operator relayed a third-party YouTube summary (Hyper Automation Labs) of a talk
Boris Cherny reportedly gave at Y Combinator Startup School, one day after Opus 5
shipped. Claims attributed to him in that summary:
- Anthropic deleted ~80 % of Claude Code's own system prompt when Opus 5 landed.
- Advice to users: every six months, delete your CLAUDE.md, your skills, your hooks —
see what the model does.
- Rebuild method: delete everything, use it, add one line back only when the model
stumbles on the same thing repeatedly.
- The model measured *slightly more intelligent* with the built-in prompts stripped.
**Two levels of confidence here, and they must not be collapsed** (updated 2026-07-29
after the operator corrected the first draft):
- **Attribution — confirmed.** The operator watched the recording and identifies Boris
Cherny on stage. This is not a channel's claim about who spoke; it is direct
observation by the operator. The talk happened and it is him.
- **The verbatim figures — still summary-level.** "80 % of the system prompt", "the
model measured slightly more intelligent without the prompts", the Bun numbers: these
reach us through the channel's editing, not through a primary transcript. They are
plausible and consistent with §2, and they are not quoted as fact anywhere below.
So if this becomes a `BP-*` entry in the best-practices register, the source field reads
*"Boris Cherny, YC Startup School talk (attribution confirmed by operator); figures via
third-party summary, not primary-verified"* — not "unverified", and not a bare citation
either. Getting a primary transcript for the figures is a nice-to-have, never a
prerequisite: the feature is argued from this repo's own logic (§3), and this repo has
one scar from treating a plausible quote as load-bearing fact («Fable low ≈ Opus high»,
fabricated, rejected 2026-07-14).
**What the operator has affirmed as in scope:** delete CLAUDE.md, then rebuild by adding
back what the model needs help with — *starting with what it must have*. That last clause
is not a detail; it is the design constraint in §6.0.
## 2. Verified ground truth (checked against the local CLI, 2026-07-29)
These *are* facts, and they are what make an empirical variant buildable:
| Fact | How verified |
|---|---|
| `claude --bare` exists — "Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets `CLAUDE_CODE_SIMPLE=1`." | `claude --help` |
| `claude --system-prompt <prompt>` — replaces the session system prompt | `claude --help` |
| `--append-system-prompt`, `--add-dir`, `--setting-sources <user,project,local>`, `--plugin-dir`, `--settings`, `--agents` — all present and all usable to compose an isolated config for a controlled run | `claude --help` |
So the `CLAUDE_CODE_SIMPLE=1` env var the video calls "undocumented" is, in this CLI
version, a documented flag (`--bare`). That matters: an A/B ablation harness would not
need an undocumented hook.
## 3. Why this fits this repo (the argument that does *not* depend on §1)
The plugin has three pillars — Health, Opportunities, Action. Every existing command
answers a question on the **addition** axis:
- `feature-gap` — what could you add?
- `optimize` — what would fit a better mechanism?
- `posture` / `tokens` / `manifest` — how good/expensive is what you have?
- `fix` / `implement` — apply changes.
**Nothing answers the subtraction question: what is no longer earning its rent?**
`feature-gap` has no inverse. That is a real hole, independent of who said what on
a stage.
Two further reasons this belongs *here* specifically:
1. **Nothing in the repo measures instruction age.** Verified by grep over `scanners/`:
`stale` appears only for knowledge-register entry age (`lib/knowledge-refresh.mjs`)
and for stale plugin-cache versions (`scan-orchestrator.mjs`, M-BUG-11). No scanner
touches `git blame`, `mtime`, or the vintage of a CLAUDE.md block. An instruction
written for Sonnet 3.5 and an instruction written last week are indistinguishable to
every current scanner.
2. **Deleting bravely is only sane if you can undo it.** That is already pillar three:
`lib/backup.mjs`, `rollback-engine.mjs`, `auto-backup-config.mjs` (PreToolUse), and
`drift`'s `saveBaseline` / `loadBaseline` / `diffEnvelopes`. The safety net exists;
the feature that would use it does not. This is arguably the strongest framing:
*config-audit is already the infrastructure that makes "delete it and see" a
measurement rather than a gamble.*
## 4. Reusable machinery (do not rebuild these)
| Need | Already exists |
|---|---|
| Snapshot config before deleting | `scanners/lib/baseline.mjs` (`saveBaseline`), used by `drift` |
| Diff before/after | `diffEnvelopes` in the same module |
| Backup + restore individual files w/ sha256 manifest | `scanners/lib/backup.mjs`, `scanners/rollback-engine.mjs` |
| Cost of each source, always-loaded subtotal | `scanners/manifest.mjs`, `token-hotspots.mjs` |
| Human-approved-writes command pattern | `knowledge-refresh`, `campaign` (both non-byte-stable by design) |
## 5. Candidate shapes (sketches — pick on Thursday, do not pre-commit)
All three inherit the floor constraint in §6.0: whatever the shape, load-bearing local
facts are never deletion candidates, and a rebuild restores them first. A shape that
cannot express that distinction is disqualified regardless of how cheap it is.
**A. Deterministic vintage scanner (`CA-VIN-*`).** Per instruction block in CLAUDE.md /
rules / skills / hooks: age from git history, plus a compensatory-phrasing signal
(blocks that exist to correct model behaviour — "ALWAYS", "never forget", "read the
whole file first", "don't guess"). Output: ranked deletion candidates with age + token
cost + why it looks compensatory. Cheapest, most testable, fits the existing scanner
architecture, and composes with `tokens`/`manifest` for the payoff figure.
> **🔴 The age half of this shape is DEAD — measured 2026-07-31 (økt #40), see §8's
> updated checklist.** Per-line `git blame` over four real instruction files gives
> single-date shares of 88 % / 55 % / 100 % / 58 %: instruction blocks trace back to bulk
> commits, so per-block age carries almost no information. Worse, `blame` reports
> *last touch*, not vintage — a reformatting commit (e.g. this repo's own `96e32df`,
> "trim project CLAUDE.md to invariants") makes old instructions look young, which biases
> the signal rather than merely thinning it. That also kills the `mtime` fallback.
> **§8 pre-registered this exact outcome and its consequence: shape A's primary signal
> collapses to the compensatory-phrasing heuristic alone, and §7.2 is answered — the
> classification needs the agent layer.** Do not resurrect age as a "weak secondary
> signal"; that is the drift the pre-registration existed to prevent. What survives of
> shape A is the *deterministic phrasing + token-cost pre-filter*, feeding a
> precision-gated judge (the `optimize` architecture).
**B. Protocol command (`/config-audit rebuild`).** The delete → live with it → earned
re-add ledger loop, spanning sessions: archive current config, record what was archived,
and maintain a ledger where a line only returns when the operator records that the model
actually stumbled on it. Highest fidelity to the source idea; needs cross-session state
(the `sessions/` machinery already exists) and is inherently not byte-stable.
**C. Measured ablation harness.** Use `--bare` + `--system-prompt`/`--add-dir` to run the
same prompt with and without a block and compare. Closest to a real verifier, most
expensive in tokens, weakest determinism. Probably a later `--experimental` sub-mode of
A or B rather than its own command.
Likely landing: **A first** (deterministic, testable, immediately useful), with B as the
workflow that consumes A's output. C stays a documented idea until A+B exist.
> **DECIDED 2026-07-31 (#40), superseding the sketch above — see §7 q2/q3.** Shape A does
> not ship as a standalone `CA-VIN-*` scanner: its age signal is dead, and what remains of
> it (a phrasing + token-cost pre-filter) is precisely the front half of `optimize`'s
> existing hybrid motor. **The landing is `/config-audit optimize --subtract`** — a fourth
> `lensCheck` class emitting `CA-OPT-*` findings, with a deterministic **floor-exclusion**
> step ahead of the judge so a load-bearing block is never a candidate. Shape B (the
> earned-re-add ledger) stays out of v1 precisely because it is what would demand
> cross-session state and therefore a command of its own. Shape C unchanged: documented,
> not built.
## 6. Hard constraints (carry these into any implementation)
### 6.0 The floor: compensatory vs load-bearing instructions
**This is the invariant all three shapes in §5 must respect, and it is what makes the
feature safe to ship at all.** Not every line in a CLAUDE.md is the same kind of thing:
| Class | What it is | Test | Disposition |
|---|---|---|---|
| **Compensatory** | An instruction correcting model *behaviour* — "read the whole file first", "don't guess", "ALWAYS verify", "think before you code" | A smarter model would do this unprompted | **Deletion candidate.** Returns only when earned. |
| **Load-bearing** | A *local fact* the model cannot derive at any capability level — "never GitHub, only Forgejo at git.fromaitochitta.com", "system bash is 3.2, no `declare -A`", "test with `node --test 'tests/**/*.test.mjs'`", "`~/.claude` is not git-tracked" | No amount of intelligence produces this from the codebase alone | **Floor. Never a deletion candidate.** |
Model capability erodes the first class and does nothing to the second. That is the whole
mechanism behind the source idea — and it means "delete your CLAUDE.md" is only correct
for one of the two classes. A tool that treats them alike would delete the operator's
Forgejo constraint because Opus 5 "is smart enough now", which is a category error: the
model isn't failing at intelligence there, it simply cannot know.
**Consequence for the rebuild ordering.** A rebuild is not one undifferentiated
add-back-on-stumble loop. It is three tiers:
1. **Floor — goes back immediately, no trial period.** Load-bearing facts. The config is
never in a state where these are absent; a "delete everything" that drops them is a
broken experiment, not a brave one.
2. **Earned — out, returns only on repeated observed stumbling.** Compensatory
instructions that turn out to still be needed by *this* model.
3. **Dead — out and never missed.** The payoff, measurable in tokens via `manifest`.
A third class sits deliberately outside the axis: **policy prohibitions** ("never commit
secrets"). These may well be things the model would honour unprompted, but their cost of
being wrong is asymmetric and they are cheap. They stay in the floor by decision, not by
classification. Do not let a "the model knows this now" argument reach them.
The hard part is tier 1 vs tier 2, and that classification — not the deletion mechanics —
is the real engineering problem in this brief. Precision is asymmetric: a missed dead line
costs a few tokens per turn; a deleted load-bearing line costs a wrong remote, a broken
bash script, or a lost afternoon.
### 6.1 Existing repo constraints
- **`~/.claude` IS git-tracked as of 2026-07-26 — but archive by `mv` into `_archive/`
anyway, never `rm`.** Premise corrected 2026-07-31 (økt #40); the rule it was used to
justify is unchanged. Ground truth: `/Users/ktg/.claude` is a git repo, 7 commits,
initial commit `13cd708` 2026-07-26, remote `backup
/Volumes/DharmaBackup/claude-config.git` (external volume — not Forgejo, not GitHub),
47 files tracked (`hooks/` 19, `commands/` 14, `scripts/` 9, `CLAUDE.md`,
`settings.json`, `learnings/`, `docs/`). The rule survives on different grounds: only
those 47 files are recoverable, the history is days old, and the remote lives on a
volume that may not be mounted. Two consequences for this feature — (a) `~/.claude`
age evidence is bounded by the repo's own birth date and is therefore worthless, and
(b) any untracked file there (`memory/`, `coord/`, session state) is still
unrecoverable after `rm`.
- **`settings.json` is pathguard-protected** — no Edit/Write. Use `jq` + temp file +
atomic `mv`, with operator OK ([[settings-json-pathguard-write]]).
- **New scanner ⇒ the 7-step byte-stability checklist** ([[adding-scanner-byte-stability]]):
scanner + orchestrator + scoring area-map + strip-added-scanner + humanizer count +
SC-5 + humanizer wiring (`SCANNER_TO_CATEGORY` entry and `TRANSLATIONS.static` per RAW
title — M-16/M-17). Frozen `tests/snapshots/v5.0.0/` must stay untouched.
- **TDD is inviolable** — red tests before implementation, including for .md contracts.
- **`feat:` commits require non-trivial diffs in both README.md and CLAUDE.md**
([[docs-gate-feat-requires-readme-claudemd]]).
## 7. Open questions for Thursday
> **STATUS 2026-07-31 (#40): q1, q2, q3 are CLOSED below — decided on measured evidence
> (dead age signal) plus the hand-built fasit (`docs/subtraction-fasit.local.md`), under
> the operator's standing delegation of technical/design calls. q4 was already closed
> 2026-07-29. Do not re-litigate without new evidence.**
>
> **The landing: `/config-audit optimize --subtract` — a fourth lens class in the existing
> hybrid motor, not a new command and not a new scanner.** Full rationale in q3.
1. Scope of the deletion candidate set: user-level `~/.claude` only, project only, or both?
(Age evidence is much stronger for project-level, per §5A.)
**CLOSED — both, and user-level is mandatory in v1.** The argument that pointed at
project-level was git-age evidence, and that evidence no longer exists (§5A box), so
scope now follows *payoff and testability* instead. Two things force user-level in:
§8's blocking floor test is defined over the operator's global CLAUDE.md, and that file
is where the always-loaded cost actually sits (~4 300 tok every turn, every repo,
every session — vs a project CLAUDE.md that loads only in its own repo). Project-level
comes along because the same motor reads it and because two of §8's four floor anchors
turned out to live there.
*Implementation note, not a reopening:* `optimize` today takes a repo `target`. Reading
`~/.claude/CLAUDE.md` is a target-resolution change, and per §6.1 the `~/.claude` write
rules apply — but this mode proposes, it never writes.
2. **The core question, given §6.0:** can compensatory-vs-load-bearing be classified
deterministically with acceptable precision, or does it need the agent layer (like
`optimize`'s precision-gated `optimization-lens-agent`, which stays silent when
unsure)? Note the two signals are independent — a load-bearing fact can be old, and a
compensatory instruction can be new — so age alone can never carry this call. Prior:
a deterministic pre-filter feeding a precision-gated judge, which is exactly the
`optimize` architecture already in the repo.
**Design the §8 fasit around the ambiguous middle, not the poles.** The four named
must-survive items are clear-cut and any mechanism will get them right; the gate is
really decided by blocks like "Conventional Commits: `type(scope): beskrivelse`"
(local convention, or a nag the model would follow anyway?), "commit ofte med
beskrivende meldinger" (pure behaviour correction?), or the model-routing rubric
(a table of local policy that reads like advice). Include 35 such blocks
deliberately. A fasit built only from obvious cases will pass a tool that fails on
real config.
**CLOSED — it cannot be done deterministically. Deterministic pre-filter → precision-
gated judge, which is the `optimize` architecture verbatim.** Two independent lines of
evidence, both gathered before any code:
- *The age signal is gone* (§5A box). It was the only deterministic input that was
going to do real discriminating work; phrasing heuristics alone are what remain.
- *The fasit shows phrasing alone is not enough.* The blocks that decide the gate all
require reading **content against container** — a load-bearing fact wearing generic
phrasing, or vice versa:
| Fasit block | The trap |
|---|---|
| B-27 (defensive shell-scripting) | Reads as universal advice ("quote your variables"), but each line records a *specific local incident*, and "keep test code ASCII-clean" is inseparable from bash 3.2 (B-26, a §8 floor anchor). **A phrasing regex deletes this and fails the gate.** |
| B-32b ("never work in another repo") | Sits inside a bullet list of compensatory anti-patterns, formatted identically to its neighbours, but encodes the polyrepo structure and names `coord-send`. Container says delete, content says floor. |
| B-05 vs B-06 | Adjacent preference bullets, near-identical form, opposite calls. |
| B-30a / B-30b / B-30c | Three consecutive bullets under one heading, three different calls (Conventional Commits is floor, "lesbarhet > cleverness" is not). |
No regex separates these; a judge reading them in context can.
- *Two failure modes the judge must be prompted against, both found in the fasit:*
**staleness is not a deletion signal** (B-29 pins Opus 4.8 while the session runs
Opus 5 — that is a `drift`/dead-reference finding about a **floor** block), and
**tier-2 is not tier-3** (B-22, premiss-verifisering, is compensatory by class yet
earned itself again during this very session).
**Design consequence — the floor is NOT the judge's call.** Load-bearing exclusion runs
as a deterministic pre-filter step *before* the judge sees anything, so a floor block is
never a candidate at all. §8's gate is blocking and precision is asymmetric; making it
depend on a probabilistic judge would be the wrong guarantee. The judge then decides
only compensatory-tier questions on the remainder, and stays silent when unsure exactly
as `optimization-lens-agent` already does.
3. Does this ship as its own command, or as a `--subtract` mode of `optimize`?
Both are defensible; command count is already 21.
**CLOSED — a `--subtract` mode of `optimize`.** The discriminator is whether v1 needs
cross-session state: it does not. v1 ranks deletion candidates and reports the payoff;
the earned-re-add **ledger** (shape B) is what would require `sessions/` state, and it
is deliberately not in v1. Without the ledger there is nothing a separate command buys.
What the mode inherits by not being new (verified against the code, 2026-07-31):
| Requirement | `optimize` already has it |
|---|---|
| Deterministic pre-filter → opus judge | `optimization-lens-scanner.mjs` (`lens-prefilter`) → `optimization-lens-agent` |
| Precision gate, silent when unsure | Stated in the agent's own prompt |
| "Not a mistake" framing | Every `optimize` finding is a *Missed opportunity* — exactly right for a line that works but no longer earns its rent |
| Register-backed provenance | `knowledge/best-practices.json`, CONFIRMED-only |
| Non-byte-stable by design | Already documented as such in CLAUDE.md |
| Finding IDs | `CA-OPT-*`**no new `CA-VIN-*` scanner** |
And what it avoids: the 7-step byte-stability checklist (§6.1), a scanners badge bump
16 → 17, snapshot risk against frozen `tests/snapshots/v5.0.0/`, and a 22nd command.
**Proportionality is the clinching argument.** The fasit puts the honest ceiling at
~8501 400 always-loaded tokens on a ~4 300-token file — real (~20 %), but nowhere near
the source anecdote's 80 %, and 26 of 34 blocks are floor. On a well-maintained config
the subtraction axis is mostly a no-op. That payoff justifies a mode on an existing
motor; it does not justify a new scanner plus a new command. ("Starte ambisiøse tiltak
når en konfig-justering holder" is a named anti-pattern.)
Registry shape: a 4th `lensCheck` class alongside the three in the agent's table, with
its own `BP-SUB-001` register rule. `--subtract` is the flag because the subtraction
axis should not fire on a plain `/config-audit optimize` run — it asks a different
question and needs the operator to have opted into it.
4. ~~Ordering against the existing queue.~~ **Decided 2026-07-29:** the operator
prioritized this work ahead of pipeline step 4 (`rollback`), to start Thursday
2026-07-30. Do not re-litigate. The open part is only what follows it — the
prior order stands underneath: step 4 `rollback`, then the M-11→M-20 batch
release, then the v5.13 plan.
## 8. Verifisering (testable criteria)
**Before building anything:**
- [x] **DONE 2026-07-31 (#40).** `grep -rniE 'blame|mtime|birthtime' scanners/` returns
zero hits → §3.1 confirmed still true at build time.
- [x] **DONE 2026-07-31 (#41).** `node scanners/drift-cli.mjs . --save --name pre-subtraction`
succeeds and `lib/baseline.mjs` round-trips → §4 reuse is real, not assumed.
Written envelope is `{meta, scanners[16], aggregate, _baseline}` with
`_baseline.target_path` = the repo (15 findings, score 55). Run with no flags
beyond `--save --name`, since M-BUG-21 makes an unknown flag's value silently
become the scan target.
- [x] **RESOLVED 2026-07-31 (#41) by not needing it.** `BP-SUB-001` was written
`confirmed`, and asserts **nothing** from §1 — no "80 %", no ablation figure, no
Cherny attribution. Its claim is grounded entirely in the Anthropic steering blog
already cited by BP-MECH-001004, re-verified the same day: *"Every line loads into
every session for every engineer working in the repo, whether it's relevant to their
task or not. This consumes tokens and dilutes adherence"*, *"Build commands,
directory layout, monorepo structure, coding conventions, and team norms all fit
naturally here"*, and *"Keep CLAUDE.md under 200 lines"*. The anecdote motivated the
feature; it is not a premise of the shipped rule.
- [x] **DONE 2026-07-31 (#40) — THE AGE SIGNAL DOES NOT EXIST. Collapse condition met.**
Per-line `git blame` (not `git log`; per-line is the question) over four real
instruction files:
| File | Lines | Largest single-date share |
|---|---|---|
| `~/.claude/CLAUDE.md` | 250 | **88 %** (2026-07-26 = repo init; max age 5 days) |
| `config-audit/CLAUDE.md` | 102 | **55 %** (2026-04-08) |
| `config-audit/.claude/rules/ux-rules.md` | 32 | **100 %** (one commit) |
| `llm-security/CLAUDE.md` | 110 | **58 %** (2026-04-08) |
Blocks trace back to bulk commits exactly as the pre-registration feared, and
`blame` measures last-touch rather than vintage (a reformat resets it), so the
signal is *biased*, not merely sparse. **Consequence, as pre-registered: shape A's
primary signal is gone and §7.2 is answered — deterministic phrasing/cost
pre-filter → precision-gated judge.** See the boxed note in §5A.
- [x] **DONE 2026-07-31 (#40): the §8 floor-test fasit is built, before any classifier
exists.** `docs/subtraction-fasit.local.md` (LOCAL-ONLY — it quotes the operator's
global CLAUDE.md verbatim and this repo's only remote is the public `open/` mirror).
34 blocks over `~/.claude/CLAUDE.md`, each labelled `FLOOR` / `POLICY-FLOOR` /
`DELETABLE`, with 13 marked ⚠ AMBIGUOUS per §7.2. Headline numbers: 26 of 34 blocks
are floor; the deletable set is ~1 400 always-loaded tokens, realistically ~850
after tier-2 earn-backs, against a ~4 300-token file (**~20 %, not 80 %**).
**Correction it forces:** two of §8's four named floor anchors are not in that file
at all — the test command is project-scope (`config-audit/CLAUDE.md`), and
"`~/.claude` is not git-tracked" is now false (§6.1). The floor gate must run over
a *set* of files, not one.
**When `optimize --subtract` is built** (was: "if shape A is built" — retitled 2026-07-31
per §7 q3; the two struck items below were premised on a new standalone scanner, which is
no longer the shape):
- [x] **DONE (#41).** Red tests written first against a synthesized fixture and confirmed
failing (module not found) before either module existed.
- [x] **DONE (#41).** Suite green at **1382/0** (was 1365).
- [x] **DONE (#41).** `git diff --stat tests/snapshots/v5.0.0/` empty, **and** a plain
`optimize-lens-cli.mjs` run diffed byte-identical against its pre-change output
(`--subtract` adds keys only when the flag is present; `LENS_DETECTORS` still
exposes exactly 3 detectors, asserted in the suite).
- [ ] ~~`node scanners/self-audit.mjs --check-readme` passes (badge counts updated:
scanners 16 → 17).~~ **No badge bump — no new scanner.** `--check-readme` must still
pass, and README/CLAUDE.md still need the docs-gate diffs for a `feat:` commit
([[docs-gate-feat-requires-readme-claudemd]]).
- [ ] Every new finding renders with a non-`Other` `userImpactCategory` and a
non-`_default` action language → humanizer wiring correct (M-16/M-17).
- [x] **PASSED 2026-07-31 (#41) — the blocking floor test.** Fasit built first (#40), tool
run after, and the comparison **machine-checked**: a script asserts the intersection
of the candidate list with every FLOOR / POLICY-FLOOR line range in the fasit is
empty. Result: **zero load-bearing blocks proposed for deletion.** Both §8 anchors
present in the subject file (B-25 "Aldri GitHub. Kun Forgejo", B-26 "System bash er
3.2") are excluded, as are B-09, B-13, B-17, B-23, B-27, B-29, B-32b and B-38b.
The first run found **five** violations the synthesized fixture missed; each was
fixed with a structural rule (list-stem merge, ordered-list-as-contract, security
terms, `unresolved-entity`, declarative guard) and a fixture shape added so it
cannot regress.
- [x] **MET (#41), with the number stated honestly.** 11 of 18 deletable groups surfaced,
≈756 always-loaded tokens ≈ **18 %** of the ~4 300-token file — inside the
pre-registered ~850 / ~20 % band. The 7 misses are the conservative default working
as intended (unresolvable entity names, code spans, and declarative/infinitive
phrasing carrying no imperative). Precision over recall held throughout: every fix
in this session traded recall away, never the gate.

View file

@ -0,0 +1,244 @@
# Q_AUDIT — the prose-invariant sweep (v6 quality plan §3)
**Session #68, 2026-08-12, Fable 5/xhigh (no advisor — every number below is
command-produced; the commands are in the appendix).** Q1 closed the write gate
in code, Q2 closed the argv contract in code. This sweep asked what ELSE is
enforced only by prose across the three surfaces the plan names: **21** command
templates, **7** agent prompts, **4** `.claude/rules/` files (4,950 lines), against
the **17** guard files that already exist under `tests/commands/` + `tests/agents/`.
Output is a rated list, not code. Nothing here was fixed in this session.
## 1. The taxonomy — what counts as a prose invariant
The open decision this session owned. A statement counts when all three hold:
1. **Something else relies on it** — another component (code, hook, agent,
downstream command) behaves correctly only while the statement is true.
2. **Its violation is silent** — nothing fails loudly when it stops holding.
3. **No code or test detects the violation.**
What deliberately does NOT count (the negative list matters — Q1's own lesson is
that a gate firing on legitimate writes gets switched off):
- **Judgment rubrics** given to agents (analyzer's 100-point CLAUDE.md rubric,
planner's risk formula) — prose is the *medium* of a judgment task, not a bug.
- **Narration/UX rules** (ux-rules.md) — degraded output, self-correcting.
- **Output budgets** ("MUST NOT exceed 300 lines") — worst case is a long report.
- **Harness facts** ("the Write tool requires a prior Read") — enforced upstream.
Marker-grep is a non-detector here: only **18** MUST/NEVER/ALWAYS-class markers
exist across all 28 command+agent files. The invariants are procedural steps
whose omission is silent, not shouted rules. They are found by reading, which is
why this was a session, not a script.
## 2. The class finding — the THIRD instance
Q1 was *gate-in-prose* (policy paraphrased in five templates). Q2 was
*argv-in-prose* (caller contract unchecked, 54 pairs). The third instance this
sweep asked for is:
**Class 3 — data-contract-in-prose: prose WRITES what code READS.**
Q2's mirror image. Templates instruct the model to hand-build files —
`manifest.yaml`, `state.yaml`, `scope.yaml`, register edits — that engines,
hooks, and later commands then parse. The writer side is a prose schema; the
reader side either trusts it or has quietly learned one measured variant of it.
The class has already bitten once: `parseManifest` grew its second format branch
*after* implement-produced backups made `restoreBackup` "a success-shaped no-op"
(comment in `scanners/lib/backup.mjs:172`).
Two further classes surfaced (the sweep found things it wasn't looking for,
again): **Class 4 — contracts an agent cannot honor** (instructions colliding
with harness behavior or the agent's own declared tools), and **Class 5 —
knowledge tables duplicated between prose and code**.
## 3. The rated list
Rated by what breaks if the invariant silently stops holding. R1/R2 outrank
everything because they sit on the *recovery* path — the surface that runs
exactly when the user is already in trouble, and the least-exercised one.
### R1 — the restore flow is model-executed prose; the code engine has no CLI entry
**CLOSED in #74** — `scanners/rollback-cli.mjs`. The measurement below is kept as written.
**Where:** `commands/rollback.md` §Implementation; `scanners/rollback-engine.mjs`.
**Measured:** 16 scanner CLIs carry a `process.argv` entry — `rollback-engine.mjs`
is not one of them. The template's "Implementation" shows an ESM `import` block a
command template cannot execute, then offers ad-hoc `cp` as the runnable
alternative. The engine's `restoreBackup()` verifies checksums before AND after
each write and returns `createdNotRemoved` — none of it reachable from the
command without `node -e`. The success template pre-renders "`(checksum
verified)`" — a claim the runnable path never establishes.
**What breaks:** the recovery path for every other write the plugin makes. A
half-restore or a stale-backup restore lands on user config at the worst
possible moment, reported as verified.
**Also unguardable as-is:** `command-cli-contract.test.mjs` probes CLIs; with no
CLI here, the whole Q2 guard class is structurally blind to this command.
**Guard shape:** a thin `rollback-cli.mjs` over `listBackups`/`restoreBackup`/
`deleteBackup`; template calls it like every other command; the contract test
then covers it for free. The "(checksum verified)" line becomes payload-driven.
### R2 — implement's backup manifest is hand-built prose; the parser knows one frozen sample of it
**CLOSED in #74** — the real fix, not the minimum: `implement.md` Step 3 calls `rollback-cli.mjs --create`, so the prose format has no author left. The measurement below is kept as written.
**Where:** `commands/implement.md` Step 3 (mkdir/cp + hand-written
`manifest.yaml` with sha256 lines); `scanners/lib/backup.mjs` `parseManifest`;
`tests/scanners/rollback-paths.test.mjs:157-186`.
**Measured:** the fix pipeline's backups go through code (`fix-engine.mjs:10`
imports `createBackup`); the implement pipeline's backup is template prose —
two copies of the backup policy, one per pipeline. `parseManifest`'s
implement-format branch exists because the seam already failed silently once.
The test fixture pinning that format is **hand-written**, not derived from the
template's own example block — the exact #63 defect shape ("a hand-typed call is
a path no user takes").
**What breaks:** implement.md's example drifts (a key renamed, quoting added) →
`parseManifest` finds 0 files → every implement backup is unrestorable while
`rollback` reports success. Second occurrence of a failure that already happened.
**Guard shape:** minimum — derive the parser fixture from `implement.md`'s own
fenced YAML (the Q2 extractor trick pointed at an example block). Real fix —
implement's Step 3 calls the same `createBackup` code path fix already uses
(via the R1 CLI), and the prose format dies entirely.
### R3 — optimize + feature-gap still instruct agents to write reports the harness blocks
**Where:** `commands/optimize.md:121-128`, `commands/feature-gap.md:123`;
agents `optimization-lens-agent.md` §Output, `feature-gap-agent.md` §Output.
**Measured:** both templates tell the agent to write `*-report.md` to the
session dir, then Read that file. The harness note measured on `analyze`
(M-BUG-18) blocks agent writes of exactly the report/findings file class;
`analyze` was converted to orchestrator-writes, these two arms were left open
(tracked in STATE as the open M-BUG-18 class — this rating is its severity call).
**What breaks:** the Read step fails or the model improvises a rescue; the
command's documented artifact (`optimization-lens-report.md`) may never exist.
User-visible flow breakage, no data corruption.
**Guard shape:** the `analyze` pattern, already proven: agent returns inline,
command persists; `analyze-report-persistence.test.mjs` is the template to copy.
### R4 — verifier-agent contradicts itself, and its "Read-Only Guarantee" is unenforced
**Where:** `agents/verifier-agent.md` — §Output Format says "Append to:
implementation-log.md"; §Read-Only Guarantee says "only uses Read, Glob, Grep /
never modifies any files"; frontmatter `tools:` lists no write tool.
**Measured:** `implement.md` Step 5 was already fixed to return-inline and
append orchestrator-side — so the agent's system prompt and the spawn prompt now
give OPPOSITE instructions to the same agent. Harness enforcement of `tools:`
is measured absent (memory: verifier/implement-log writes went through live).
**What breaks:** which instruction wins is nondeterministic; if the agent
improvises a write to satisfy its own §Output Format, a full-file Write on the
shared log clobbers parallel implementer entries — the precise defect
`implement-log-append.test.mjs` exists to prevent, entering through the file
that test does not read.
**Guard shape:** rewrite verifier-agent's Output section to return-inline (one
file), and extend `implement-log-append`/`agent-prompt-shape` to assert no agent
prompt instructs appending to the shared log. Cheap.
### R5 — session state (`state.yaml`, `scope.yaml`) is a model-written machine contract with no schema anywhere
**Where:** every phase template ("Write scope.yaml and state.yaml", "append —
never replace — completed_phases"), `.claude/rules/state-management.md`,
readers in `hooks/scripts/session-start.mjs` + `stop-session-reminder.mjs` +
every session-aware command.
**Measured:** the phase vocabulary (`discover``verify`) appears as a shared
constant in **zero** code files — it lives only in prose copies (status.md's
table, state-management.md, each template). Hooks parse with a line-grep
(`parseYamlValue`) and print whatever they find. The existing guard
(`command-shell-state-shape`: "phase commands name all four fields") checks the
template *text*, not the written *file*.
**What breaks:** resume-by-recency picks wrong sessions, status misnarrates,
session-start reminders go quiet — degradation, not corruption, but it erodes
exactly the "can resume if interrupted" promise the rule exists for.
**Guard shape:** either a state-write CLI (heavy) or a defensive reader: a lib
that validates phase tokens + required fields and *flags* malformed state, used
by hooks and dogfooded in a test. The plugin flags drift in everyone else's
config; its own session state deserves the same reader.
### R6 — both "Required Frontmatter" contracts are unguarded (currently compliant)
**Where:** `.claude/rules/agent-development.md`, `.claude/rules/command-development.md`.
**Measured:** no test outside fixtures matches `allowed-tools`;
`agent-prompt-shape` asserts only `name:` on **3 of 7** agents. Measured today:
7/7 agent frontmatters match the CLAUDE.md table; duplicate colors: **0**. So —
compliant, unwatched. Every MUST in those two rules is enforced by nothing.
**What breaks:** a new agent/command ships with missing `allowed-tools` or a
duplicate color; nothing fails; the rules files become fiction one file at a
time (the exemption-table lesson from Q1: what nothing declares, everyone
re-answers by reading).
**Guard shape:** near-free shape test walking `agents/*.md` + `commands/*.md`
asserting the two rules' required keys, name conventions, color uniqueness.
Note the irony budget: `plugin-health-scanner` already audits *other* plugins'
frontmatter — pointing it at its own repo in a test is the dogfood version.
### R7 — secret detection exists only as agent prose, in a domain STATE has parked elsewhere
**Where:** `agents/scanner-agent.md` §Secret Detection Patterns (xoxb/sk-/ghp_
regexes); `agents/verifier-agent.md` Check 7 ("Secrets Scan ✓").
**Measured:** `xoxb`/`ghp_` appear in **zero** files under `scanners/`;
`mcp-config-validator.mjs` contains the string "secret" **zero** times. The
deterministic pipeline has no secret scanning at all; the agent path claims it
in prose, and the verifier's report template renders "Secrets Scan ✓ Pass" as a
table row regardless. STATE parks secrets as the `llm-security` plugin's domain.
**What breaks:** a user reads "Secrets Scan ✓" as an executed check. The lie is
in the reporting, not in a missing feature — the feature is deliberately owned
elsewhere.
**Guard shape:** this is a *removal* candidate, not a gate (measurement can
decline the feature): strip the prose secret patterns + the verifier's Check 7,
say "secrets: out of scope, see llm-security" where the row used to be. If the
capability is ever wanted deterministically, it starts life as a scanner with a
finding code, not as agent prose.
### R8 — knowledge tables duplicated between prose and code
**Where/measured:** managed-path table — **three** copies (scanner-agent prose +
`file-discovery.mjs` + `active-config-reader.mjs`). Precedence — analyzer prose
("global beats managed (user preference)") vs `conflict-detector.mjs:138`
("local > project > user"): different vocabularies for the same claim, no link.
Optimization-lens agent's mechanism table restates register entries
(BP-MECH-001/002/004, BP-SUB-001) that live as data in
`knowledge/best-practices.json`.
**What breaks:** slow divergence — an agent narrates precedence or hierarchy the
deterministic scanners no longer implement. Confusing, not corrupting.
**Guard shape:** two-copies rule applies but *measure first* (#67): the two code
copies may legitimately differ; the prose copies should cite the code as owner
("hierarchy per `file-discovery.mjs`") rather than restate values.
### R9 — knowledge-refresh applies approved writes by model edit, validated only afterwards
**Where:** `commands/knowledge-refresh.md` Step 6 (model `Edit` of
`best-practices.json`, then run the schema test and revert on failure).
**Measured:** already tracked open in STATE ("knowledge-refresh skrive-CLI");
path anchoring is guarded (`knowledge-refresh-write-target.test.mjs`), the write
itself is not. The post-hoc validation step is real mitigation — this ranks
last *because* the failure is loud (a failing schema test in the same flow).
**Guard shape:** the campaign pattern, which this command's own sibling already
implements: every mutation a subcommand of a write-CLI. `campaign.md` is the
in-repo proof that "human-approved writes" and "CLI-executed writes" compose.
## 4. What this changes in the plan
- **Q3 (severity axis)** should carry R1/R2 as its first two rows — recovery-path
items never live in a backlog paragraph (plan §2 property 2).
- **Q4 (v6.0.0 release)** is NOT blocked by this list (the release gate is Q1 +
green suite); but R1+R2 are the strongest candidates for the first post-v6
chunk, as one chunk: a rollback/backup CLI closes both, and converts R2's
guard from "pin the prose format" to "delete the prose format".
- **R4 + R6** are lunch-sized; they can ride along with any adjacent session the
way M-BUG fixes have.
- **R7** is an operator decision (removing a claimed capability): propose, don't do.
## Appendix — measurement log
Every number above, and the command that produced it (run at `30c78ae`):
| # | Number | Command |
|---|--------|---------|
| 1 | 21 / 7 / 4 files | `ls commands/*.md \| wc -l` etc. |
| 2 | 4,950 lines | `wc -l commands/*.md agents/*.md .claude/rules/*.md` |
| 3 | 17 guard files | `find tests/commands tests/agents -name '*.test.mjs' \| wc -l` |
| 4 | 18 markers | `grep -cE '\b(MUST\|NEVER\|ALWAYS\|DO NOT\|…)\b' commands/*.md agents/*.md` |
| 5 | 16 CLIs, rollback-engine absent | `grep -ln "process.argv" scanners/*.mjs` |
| 6 | parseManifest dual-format + its cause | `sed -n '140,220p' scanners/lib/backup.mjs` (comment at :172) |
| 7 | hand-written fixture | `grep -n "sha256" tests/scanners/rollback-paths.test.mjs` (:159-186) |
| 8 | fix uses code backup | `grep -n "backup" scanners/fix-engine.mjs` (:10) |
| 9 | 0 secret patterns in code | `grep -rln "xoxb\|ghp_" scanners/` (empty); `grep -n "secret" scanners/mcp-config-validator.mjs` (empty) |
| 10 | 3 copies managed-path table | `grep -rln "Library/Application Support" scanners/` (2) + scanner-agent prose (1) |
| 11 | 0 phase-vocabulary constants | `grep -rn "'discover'" scanners/lib/*.mjs hooks/scripts/*.mjs` (empty) |
| 12 | 3/7 agents in shape guard | `AGENT_FILES` array read in `tests/agents/agent-prompt-shape.test.mjs` |
| 13 | 0 frontmatter guards | `grep -rln "allowed-tools" tests/` (fixtures + yaml-parser only) |
| 14 | 0 duplicate colors | `grep -h "^color:" agents/*.md \| sort \| uniq -d \| wc -l` |
| 15 | verifier self-contradiction | Read `agents/verifier-agent.md` (:143 append vs :242 read-only) |
Not covered by this sweep (deliberate): `knowledge/*.md` content freshness
(knowledge-refresh's domain), README/plugin.json surface (repo-standard's
domain), the deterministic scanners themselves (Q1/Q2 territory, already coded).

View file

@ -17,7 +17,7 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
| `mcp-config-validator.mjs` | MCP | Server types, env vars, unknown fields |
| `import-resolver.mjs` | IMP | Broken @imports, circular refs, deep chains, tilde paths |
| `conflict-detector.mjs` | CNF | Settings conflicts, permission contradictions, hook duplicates |
| `feature-gap-scanner.mjs` | GAP | 25 feature checks across 4 tiers — shown as opportunities, not grades |
| `feature-gap-scanner.mjs` | GAP | 24 feature checks across 4 tiers — shown as opportunities, not grades |
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascade, bloated SKILL.md descriptions, MCP tool-schema budget, MCP tool-schema deferral (CA-TOK-006), stale plugin-cache disk-cleanup (prompt-cache patterns) |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of CLAUDE.md cascade (beyond Pattern A's top-30 window); plus volatile content inside `@import`-ed files (v5.10 B6, one hop) |
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries (low). (1) Tools in BOTH `permissions.deny` AND `permissions.allow` — deny wins; dominance is param-aware and treats the `Tool(*)` deny-all glob as equivalent to a bare deny (covers a bare allow). (2) Unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — CC accepts allow globs only after a literal glob-free `mcp__<server>__` prefix. Predicates shared with CNF live in `lib/permission-rules.mjs` |
@ -44,6 +44,8 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
| `active-config-reader.mjs` | Read-only inventory: readActiveConfig(), detectGitRoot(), walkClaudeMdCascade(), readClaudeJsonProjectSlice() (longest-prefix match), enumeratePlugins(), enumerateSkills(), readActiveHooks(), readActiveMcpServers() (with cache → package.json tool-count fallback), estimateTokens() (v5: `'mcp'` kind = 500 + toolCount × 200) |
| `tokenizer-api.mjs` | Anthropic `count_tokens` wrapper for `--accurate-tokens` (v5 N5); 5s AbortController timeout, exponential 429 backoff, key masking |
| `humanizer.mjs` | Plain-language output translator (v5.1.0): `humanizeFinding`, `humanizeFindings`, `humanizeEnvelope`, `computeRelevanceContext`. Pure functions; never mutate inputs. Adds `userImpactCategory`, `userActionLanguage`, `relevanceContext` fields and replaces title/description/recommendation when a translation exists. Bypassed by `--raw` and `--json` paths. |
| `cli-args.mjs` | Argv precondition shared by the CLIs: `findArgError(args, spec)` / `requireValidArgs(args, spec)`. Rejects an unknown flag, and a value-taking flag whose next token is missing or is itself a flag — exit 3, never a verdict. Runs BEFORE each CLI's own parse loop, so valid argv reaches the existing parser unchanged (see Implementation notes → arg-sluk) |
| `require-target-dir.mjs` | Target-path precondition: a scan root that does not exist, or is not a directory, is exit 3 rather than a graded verdict (#56) |
| `humanizer-data.mjs` | TRANSLATIONS table for 16 scanner prefixes (CML/SET/HKV/RUL/MCP/IMP/CNF/COL/TOK/CPS/DIS/GAP/PLH/SKL/OST/OPT). Three-step lookup: exact title → regex pattern → `_default` → fall through to original |
## Action Engines (`scanners/`)
@ -219,6 +221,46 @@ returns ≥1 chatty hook — surfaces the documented **filter-before-Claude-read
grep ERROR and return only matches instead of a 10,000-line log). No chatty hook → silent (opportunity,
not noise — same contract as the cliOverMcp / bundledSkills levers).
### feature-gap — agent model/effort routing lever (v5.14 C4, `CA-GAP-028`)
`agentModelRoutingLeverFinding` fires only when the target has **authored** subagents (the same
`isAuthoredConfig` set the presence checks use, so plugin-bundled and fixture agents cannot make a
machine look routed — M-BUG-13) and **not one of them** names `model:` or `effort:`. Cites
`BP-MODEL-001` (a subagent's `model` defaults to `inherit`, so omitting it is a choice to pay the
session's rate) and `BP-MODEL-002` (effort is a separate axis with its own frontmatter field).
**Why a lever and not a 25th dimension — decided by measurement, not taste.** A dimension is always
evaluated, so "no agents at all" would have to read as *present*, and present weight feeds the
utilization score. Measured on `tests/fixtures/marketplace-medium` (hermetic HOME) before the change:
`utilization.score` 44, `segment` "Developing", where the "Competent" boundary is 45. As a t3
dimension the denominators move 41→42 and the vacuous present pushes 18/41→19/42 = **45** — flipping
`segment` in the frozen `v5.0.0/posture.json`, which `strip-retired-gap.mjs` does **not** mask (it
drops only `utilization.score`/`overhang` and `feature_coverage.score`). A lever leaves every
denominator alone and cannot move a score it never enters. The general rule now lives in CLAUDE.md
(*GAP dimensions vs. levers*).
**One check across both axes, not one per axis.** It fires only when *neither* axis is used anywhere,
so a deliberate everything-on-one-model policy stays silent. The cost is recall: a config that pins
`model:` everywhere but never `effort:` gets no nudge. That is the v1 boundary, chosen for precision.
**`model: inherit` is not routing** — found by dogfooding, where installed agents write it out
explicitly. `inherit` is the documented default, so spelling it out changes nothing about what the
agent costs; counting it as a pin would let a config opt out of the opportunity without changing
anything real. Effort has no documented sentinel of this kind, so it has no counterpart rule.
**Two silences that must not be conflated.** "No authored agents" (owned by dimension `t2_6`,
*No custom subagents*) and "the only agents on disk are plugin-bundled" produce the same quiet
output for different reasons. `tests/scanners/gap-agent-model-routing.test.mjs` P5 pins the second
one specifically — it asserts the agent file *was* discovered before asserting silence, so the arm
cannot pass for P4's reason.
**Humanizer coverage is now a blanket invariant.** The old guard asserted `TRANSLATIONS.GAP.static`
keys *equal* `GAP_CHECKS` titles, which forbade humanizing any lever — so all three existing levers
fell through to the generic GAP `_default` ("You have a feature opportunity worth a look"), wrong for
a budget lever. The guard now requires a static entry for **every title GAP can emit** (dimensions
levers), and it was seen red against those three before the four entries were written. `TITLE_TO_ID`
keeps strict equality with `GAP_CHECKS`: levers are not dimensions and must stay out of scoring.
### cache-prefix-scanner — @import extension (v5.10 B6)
CPS originally scanned only the files discovery classifies as `claude-md`. But a CLAUDE.md can pull
@ -254,6 +296,39 @@ model-switch is a *runtime* behaviour, not static config a scanner can reliably
automation. "No overstated behavioral finding ships" — so even the permitted opusplan *info*-advisory was
left out; the verified @import extension is the whole of B6.
### GAP scanner — authored-config scoping + direct cascade read (M-BUG-13)
The 25 presence checks ask "does the user's effective config have feature X?" and GAP **always**
runs `includeGlobal: true`. Two failure modes made the answer wrong on a real machine, both surfaced
by dogfooding `feature-gap`/`posture --global`:
1. **Demo/vendored config masks real gaps.** This plugin's own `examples/optimal-setup/` is a complete
config (sets `outputStyle`/`statusLine`/`worktree`/`model`/`keybindings.json`/`.lsp.json`), and its
copies vendored under `~/.claude/plugins/cache/.../config-audit/<ver>/examples/` are pulled into the
includeGlobal discovery. Because `anySettingsHas`/`files.some(...)` accept ANY discovered file, that
one demo file drove every tier-3 check to "present" → **GAP=0 on any target** (false negative).
Fix: `isAuthoredConfig` filters `ctx.files`/`parsedSettings` to the user's authored cascade —
excludes `~/.claude/plugins/` (absPath marker, mirrors CNF's M-BUG-2 exclusion) and any file whose
path **relative to the scan target** sits under `examples/` or `tests/fixtures/`. relPath (not
absPath) is deliberate: a fixture scanned AS the target keeps its own files, so the frozen v5.0.0
snapshots (scanned from `tests/fixtures/marketplace-medium`, which has no such nested trees) are
byte-stable.
2. **The real `~/.claude/settings.json` is invisible to the settings-key checks.** Discovery misses it
(its relPath carries no `.claude` segment when the walk root IS `~/.claude` — the gotcha) AND, when
vendored plugins flood the walk, the `maxFiles=2000` cap drops it. After (1) removed the demo
maskers, `statusLine`/`autoMode`/`permissions` (which the user HAS) would flip to false **positives**.
Fix: `readSettingsCascade` reads the four canonical cascade paths (user `settings.json`/`.local`,
project `settings.json`/`.local`) directly and merges them INTO `parsedSettings` — immune to the
cap and the gotcha. Merge (not replace) keeps non-canonical project settings and leaves the snapshot
(hermetic empty HOME → cascade adds nothing new) byte-stable.
Net: an empty target now surfaces ~18 humanized opportunities (was masked to ~0); config-audit's own
repo still shows 0 in output via its intentional `.config-audit-ignore` `CA-GAP-*` self-suppression
(a plugin repo legitimately lacks user-project features) — suppression is an envelope-layer concern,
orthogonal to this scanner fix. Scoped GAP-local; the includeGlobal discovery gotcha itself is left
to other consumers (see auto-memory `discovery-includeglobal-user-settings-gotcha`).
### CML scanner — context-window-scaled char budget
Beyond the line-count checks (200/500 lines, both MEDIUM), the CML scanner mirrors
@ -364,6 +439,43 @@ claimed CC "suggests the parent directory when an entry points at a file"; that
primary-source-verified conditions. `skills` is deliberately *not* in `SHADOWING_PATH_FIELDS`
(it adds to the default scan, never shadows).
### PLH scanner — `scanDetailed`, `--output-file`, and the marketplace.json exemption (økt #46)
`scan()` returns the **frozen v5.0.0 envelope** (`scanner, status, files_scanned, duration_ms,
findings, counts`) and nothing else — `--raw`/`--json` print it verbatim and are snapshot-gated. Two
things the `/config-audit plugin-health` report requires therefore cannot live there: one row per
plugin (the `| Plugin | Grade | Commands | Agents |` table) and the cross-plugin/per-plugin split.
Both were computed inside `scan()` and discarded at the return: `pluginResults` never escaped, and
the only grade code — `formatPluginHealthReport` — had no caller anywhere in the repo.
`scanDetailed(targetPath)` is the seam. It returns `{ result, plugins, crossPluginFindings }`;
`scan()` is now `(await scanDetailed(p)).result`, so the byte-stable envelope is unchanged by
construction. `plugins[]` carries `name, declaredName, path, commandCount, agentCount, findingCount`
plus `score`/`grade` from the shared `pluginGrade(issueCount)` helper (which
`formatPluginHealthReport` now also calls, so the formula has exactly one home).
Cross-plugin findings are identified **positionally**, not by predicate: `crossPluginStart =
allFindings.length` is taken immediately before the namespace/command-name sections, and the tail is
sliced off at the end. A predicate would have to key on `category: 'plugin-hygiene'`, which the
per-plugin shadow and skills findings share. The marker (`crossPlugin: true`) is stamped only on the
**humanized copies** in the `--output-file` payload — never inside `finding()`, which would add a key
to the frozen envelope.
`--output-file` follows the `drift-cli` contract: humanized payload in default mode, stdout
untouched. This matters because default mode writes its report to **stderr**, and ux-rules rule 2
requires the command to run under `2>/dev/null` — before this, `commands/plugin-health.md` (and both
optional scanner calls in `commands/posture.md`) captured zero bytes. Argument parsing uses the same
`BOOL_FLAGS`/`VALUE_FLAGS` + unknown-flag-throws shape as `drift-cli`/`fix-cli` (M-BUG-21, third
arm); here the swallowed-flag failure mode was *worse than an error* — scanning the dropped flag's
value found no plugins, so the scanner answered `No plugins found` (info) with exit `0`.
**`marketplace.json` exemption:** `.claude-plugin/`'s known-file set is `plugin.json` **and**
`marketplace.json`. The catalog's location is documented and required (*"Create
`.claude-plugin/marketplace.json` in your repository root"*), and a marketplace entry with
`"source": "./"` makes the repo root its own plugin — so one `.claude-plugin/` legitimately holds
both. Verified against the primary docs before the change; the check was a false positive, latent in
this marketplace only because `catalog/` ships no `plugin.json` and is thus not scanned as a plugin.
### SET scanner — autoMode validation (`CA-SET`)
Per-file check in `settings-validator.mjs` (`autoMode` was in `KNOWN_KEYS` but had no nested
@ -466,6 +578,56 @@ orchestrates pre-filter→agent→report. **Agent-driven → deliberately NOT by
outside the snapshot suite); the pre-filter lib *is* unit-tested (13 tests). No new orchestrated
scanner → scanner count stays 15; agents 6→7, commands 18→19, suite 1055→1068.
### Subtraction lens (`optimize --subtract`, `BP-SUB-001`) — the inverse axis
**Why a mode, not a command or scanner.** Every other command asks an addition question; nothing
asked what is no longer earning its rent. A hand-built ground truth over a real 250-line global
CLAUDE.md put the honest payoff at ~8501400 always-loaded tokens of ~4300 (~20 %, not the source
anecdote's 80 %), with 26 of 34 blocks load-bearing. That proportion justifies a fourth `lensCheck`
on the existing hybrid motor — not a new scanner (no badge bump 16→17, no frozen-snapshot risk) and
not a 22nd command. Cross-session state is what would have forced a command, and the earned-re-add
*ledger* is deliberately out of v1.
**Polarity is flipped from `lens-prefilter`.** That module is recall-first because a false candidate
only costs the judge a moment. Here a false candidate is a proposal to *delete*, so
`subtraction-prefilter.mjs` is precision-first and carries a blocking deterministic guarantee.
**Floor-exclusion is a separate module on purpose** (`lib/floor-exclusion.mjs`). §6.0's asymmetry —
a missed dead line costs a few tokens per turn, a deleted load-bearing line costs a wrong remote —
means the guarantee must not rest on a probabilistic judge, so the floor runs *before* the agent
and is legible as its own unit. Markers: code span, URL/host, filename, rooted path, version pin,
policy invariant, and `unresolved-entity` (a mixed-case capitalized word mid-sentence). The last is
a deliberate conservative default — resolving "Forgejo" from an ordinary capitalized word needs a
dictionary, so the mechanism declines and keeps the block.
**Granularity: leaf block + two structural exceptions.** (1) A paragraph ending in `:` merges with
the list it introduces — a stem often carries no literal of its own, and deleting it without its
list is meaningless. (2) An *ordered* list is a contract: steps inherit floor from any sibling,
because deleting step 2 of a five-step protocol is not like dropping one platitude. Unordered lists
do **not** inherit — a load-bearing bullet and a disposable one routinely share a list, and
container-reasoning is exactly the error the ground truth was built to catch.
**Three lessons from the dogfood run**, all invisible to the synthesized fixture and worth keeping:
- **JS `\b` is ASCII-only.** `/\bunngå\b/` never matches — the trailing `å` is not a word character,
so there is no boundary after it. Every Norwegian keyword ending in æ/ø/å was silently dead. Use
the `LB`/`RB` lookaround constants, never `\b`, around that vocabulary.
- **A bare `word/word` is not a path.** `pros/cons` vetoed the single largest deletable block until
`PATH_RE` was tightened to rooted paths and globs; real filenames are `FILENAME_RE`'s job.
- **"Mid-sentence" must key on a preceding lowercase letter**, not on "anything that is not a full
stop". The loose version read `**Bold labels:**` and quoted openers (`"Som AI kan jeg ikke…"`) as
entities and cost 4 of 11 deletable groups.
**Measured against the ground truth:** zero load-bearing blocks proposed (the blocking §8 gate),
11/18 deletable groups surfaced, ≈756 tok ≈ 18 % of the file — inside the pre-registered band. The
misses are all the conservative default working as designed (entity names, code spans, and
declarative/infinitive phrasing that carries no imperative). No new scanner: scanners stay 16,
commands 21, agents 7; suite 1365→1382.
**Note for a future narrowing of the veto:** the two failure modes the agent prompt hardens against
— staleness-is-not-deletion and tier-2-is-not-tier-3 — are currently *also* covered by exclusion
(both example blocks carry code spans/version pins and never reach the judge). The prompt language
is the only protection if those markers are ever loosened.
**Test-isolation fix (this session):** `token-hotspots.test.mjs` `runScanner` now wraps `scan()` in
the shared `withHermeticHome` helper — the suite is green on BOTH a real and a clean `HOME` (the OPT
section's old «run with clean HOME» caveat is resolved). Snapshot/byte tests were already hermetic.
@ -607,3 +769,107 @@ The second half of Block 4. **Asymmetric:** plan export is the new testable code
commands **21** (export is a *mode*, not a new command), snapshot/backcompat suite untouched.
suite 1150→1168 (lib +8, export-cli +10). **Block 4a (`migrateLedger`) still deferred** to the
first breaking schema change (export needs no schema bump).
### arg-sluk — the CLI argument class, measured across all fourteen CLIs (v5.14, #57)
`scanners/lib/cli-args.mjs`. Every CLI in `scanners/` parses argv with a chain of
`if (a === '--x') … else if …`, and two things fell through that chain in silence.
**Arm 1 — the unknown flag.** With no `else` branch, `--zzz` left no trace: exit 0, full
payload, a confident answer to a question the caller did not ask. First costed in #51, when
`knowledge-refresh`'s only knob reached the CLI malformed and the command reported "all 14
register entries were re-verified within the last 90 days" — about a threshold the user had
just overridden.
**Arm 2 — the value that was really a flag.** `a === '--output-file' && args[i + 1]` asks
only whether a next token *exists*, never whether it is a value. So `--output-file --json`
took `--json` as the filename. Measured: `manifest`, `campaign-cli` and
`knowledge-refresh-cli` each **wrote a file literally named `--json`** into the caller's
working directory, exit 0, with `--json` mode silently dropped. A wrong answer is bad; an
unintended file on disk is worse.
**Width — the deferral list was a prediction, not a measurement.** `KNOWN_OPEN` in
`tests/scanners/cli-unknown-flag-rejection.test.mjs` named **two** CLIs. Measuring all
fourteen found **7** open on arm 1 and **10** on arm 2 — including `campaign-cli` and
`knowledge-refresh-cli`, which were already in `GUARDED` and *passing* the arm-1 test while
arm 2 stood open a few lines away. Three CLIs (`drift-cli`, `fix-cli`,
`plugin-health-scanner`) were already correct on both arms because they use a different
parse form; they were moved into `GUARDED` rather than left unguarded.
**Why a gate and not a rewrite.** The module runs *before* each CLI's existing loop and does
not replace it. Valid argv therefore reaches the existing parser byte-for-byte unchanged, so
no frozen snapshot can move — the byte-stability argument is structural, not empirical. The
three CLIs that carried a bespoke `else if (a.startsWith('--')) fail(…)` branch had it removed
once the gate made it unreachable, along with the now-redundant `&& args[i + 1]` guards.
Exit code is **3** by the exit-code contract: a malformed argument means the scanner never got
to do its job, which is categorically different from 0/1/2 — verdicts about a configuration
that *was* examined.
---
### rollback-cli — the recovery path gets a runnable entry (R1+R2, #74)
`scanners/rollback-cli.mjs` is the entry to `rollback-engine.mjs` and, via
`--create`, to `lib/backup.mjs`. It closes the two KRITISK rows of the Q3
severity table as one chunk, because neither half holds alone.
**R1 — the restore was model prose over a code engine.** Measured at the head of
the chunk: 16 files under `scanners/` carried a `process.argv` entry;
`rollback-engine.mjs` was not one of them, though it had verified every file's
checksum *before and after* each write since M-BUG-22 and reported
`createdNotRemoved` since M-BUG-25. `commands/rollback.md` §Implementation
showed an ESM `import` block a command template cannot execute and offered
ad-hoc `cp` underneath as the runnable alternative, then pre-rendered
"`(checksum verified)`" three times in the success output. `cp` establishes no
checksum, so the verification was a property of the template, not of the run —
on the one surface that runs when the user is already in trouble.
**R2 — the backup format had two authors, one of them prose.** The fix pipeline
backed up through `createBackup`; the implement pipeline hand-built its own —
`mkdir`, `cp`, a `date +%Y%m%d_%H%M%S` id, and a manifest typed out in the
template. `parseManifest` grew a second branch for that format because the seam
had already failed silently once, and the fixture pinning it was **hand-written**
rather than derived from the template's own text: the #63 shape on the data
side. Rename a key in the template and `parseManifest` returns zero files while
`rollback` reports success.
**Why one chunk.** Fix only R1 and the new CLI still parses a prose format. Fix
only R2 and the format is clean with no runnable entry behind it.
**Exit contract.** 0 done · 1 outstanding (a restore the scope gate will not
perform without `--approve-scope`, nothing written; or a backup that covered
fewer targets than it was given) · 2 at least one file failed · 3 the CLI could
not do its job. A gated restore is **1, not 3**: "this write leaves your project"
is a verdict about a write that *was* examined, and it rides in the payload,
where a command running under `2>/dev/null` can act on it. That is F3's class,
avoided rather than repeated.
**`--created` records, it does not copy.** No backup can hold a file that does
not exist yet. Those paths go into the manifest so `rollback` can list what it is
leaving in place. `serializeManifest` emits the bare `created:` key; the pre-R2
implement format used `created: <timestamp>` with a VALUE, meaning the backup id,
and `parseManifest` tells them apart on exactly that — which is why the two never
collide in one file.
**The implement-format branch stays.** Nothing writes that shape any more, but
every backup implement made before this chunk is on disk in it and must remain
restorable — the same reasoning that keeps `getLegacyBackupDir()` readable. The
hand-written fixture in `tests/scanners/rollback-paths.test.mjs` therefore
changed meaning rather than becoming obsolete: it is now a golden sample of
historical bytes, which is a legitimate thing to write by hand, instead of a
stand-in for a template's own text, which is not.
**What replaced "(checksum verified)".** `tests/commands/backup-restore-contract.test.mjs`
runs the CLI and checks every field `rollback.md` renders against the real
payload. A renamed payload key now fails a test instead of turning into a
confident sentence. Seen red against its own defect by renaming `{status}`.
**A guard hole found by mutation.** The first version of the implement-side
assertion matched `--create` as a substring, and the same invocation carries
`--created` — so replacing the `--create` call with `--list` left the guard
green. It matches `--create(?![a-z])` now. Separately, mutating
`if (!requireValidArgs(...)) return;` into a bare call showed that
`requireValidArgs` sets exit 3 *by itself*: a CLI can report "I could not parse
my arguments" and still run the restore underneath. `rollback-cli.test.mjs`
asserts on the bytes for that case, not on the exit code.

View file

@ -0,0 +1,228 @@
# v5.14 Plan — Doctor Overlap, Model Routing, Effort Awareness, Dead References
> **Filename note:** kept as `v5.13-…` so existing references resolve; the target has been
> v5.14 since the `5.13.0` slot was consumed by the pipeline-hardening batch. Rewritten
> 2026-08-03 (session #53) to merge the `/doctor`-overlap decision (Oppgave A) and the
> context-engineering follow-ups (B1B4) from `docs/v5.14-doctor-overlap-brief.md` into the
> pre-existing chunks. One plan, top-to-bottom, no relitigation.
## Inputs and their status
| Input | Status |
|---|---|
| `/doctor`-overlap measurement (session #53, fasit-first) | **DONE** — decisions binding, recorded in `docs/doctor-overlap-results.local.md` |
| B1 register freshness defect (evidence-age / supersededBy / sources[]) | **DONE** — landed as `d66035e` (TDD, 1477/1477 green) |
| Video-derived model/effort chunks (verified 2026-07-14) | Open — carried below unchanged |
| Open posts from dogfood sessions #45#51 | Open — detail lives in `STATE.md`; referenced here by ID only |
## A. The `/doctor` verdict (measured 2026-08-03, CC 2.1.220 — binding)
`/doctor` (alias `/checkup`, v2.1.205+) is an **agent-driven, usage-data-backed, quota-priced,
non-reproducible** checkup: 10 checks incl. unused skills/plugins/MCP vs. context cost,
CLAUDE.md dedup/contradiction judgment, derivable-content trim (checked-in files only),
lazy-loading migration, hook latency, and a chars÷4 context manifest. It overlaps our
**judgment lenses and alarms — not the deterministic validators.** Full per-scanner table in
`docs/doctor-overlap-results.local.md`.
Binding outcomes (0 whole scanners removed; 2 measured function-duplicates removed;
5 surfaces repositioned):
| ID | Chunk | What |
|---|---|---|
| **D1** | GAP autoMode removal | Delete the «No autoMode classifier» gap dimension (`feature-gap-scanner.mjs:485`) — `/doctor` Sjekk 8 checks AND fixes it. Byte-stability: existing-scanner finding-removal variant of [[adding-scanner-byte-stability]]; humanizer entries for the removed title go too. |
| **D2** | SKL alarm re-scope | Remove CA-SKL-002's aggregate-over-budget **alarm** role (native startup warning v2.1.105/2.1.181 + `/doctor` Sjekk 6 both do it better-placed); re-position CA-SKL-001 as per-description **attribution** (author lens, repo scope); CA-SKL-003 untouched. Exact form (delete vs. re-scope 002) decided inside the chunk against the frozen-baseline cost. |
| **D3** | Positioning rewrite | **DONE in #53** — README section «config-audit vs. the built-in /doctor» (division-of-labor table + per-area split for SET/HKV/CNF/OPT/tokens/manifest, citing `/doctor`'s own referral to `optimize --subtract`) + CLAUDE.md invariant line. Remaining refinement (per-command copy in `commands/*.md`, if wanted) rides along with D1/D2. |
**Strategic line (constrains all copy):** our defensible identity = determinism/byte-stability,
all scopes incl. LOCAL, cross-repo campaign breadth, zero quota. `/doctor`'s unmatchable edge =
usage telemetry. Long-term (v5.15+ candidate, NOT this release): transcript/usage telemetry as a
scanner *input*, so the axes compose instead of competing.
## B. Context-engineering follow-ups (article 2026-07-24, brief §2)
- **B1 — DONE** (`d66035e`): `sources[]` + `published` + `supersededBy` + evidence-age rule
(green-but-outdated is now expressible and flagged; re-verifying the old source no longer
clears it). BP-SUB-001 carries the article as verified corroborating source.
**Deviation from brief, verified 2026-08-03:** the article contains NO mechanism-choice or
size-limit content → it was NOT added to BP-MECH-*/BP-SIZE-001 (that would be false
provenance). If a future read finds real coverage, add it then.
- **B2 — DONE, and the detector was DECLINED by measurement.** `BP-JUDG-001` ships as
register knowledge with `lensCheck: null`; **no `CA-OPT-002`** (OPT next-free stays 2).
Measured over **409 real CLAUDE.md files** (38 488 lines, 8 689 prose blocks): the caging
class fires **7 times, all 7 false positives**, confirmed along an independent grep path in
both word orders (5 lines / 1 line, none an instruction). Where the shape *does* occur — 45
lines across 4 755 skill/agent/command files — it is the author's **editorial policy** (emoji,
sentence length, slide titles), and nothing in the text separates that from a vendor's
over-tight guardrail: the article's reasoning does not transfer, because the model is not the
author of a user's config. Precision-first ⇒ silence. Two premises the chunk falsified: the
brief's «these blocks are inside the floor» (the article's own example carries **no** floor
marker; the corpus tendency is 76 %, which is not a mechanism), and the §4 form-noun
vocabulary (`name`/`format` alone were 97 % of fires). Full record:
`docs/b2-judgment-lens-fasit.local.md` §9.
- **B3 — two-layer duplication/contradiction (CNF extension):** article rule 4 + `/doctor`'s
measured Sjekk 2 catch (global CLAUDE.md model-policy ↔ agent frontmatter) prove the class
exists and is catchable. Extend `conflict-detector` with cross-layer checks where the pair is
structurable (CLAUDE.md/rule, rule/skill-description, CLAUDE.md-keyword ↔ frontmatter field).
This **supersedes the old "Explicitly rejected #5"** below — it now has evidence.
- **B4 — thinner gaps (backlog, after everything above):** rule 2 (skills/agents leaning on
examples where a parameter enum would do) and rule 6 (reference *form*: prefer in-code files;
`import-resolver` is the natural owner). Park until D/B2/B3 land.
- **Rules 3 and 5 need nothing** (verified in brief §B4): progressive disclosure = BP-LOAD-001..006
+ BP-MECH-003 + `token-hotspots`/`manifest`; router pattern + auto-memory covered.
## C. Carried chunks (verified 2026-07-14 — unchanged specs)
Source-verification table, rejected-claims list, and full chunk specs below are carried
verbatim from the 2026-07-14 revision; only numbering context changed.
### C1 — Register entries: model routing + effort (dogfoods `knowledge-refresh`)
- **BP-MODEL-001** (`model-fit`): mechanical/read-only subagents can pin a cheaper model via
`model:` frontmatter; orchestrator keeps the strong model. Source: code.claude.com/docs/en/sub-agents
`confirmed`.
- **BP-MODEL-002** (`model-fit`): reasoning effort tunable at five levels in five places;
default `high`; higher is not universally better. Source: code.claude.com/docs/en/model-config
`confirmed`.
- Schema per `scanners/lib/best-practices-register.mjs:42-102`. New sources SHOULD carry
`published` so the B1 evidence-age rule has teeth.
### C2 — fix-engine effort hygiene (tiny, TDD)
`fix-engine.mjs:26` `VALID_EFFORT_LEVELS` missing `xhigh` → nearest-match "fix" for `xhig`
corrects to `high`. Red test first: `findNearestEffortLevel('xhig') === 'xhigh'`. Align with
`settings-validator.mjs:75`.
### C3 — CA-CML dead prose references (new deterministic check)
Flag backtick-quoted relative file paths in CLAUDE.md prose that do not exist on disk (today
only `@import` targets are checked). Conservative v1: skip URLs, globs, placeholders,
absolute/`~/` paths. Severity low. New CA-CML-NNN (verify next free NNN at implementation).
Byte-stability per [[adding-scanner-byte-stability]] incl. humanizer step 7.
### C4 — feature-gap + inventory: model/effort awareness
New T3 opportunity check: authored agents where NO agent sets `model:`/`effort:` → routing
opportunity citing BP-MODEL-001/002. Fires only when authored agents exist; opportunity
framing, suppressable. `whats-active`/`manifest` surface `model`/`effort` per agent.
Humanizer step 7; verify via direct `scan()` ([[agent-commands-need-scanner-scoping]]).
Known tension with the operator's own Opus-for-everything policy stands as written 2026-07-14:
the check serves general users; on this machine it gets suppressed.
### C5 — planner-agent adversarial gate (AFTER DEL B 3.2 dogfood)
Required "Failure modes" section in `agents/planner-agent.md`'s action-plan contract.
Sequencing: only after the DEL B fasit pass that judges planner-agent, or the fasit target
moves mid-evaluation.
## D. Open posts from dogfooding (detail in STATE.md — not restated here)
C-SKL1 (#37) · M-BUG-26 · **M-BUG-28** (suppression-ID positional instability — ID-semantics
change touching all scanners + frozen snapshots, own chunk) · **M-BUG-41** (no scope-gate from
scan to write, two arms — design change, own chunk) · **arg-sluk CLI arm**
(`optimize-lens-cli` + `token-hotspots-cli`, `KNOWN_OPEN` in
`tests/scanners/cli-unknown-flag-rejection.test.mjs`) · **P6/M-BUG-44** (scanner-side stdout
with `--output-file`) · knowledge-refresh write-CLI · cleanup-invisible session files ·
web-poll candidates (nothing written; primary sources unread).
## Priority order for v5.14
Cheap-and-loud first, judgment-heavy later; D-chunks early because they DELETE code the rest
must not build on. **Re-ordered 2026-08-10 (operator decision, session #61):** the operator wants
to *use* the subtraction axis, so its write half — and the scope-gate it depends on — move ahead
of the remaining additive work. Everything below step 4 is unchanged in content, only in position.
1. ~~**C2**~~ ✅ · 2. ~~**Arg-sluk CLI arm**~~ ✅ · 3. ~~**D1 + D2**~~ ✅ · 4. **D3** (rest dropped,
`stop-at-meaningful-value`) · 5. ~~**M-BUG-28**~~ ✅ · 6. ~~**C1**~~ ✅ · 7a. ~~**C4**~~
8. **M-BUG-41** (scope-gate design) — **promoted from 10.** Prerequisite for anything that writes
outside the repo the session stands in, which subtraction-write does by definition
(`~/.claude/CLAUDE.md`).
9. **SUB-WRITE** (new) — the write half of `optimize --subtract`; see §C6 below.
10. **C3** (CA-CML dead prose references) — was 7b.
11. **B2** (new lens axis)
12. **B3** (CNF two-layer extension)
13. **P6/M-BUG-44**, knowledge-refresh write-CLI, cleanup glob (small batch)
14. **C5** (after DEL B 3.2), **B4** backlog last
15. Release-cut via `release-plugin.mjs` when the batch is coherent. **Level is MAJOR — v6.0.0:**
M-BUG-28 shipped as `fix(scanners)!` with a `BREAKING CHANGE:` footer, which outranks the
minor the D1/D2 removals plus C3/C4 additions would have implied. `release-plugin.mjs` does
not derive the level — pass `--version` explicitly.
### C6 — SUB-WRITE: the write half of `optimize --subtract`
`--subtract` proposes and never writes (`commands/optimize.md`), which is correct for a v1 whose
judge is an agent. The operator now wants the removal executed. Scope: apply an approved
subtraction candidate to the CLAUDE.md it came from, with backup and rollback.
Non-negotiable frames, all inherited rather than invented here:
- **The floor is not the judge's decision.** `floor-exclusion.mjs` runs deterministically before
anything is proposed, and that ordering must not migrate into the write path either.
- **`~/.claude` is git-tracked with a `.gitignore` of `*`** — archive by `mv` into `_archive/`,
never `rm`. Machine-side config writes need operator approval.
- **User level is mandatory in v1**: that is where the cost is (~4 300 tokens every turn in every
repo). Project level follows.
- **Honest sizing:** the #40 fasit measured deletable ≈1 400 always-loaded tokens, realistically
≈850 after tier-2 earn-backs, against a ≈4 300-token file — **≈20 %, not 80 %.** Do not let the
command's copy imply more.
- Verify the backup covers the file the write actually touched, not merely that a backup exists
(M-BUG-31's shape).
Open decision, to be settled in the chunk's fasit before code: whether removal is a `fix`-engine
action, a `plan`/`implement` step, or its own flag — decided against M-BUG-41's gate, not before it.
**Rejected 2026-08-10, do not revive:** a sibling `/repo-reinit` skill that rewrites a CLAUDE.md
from scratch. It would be a third implementation of one judgement (this axis, plus `/doctor`
Check 3) and fails the binding `/doctor` positioning; and regenerating destroys exactly the floor
— local facts, gotchas, policy invariants — that a mature repo's CLAUDE.md is most valuable for.
`repo-init` already owns the fresh-repo case.
## Source verification (done 2026-07-14 — carried)
| Claim from video | Verdict | Source |
|---|---|---|
| Orchestrator + cheaper worker models is supported/recommended | VERIFIED | code.claude.com/docs/en/sub-agents, /workflows |
| Effort tunable per settings/session/launch/agent-frontmatter/SDK; `low..max` | VERIFIED | code.claude.com/docs/en/model-config#adjust-effort-level |
| Leaked Fable 5 system-prompt principles | VERIFIED near-verbatim, provenance unconfirmed | github.com/asgeirtj/system_prompts_leaks |
| "Fable low ≈ Opus high" chart | **CONTRADICTED** | anthropic.com/news/claude-fable-5-mythos-5 |
## Explicitly rejected (unchanged unless noted)
1. "Fable low ≈ Opus high" framing — never encode.
2. Tool-call-count effort scaling as register entry — unconfirmed leak, not carried.
3. Cost/intelligence/"taste" routing-table generator — subjective, doesn't fit provenance-gated design.
4. "Fable mode" skill — out of plugin scope.
5. ~~CLAUDE.md prose contradiction detection~~**superseded by B3** (2026-08-03: article
rule 4 + `/doctor` Sjekk 2 measurement supplied the evidence the 2026-07-14 rejection lacked).
## Verification (per chunk, unchanged discipline)
- Full suite green (`node --test 'tests/**/*.test.mjs'`; baseline 2026-08-03: 1477/0), frozen
`tests/snapshots/v5.0.0/` untouched (`git status --porcelain` empty), red test before every
production change.
- **D1:** GAP fixture with autoMode absent → no finding; humanizer has no orphaned entries
(M-16/M-17 checks reversed for removal); frozen baselines untouched or consciously re-seeded
per [[adding-scanner-byte-stability]].
- **D2:** over-budget fixture → no 002-alarm (or re-scoped payload per in-chunk decision);
001 fires per oversized description with attribution copy; 003 unchanged.
- **D3:** README/CLAUDE.md name `/doctor` explicitly; `self-audit --check-readme` PASS.
- **C1C5:** criteria as specified 2026-07-14 (C2 red-first nearest-match; C3 fixture
missing-path fires / URL-glob-placeholder silent; C4 authored-agent matrix; C5 failure-modes
section present).
- **B2 (superseded by the measurement above):** the criterion was written for a detector that
measurement declined. What was verified instead: `BP-JUDG-001` present, `confirmed`, primary
source dated `2026-07-24`, `lensCheck` **absent** — plus a guard that every `lensCheck` in the
register is backed by a real detector, so no later session can "complete" the entry by wiring
one. Both assertions seen RED against their own defect before landing.
- **B3:** fixture with same instruction in CLAUDE.md + rule → CNF finding; single-layer only →
silent ([[guard-can-be-green-on-its-own-defect]]: assert the blanket invariant).
## Key assumptions (test at implementation)
1. Per-agent `effort` frontmatter still official — re-fetch sub-agents + model-config pages.
2. Finding-type REMOVAL in an existing scanner leaves frozen v5.0.0 snapshots untouched only if
no frozen fixture carries the type — verify per D1/D2 before committing; re-seed consciously
if not.
3. Next free CA-CML/CA-GAP/CA-OPT NNN — grep tests + snapshots before assigning.
4. `/doctor`'s check set is version-fluid (2.1.205→220 changed it materially) — re-run the
overlap measurement cheaply (CLI + one in-session run) before executing D1D3 if CC has
moved significantly past 2.1.220.

View file

@ -0,0 +1,136 @@
# Brief — `/doctor`-overlapp og ny kontekst-doktrine (v5.14-inngang)
**Skrevet:** 2026-08-03, økt #52 (Opus 5/high)
**Skrevet for:** neste økt — **Fable 5 / high** (operatørens modellvalg; rubrikken ga `Opus 5/high`, `rule=path=partial`)
**Status ved overlevering:** ingenting implementert. Denne økten leverte kun analyse + denne briefen.
---
## 0. Hva denne økten gjorde (så du slipper å gjenta det)
Fant og leste originalartikkelen bak videoen operatøren limte inn:
> **«The new rules of context engineering for Claude 5 generation models»**
> Thariq Shihipar, Member of Technical Staff, Anthropic — **24. juli 2026**
> https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models
Verifisert direkte mot artikkelen (`WebFetch`, to pass). Tre ting videoen tok feil på — ikke gjenta dem:
1. Forfatteren heter **Thariq Shihipar**, ikke «Tariq».
2. **Artikkelen sier ingenting om tokens, kostnad, caching eller måling.** Videoens token-budsjett-argument er skaperens påbygg. Det er riktig, men det er ikke Anthropics påstand — og det betyr at vi **ikke kan sitere artikkelen som hjemmel** for token-argumenter i registeret (Verifiseringsplikt).
3. **Videoens `/doctor`-liste (fem ting) står ikke i artikkelen.** Artikkelen har én setning: *«We rolled out a new command called `claude doctor,` which will help you do this automatically.»* Alt annet i videoen er skaperens observasjon fra sin egen kjøring. **Behandle den som uverifisert.**
Målt ground truth (CC **2.1.220**, denne maskinen):
```
$ claude doctor --help
Check the health of your Claude Code installation. Reads settings files in the
current directory without a trust prompt. For a full checkup that can also fix
issues, run /doctor in a session.
```
**CLI-en `claude doctor` = kun install-helse. Det er `/doctor` i sesjon som overlapper oss** («a full checkup that can also fix issues»). Ikke bland dem.
---
## 1. OPPGAVE A — mål `/doctor`, og fjern det vi dupliserer
**Dette er økten sin hovedoppgave, og den har en beslutning i seg som skal tas, ikke utsettes.**
### Premisset som skal falsifiseres
Anthropic leverer nå en innebygd, gratis kommando som gjør noe av det config-audit finnes for. Vi vet ikke hvor mye. Vi har aldri målt det. **Hver funksjon i config-audit som `/doctor` gjør like godt eller bedre, skal ut** — ikke omdøpes, ikke «beholdes for kompletthet», ikke pakkes inn i en flagg. Ut.
Begrunnelsen er pluginens egen doktrine, snudd mot oss selv: `BP-SUB-001` sier at en blokk som ikke lenger tjener sin plass skal vurderes fjernet. En scanner som duplikerer en innebygd kommando tjener ikke sin plass — den koster vedlikehold, tester, byte-stabile baselines og operatørens oppmerksomhet, for et svar hen kan få gratis.
### Metode (samme mal som de ti dogfood-chunkene — fasit FØR kjøring)
1. **Skriv `docs/doctor-overlap-fasit.local.md` FØR du kjører noe.** Nummererte prediksjoner: for hver av våre 16 scannere, forutsi om `/doctor` dekker den (JA / DELVIS / NEI) og hvorfor. Eksplisitte avkreftelser: hvilke scannere du er *sikker* på at `/doctor` ikke rører. Forpliktende breddetall: «jeg forventer at N av 16 overlapper». **Aldri rediger fasiten for å matche utfallet** — [[judge-the-judge-build-fasit-first]].
2. **Kjør `/doctor` i en sesjon** og fang hele outputen. Merk: `/doctor` kan *fikse* ting — kjør den der en utilsiktet fiks er billig, og les hva den foreslår før du godtar noe.
3. **Kjør `claude doctor` (CLI) også**, separat, så de to ikke smelter sammen i notatene.
4. **Sammenlign mot vår faktiske inventar** — ikke mot README-ens beskrivelse av den. Kildene er `scanners/*.mjs` (16) og CA-ID-rommet.
5. **Premiss-verifiser fasiten mot utfallet**, og rapporter avviket før du handler.
### Beslutningen som skal tas i denne økten
For hver scanner/kommando, ett av tre utfall — skrevet ned med begrunnelse:
| Utfall | Betyr | Handling |
|---|---|---|
| **FJERNES** | `/doctor` gjør dette like godt eller bedre | Egen v5.14-chunk: slett scanner + tester + CA-ID + README/CLAUDE.md-rader. Frosne baselines må re-seedes bevisst. |
| **BEHOLDES, SKJERPES** | Vi gjør noe `/doctor` ikke gjør, men README selger det ikke slik | Omskriv posisjoneringen så forskjellen er eksplisitt |
| **BEHOLDES** | Ingen overlapp | Ingen handling |
### Hypotesen min (skriv din egen fasit først — les denne etterpå)
Der jeg tror vi faktisk skiller oss, som *bør* overleve målingen:
determinisme + byte-stabile baselines, `drift` mot lagret baseline, suppressions med revisjonsspor, backup/rollback, `campaign` på tvers av repo, `plugin-health` + polyrepo-katalog, og det provenansstemplede registeret med `confidence`-nivåer. Der jeg tror overlappet er reelt: deler av `posture`, og deler av `feature-gap`s t1-nivå.
**Men dette er en hypotese fra en økt som ikke har kjørt `/doctor`. Den er ikke fasit, og den skal ikke få lov til å ankre din.**
### Utfall som ikke er lov
- «Vi beholder alt, men dokumenterer forskjellen bedre.» Det er svaret man gir når man ikke vil måle.
- Å utsette fjerningen til «senere». Beslutningen tas her; *utførelsen* er en egen chunk (byte-stabilitet + frosne baselines gjør sletting til flerfilsarbeid).
---
## 2. OPPGAVE B — oppdater de videre planene
Når oppgave A har landet en beslutning, skal planverket reflektere den. **Ikke før** — rekkefølgen er poenget, ellers planlegger vi rundt et overlapp vi ikke har målt.
### B1. Registeret (`knowledge/best-practices.json`) — høyest verdi, lavest risiko
To ting, og de er forskjellige:
**(a) Provenans.** Alle `BP-MECH-*`, `BP-SIZE-001` og `BP-SUB-001` siterer «Steering Claude Code»-bloggen. Den nye artikkelen bekrefter og forsterker dem — legg den til som kilde. Særlig `BP-SUB-001`, som nå har nesten ordrett dekning: *«briefly describe what your repo is for, but spend most of the tokens on gotchas inside of the codebase»* og *«Avoid stating 'the obvious' things Claude should know by looking at your file system or your repo»*.
**(b) En defekt i vår egen ferskhetsgaranti — dette er det viktigste funnet.**
`BP-SUB-001` er stemplet `verified: 2026-07-31`. Artikkelen kom **24. juli**. Vi re-verifiserte altså den *gamle* kilden en uke etter at den nye lå ute, og fanget den ikke.
Årsaken er strukturell: `knowledge-refresh-cli.mjs` gjør kun `assessFreshness` — den **aldrer eksisterende oppføringer etter dato**. Den har ingen måte å uttrykke «en ny kilde har supersedert en gammel». **En oppføring kan derfor være grønn og substansielt foreldet samtidig.** Det er en garanti vi gir som ikke holder.
Fiksen er en datamodell-endring (`sources[]` og/eller `supersededBy`) + en freshness-regel som ser på kildens alder, ikke bare oppføringens. Dette er den eneste posten her som er nær-ren TDD og har sterk verifikasjon.
### B2. Ny lens-akse for artikkelens **regel 1** (skjønn over regler)
`BP-SUB-001` fanger blokker som *gjentar generell ingeniør-atferd*. Artikkelens regel 1 er en **annen defektklasse**: instruksjoner som er lokale og spesifikke, men som **overspesifiserer og burer skjønnet**. Anthropics eget eksempel var ikke redundant — «never write multi-paragraph docstrings, one short line max» er presis og lokal. Den ble slettet fordi den *begrenset* en modell som nå dømmer bedre selv.
**Viktig, og lett å gjøre feil:** dette kan ikke bli en utvidelse av `--subtract`. `scanners/lib/floor-exclusion.mjs` beskytter «policy invariants» og «local facts» fra å bli slettekandidater — nettopp kategorien artikkelen sier ofte er for stram. Gulvet gjør riktig jobb for `--subtract`; regel 1 trenger sin **egen akse**: *behold innholdet, løsne formuleringen*, ikke *fjern blokka*. Å blande dem er ÅS#5-defektklassen om igjen (to akser presset inn i ett vokabular).
Foreslått: `BP-JUDG-001` + `CA-OPT-002`, med samme presisjonsgate som `optimization-lens-agent` allerede har (siter regel + kilde, ti stille når usikker).
### B3. Regel 4 — vi måler feil akse
Vi har duplikatdeteksjon (`claude-md-linter` 3+ repetisjon, `conflict-detector` hook-duplikater, `CA-TOK-002` permissions). **Alt er innenfor ett lag.** Artikkelens regel 4 handler om samme instruksjon i **to lag** — hos oss: CLAUDE.md *og* en rule, rule *og* en skill-beskrivelse, CLAUDE.md *og* en agent-prompt. Sannsynligvis en utvidelse av `conflict-detector` (den kjenner allerede flere lag), ikke en ny scanner.
### B4. Regel 2 og 6 — ekte hull, men tynnere
- **Regel 2** (eksempler → grensesnitt): ingen scanner ser på om en skill/agent lener seg på eksempler der en parameter-enum ville gjort jobben. `skill-listing-scanner` teller tegn, ikke form.
- **Regel 6** (rike referanser): *«prefer files that are in code as it provides clear, high-fidelity instructions»*, *«a HTML mockup of a design will generally produce better results than a description or screenshot»*. Vi har ingen oppfatning om referanse-*form*. Som eier av `@`-referanser (`import-resolver.mjs`) er vi det naturlige stedet.
**Regel 3 og 5 krever ingenting.** Progressiv avdekking *er* `BP-LOAD-001..006` + `BP-MECH-003` + `token-hotspots` + `manifest`; router-mønsteret dekkes av `t2_2`/`t2_3`; auto-memory av `t2_4`. Bekreftet, ikke endret.
### B5. Skriv om `docs/v5.13-model-routing-effort-deadref-plan.md` (v5.14-planen)
Den bærer i dag: C-SKL1 (#37), M-BUG-26, M-BUG-28, M-BUG-41 (to armer), arg-sluk-klassens CLI-arm (`optimize-lens-cli` + `token-hotspots-cli`), P6/M-BUG-44 scanner-siden. **Alt dette står fortsatt.** Oppgave A og B1B4 skal flettes inn og prioriteres mot det — ikke legges oppå som en parallell plan. Detaljene på de åpne postene ligger i `STATE.md`; ikke gjenskap dem her.
---
## 3. Rekkefølge
1. Fasit for `/doctor`-overlappet → kjør → premiss-verifiser → **beslutning per scanner**
2. B1 registerfiksen (TDD, sterk verifikasjon — den eneste posten her som har det)
3. B5 omskriving av v5.14-planen med A + B1B4 innflettet
4. Oppdater `STATE.md` + `README`/`CLAUDE.md`-posisjonering mot `/doctor`
**Scope-grense:** ingen sletting av scannere i denne økten. Beslutningen tas og skrives ned; utførelsen er egne chunks, fordi frosne baselines og byte-stabilitet gjør sletting til flerfilsarbeid med egen verifikasjon.
---
## 4. Om modellvalget
Rubrikken ga `Opus 5/high` (rad 3, `rule=path=partial`). Operatøren valgte **Fable 5** for denne økten. Konsekvenser å være klar over:
- **Ingen advisor.** Fable godtar kun Fable-advisor, og Fable er ikke valgbar som advisor i CC 2.1.220. Fallback-raden (`Sonnet 5/xhigh --advisor opus`) er derfor *ikke* tilgjengelig som billigere utvei i denne økten.
- `route-last` for neste økt skal registrere `model=Fable 5; effort=high` + om oppgave A faktisk ble lukket. Det er dataene som avgjør om Fable-radene i rubrikken noen gang blir levende policy.

96
docs/v6-quality-plan.md Normal file
View file

@ -0,0 +1,96 @@
# v6 quality plan — from "green suite" to A+
**Written 2026-08-12 (session #65), after a gate defect surfaced by accident rather than by
process.** This plan is about the *process* that missed it, not about the one defect.
## 0. Root cause — and why a bigger model is not the fix
The defect (`fix-engine.mjs` writes files without ever consulting the scope gate) was **already
written down**. It sat in `STATE.md`'s ÅPNE POSTER paragraph, mid-sentence, between "P6/M-BUG-44"
and "M-BUG-26":
> *scope-gaten for de FEM andre armene er fortsatt prosa-kontrakt — `fix-engine` leser ikke `gate`*
It was read at session start and not acted on. **A stronger model reading the same paragraph
reaches the same conclusion**, because the paragraph gives it no reason to: an unenforced safety
gate is formatted identically to "4 inline copies of a target guard" and "cleanup of invisible
session files". The tracking system has **no severity axis**, so nothing in the data says one of
these can let a write reach `~/.claude/CLAUDE.md` unapproved and the others cannot.
That is the finding. Model choice does not fix a missing severity axis.
**Second occurrence, same class.** #63 found a gate silently not firing because the *command
layer* was untested (`--repo <scan-target>` dropped the gate to `silent`, 29 removals, no
approval asked). #65 finds a gate not firing because the *engine* never reads it. Two instances
of "only prose stood behind a write gate" is a class, not luck ([[defect-found-in-one-file-is-a-class]]).
## 1. The class, measured
| Question | Measured 2026-08-12 |
|---|---|
| Files in `scanners/` that write to disk | **9** |
| …that import the scope gate | **1** (`lib/subtraction-write.mjs`) |
| Command templates | **21** |
| …that name a write action | **17** |
| …that invoke `write-scope-cli.mjs` | **5** |
**The 8 ungated writers are not 8 bugs.** `write-output.mjs`, `backup.mjs`, `baseline.mjs`,
`scan-orchestrator.mjs` write plugin-managed artefacts and are legitimately exempt. The defect is
that **nothing declares which**: "does this write path need the gate?" is answered by reading
code, never by a guard. That is precisely what let `fix-engine` sit unguarded next to
`subtraction-write`, which does it right.
## 2. What A+ means here, concretely
Not "more care". Three falsifiable properties:
1. **No invariant is enforced only by prose.** Every contract a command template states about a
write, a gate, or a scope is asserted by a test that fails when the code stops honouring it.
2. **Every open item carries a severity and a consequence sentence.** "What breaks if this stays
open" is written next to it, and anything touching a write, a gate, or user-scope config never
lives in the backlog paragraph.
3. **Shipped ≠ committed.** Work that is not released is not quality: the machine runs the
released plugin, so 29 unreleased commits are 29 fixes nobody has.
## 3. Chunks, in order
### Q1 — the gate moves from prose into code (BLOCKS the release)
`fix-engine` calls `classifyWriteTarget` + `strongestGate`, exactly as `subtraction-write` already
does — share the constant, do not copy it ([[two copies of one table drift]]). Add an **explicit
exemption table** naming every plugin-managed writer and *why* it is exempt.
**Verify:** a guard that walks `scanners/` for write calls and fails on any writer that neither
imports the gate nor appears in the exemption table. Seen RED against today's tree first.
### Q2 — the command layer gets contract tests
The 17 templates that name a write are today verified by nothing. Build the argv **from the
template's own text** ([[dogfood-the-command-not-the-cli]]) and assert: the command a template
tells the agent to run parses, targets the file the gate classified, and calls the gate before
any write.
**Verify:** delete the `write-scope-cli` line from one template → its test goes red.
### Q_AUDIT — one Fable session: find the rest of the class
A cross-cutting sweep for other invariants that exist only in prose (agent prompts, command
templates, `.claude/rules/`), each rated by what breaks if it silently stops holding. This is
review/big-picture work — Fable's documented form strength and a **first choice**, not a fallback.
Output: a rated list, not code. A Fable session runs **without advisor**.
### Q3 — severity axis in the tracking (cheap, rides along)
`STATE.md` open items become a table with `severity` + consequence. Rule: safety/write/user-scope
items are never in the backlog paragraph. This is the fix for the actual root cause.
### Q4 — release v6.0.0
29 commits, 21 of them `feat`/`fix`, including breaking ID semantics (`7a794b4`). Only after Q1.
Gate: `self-audit --check-readme` + full suite + `check-versions.mjs` 0 ERROR.
### Then B3 (two-layer CNF), as planned.
## 4. Model routing for this plan
| Chunk | Model | Why |
|---|---|---|
| Q1, Q2, Q3 | **Opus 5 / high** | implementation with strong verification (tests fail loudly) |
| Q_AUDIT | **Fable 5 / xhigh** | cross-cutting review + planning; deliberate override of the rubric, recorded in STATE as an override, no advisor |
| Q4 release | Opus 5 / high | mechanical but one-way (a pushed tag) |
**Not a blanket model upgrade.** Escalating every session to compensate for a missing guard is the
expensive way to not fix the guard.

View file

@ -6,7 +6,11 @@ import { readdirSync, readFileSync, existsSync } from 'fs';
import { join, basename } from 'path';
import { homedir } from 'os';
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
// Canonical location since v2.2.0. The pre-v2.2.0 path is kept as a fallback so
// sessions created before the move are still detected (see commands/cleanup.md).
const canonicalSessionsDir = join(homedir(), '.claude', 'config-audit', 'sessions');
const legacySessionsDir = join(homedir(), '.config-audit', 'sessions');
const sessionsDir = existsSync(canonicalSessionsDir) ? canonicalSessionsDir : legacySessionsDir;
if (!existsSync(sessionsDir)) {
process.exit(0);

View file

@ -6,7 +6,11 @@ import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
import { join, basename, dirname } from 'path';
import { homedir } from 'os';
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
// Canonical location since v2.2.0. The pre-v2.2.0 path is kept as a fallback so
// sessions created before the move are still detected (see commands/cleanup.md).
const canonicalSessionsDir = join(homedir(), '.claude', 'config-audit', 'sessions');
const legacySessionsDir = join(homedir(), '.config-audit', 'sessions');
const sessionsDir = existsSync(canonicalSessionsDir) ? canonicalSessionsDir : legacySessionsDir;
if (!existsSync(sessionsDir)) {
console.log('{}');

View file

@ -1,6 +1,6 @@
{
"version": 1,
"note": "Machine-readable best-practices register. SOURCE OF TRUTH for the optimization lens (v5.7 CA-OPT). Human-readable mirror lives in knowledge/*.md. Every entry is provenance-stamped (source.url + source.verified) and carries a confidence; only CONFIRMED claims are consumed user-facing (Verifiseringsplikt). Curated manually + by /config-audit knowledge-refresh (human-approved). Seeded from docs/v5.5-steering-model-plan.md V-rows + the Anthropic 'Steering Claude Code' blog.",
"note": "Machine-readable best-practices register. SOURCE OF TRUTH for the optimization lens (v5.7 CA-OPT). Human-readable mirror lives in knowledge/*.md. Every entry is provenance-stamped (source.url + source.verified; optional corroborating sources[] with published dates feed the evidence-age freshness rule; source.supersededBy marks a source replaced by a newer one) and carries a confidence; only CONFIRMED claims are consumed user-facing (Verifiseringsplikt). Curated manually + by /config-audit knowledge-refresh (human-approved). Seeded from docs/v5.5-steering-model-plan.md V-rows + the Anthropic 'Steering Claude Code' blog.",
"entries": [
{
"id": "BP-MECH-001",
@ -12,7 +12,11 @@
"severity": "low",
"category": "mechanism-fit",
"lensCheck": "claude-md-lifecycle-phrasing",
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
"source": {
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
"verified": "2026-06-20"
}
},
{
"id": "BP-MECH-002",
@ -24,7 +28,11 @@
"severity": "low",
"category": "mechanism-fit",
"lensCheck": "unscoped-path-specific-instruction",
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
"source": {
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
"verified": "2026-06-20"
}
},
{
"id": "BP-MECH-003",
@ -36,7 +44,11 @@
"severity": "low",
"category": "mechanism-fit",
"lensCheck": "procedure-in-claude-md",
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
"source": {
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
"verified": "2026-06-20"
}
},
{
"id": "BP-MECH-004",
@ -48,7 +60,11 @@
"severity": "low",
"category": "mechanism-fit",
"lensCheck": "never-instruction",
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
"source": {
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
"verified": "2026-06-20"
}
},
{
"id": "BP-MECH-005",
@ -60,7 +76,11 @@
"severity": "medium",
"category": "mechanism-fit",
"lensCheck": "CA-OST-001",
"source": { "url": "https://code.claude.com/docs/en/output-styles", "title": "Output styles", "verified": "2026-06-20" }
"source": {
"url": "https://code.claude.com/docs/en/output-styles",
"title": "Output styles",
"verified": "2026-06-20"
}
},
{
"id": "BP-LOAD-001",
@ -69,7 +89,11 @@
"confidence": "confirmed",
"category": "loading-model",
"lensCheck": null,
"source": { "url": "https://code.claude.com/docs/en/context-window", "title": "Context window — what survives compaction", "verified": "2026-06-20" }
"source": {
"url": "https://code.claude.com/docs/en/context-window",
"title": "Context window — what survives compaction",
"verified": "2026-06-20"
}
},
{
"id": "BP-LOAD-002",
@ -78,7 +102,11 @@
"confidence": "confirmed",
"category": "loading-model",
"lensCheck": null,
"source": { "url": "https://code.claude.com/docs/en/memory", "title": "Memory — path-specific rules", "verified": "2026-06-20" }
"source": {
"url": "https://code.claude.com/docs/en/memory",
"title": "Memory — path-specific rules",
"verified": "2026-06-20"
}
},
{
"id": "BP-LOAD-003",
@ -87,7 +115,11 @@
"confidence": "confirmed",
"category": "loading-model",
"lensCheck": null,
"source": { "url": "https://code.claude.com/docs/en/context-window", "title": "Context window — what survives compaction", "verified": "2026-06-20" }
"source": {
"url": "https://code.claude.com/docs/en/context-window",
"title": "Context window — what survives compaction",
"verified": "2026-06-20"
}
},
{
"id": "BP-LOAD-004",
@ -96,7 +128,11 @@
"confidence": "confirmed",
"category": "loading-model",
"lensCheck": null,
"source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" }
"source": {
"url": "https://code.claude.com/docs/en/skills",
"title": "Skills",
"verified": "2026-06-20"
}
},
{
"id": "BP-LOAD-005",
@ -105,7 +141,11 @@
"confidence": "confirmed",
"category": "loading-model",
"lensCheck": null,
"source": { "url": "https://code.claude.com/docs/en/hooks", "title": "Hooks", "verified": "2026-06-20" }
"source": {
"url": "https://code.claude.com/docs/en/hooks",
"title": "Hooks",
"verified": "2026-06-20"
}
},
{
"id": "BP-LOAD-006",
@ -114,7 +154,11 @@
"confidence": "confirmed",
"category": "loading-model",
"lensCheck": null,
"source": { "url": "https://code.claude.com/docs/en/sub-agents", "title": "Subagents", "verified": "2026-06-20" }
"source": {
"url": "https://code.claude.com/docs/en/sub-agents",
"title": "Subagents",
"verified": "2026-06-20"
}
},
{
"id": "BP-SIZE-001",
@ -125,7 +169,11 @@
"severity": "medium",
"category": "size-budget",
"lensCheck": "CA-CML-001",
"source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" }
"source": {
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
"verified": "2026-06-20"
}
},
{
"id": "BP-SIZE-002",
@ -135,7 +183,120 @@
"severity": "low",
"category": "size-budget",
"lensCheck": "CA-SKL-002",
"source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" }
"source": {
"url": "https://code.claude.com/docs/en/skills",
"title": "Skills",
"verified": "2026-06-20"
}
},
{
"id": "BP-SUB-001",
"claim": "Every line of CLAUDE.md loads into every session whether or not it is relevant, which consumes tokens and dilutes adherence. A line that states a local fact the model cannot derive (build commands, directory layout, conventions, team norms) earns that cost; a line that only restates general engineering behaviour pays it without being the kind of content CLAUDE.md is for, and is a candidate for removal.",
"mechanism": "deletion",
"appliesTo": "claude-md",
"recommendation": "Review the block for removal, then re-add it only if the model actually stumbles on it repeatedly. Local facts (remotes, versions, paths, conventions) and policy invariants are the floor and are never removal candidates.",
"confidence": "confirmed",
"severity": "low",
"category": "subtraction",
"lensCheck": "compensatory-instruction",
"source": {
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
"verified": "2026-07-31"
},
"sources": [
{
"url": "https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models",
"title": "The new rules of context engineering for Claude 5 generation models",
"published": "2026-07-24",
"verified": "2026-08-03",
"note": "Near-verbatim coverage: 'briefly describe what your repo is for, but spend most of the tokens on gotchas inside of the codebase'; 'Avoid stating the obvious things Claude should know by looking at your file system or your repo.'"
}
]
},
{
"id": "BP-MODEL-001",
"claim": "A subagent's `model` frontmatter field defaults to `inherit`, so a subagent that names no model runs on the main conversation's model. Routing mechanical or read-only subagents to a cheaper alias (`haiku`, `sonnet`) while the orchestrating session keeps the stronger model is the documented way to control cost. The pin is not absolute: Claude Code resolves the model as CLAUDE_CODE_SUBAGENT_MODEL, then a per-invocation `model` parameter, then the frontmatter, then the main conversation's model.",
"mechanism": "model",
"appliesTo": "agent",
"recommendation": "Set `model:` explicitly on subagents whose work is mechanical or read-only (search, extraction, summarisation) and leave the orchestrator on the stronger model. Omitting the field is not a neutral default — it inherits, so every subagent costs what the session costs.",
"confidence": "confirmed",
"severity": "low",
"category": "model-fit",
"lensCheck": null,
"source": {
"url": "https://code.claude.com/docs/en/sub-agents",
"title": "Create custom subagents — supported frontmatter fields / choose a model",
"verified": "2026-08-10"
},
"sources": [
{
"url": "https://claude.com/blog/claude-model-and-effort-level-in-claude-code",
"title": "Choosing a Claude model and effort level in Claude Code",
"published": "2026-07-07",
"verified": "2026-08-10",
"note": "Verbatim: 'Pick a smaller model when the work is routine. For example, edits you can describe precisely, mechanical changes, or questions about code that's already in context.'"
}
]
},
{
"id": "BP-MODEL-002",
"claim": "Reasoning effort is an axis separate from model choice: five levels (`low`, `medium`, `high`, `xhigh`, `max`) on current models, four on Opus 4.6 and Sonnet 4.6, which omit `xhigh`; the default is `high` on every model that supports effort except Opus 4.7, which defaults to `xhigh`. Higher is not universally better — `max` \"can improve performance on demanding tasks but may show diminishing returns and is prone to overthinking\". Effort is settable in six places: `/effort`, the slider in `/model`, the `--effort` flag, CLAUDE_CODE_EFFORT_LEVEL, `effortLevel` in settings, and `effort:` in skill or subagent frontmatter; the environment variable takes precedence over all of them.",
"mechanism": "effort",
"appliesTo": "agent",
"recommendation": "Treat effort as a per-task dial rather than a global maximum: pin a lower `effort:` in the frontmatter of mechanical skills and subagents, and reserve `xhigh`/`max` for work whose product is judgment. The scale is calibrated per model, so the same level name is not the same amount of thinking across models — and CLAUDE_CODE_EFFORT_LEVEL silently overrides every other source, so verify which level is actually in force.",
"confidence": "confirmed",
"severity": "low",
"category": "model-fit",
"lensCheck": null,
"source": {
"url": "https://code.claude.com/docs/en/model-config",
"title": "Model configuration — adjust effort level / set the effort level",
"verified": "2026-08-10"
},
"sources": [
{
"url": "https://claude.com/blog/claude-model-and-effort-level-in-claude-code",
"title": "Choosing a Claude model and effort level in Claude Code",
"published": "2026-07-07",
"verified": "2026-08-10",
"note": "Verbatim: 'Claude will be more predisposed to double-checking additional hypotheses or verifying correctness at higher effort levels, but it generally won't artificially inflate usage for simple tasks at higher effort levels.'; 'In fact, our team pays close attention to \"overthinking\" during model training as it degrades effectiveness.'"
}
]
},
{
"id": "BP-JUDG-001",
"claim": "An instruction that fixes a form decision — how code or prose should look (comment density, docstring length, naming shape, sentence or paragraph count) — as an absolute rule buys a guardrail current models no longer need, and is wrong for the cases the rule did not anticipate. Anthropic removed its own example from the Claude Code system prompt: \"Never write multi-paragraph docstrings or multi-line comment blocks — one short line max\" was replaced by \"Write code that reads like the surrounding code: match its comment density, naming, and idiom.\"",
"appliesTo": "claude-md",
"recommendation": "Where an absolute governs a form decision rather than a local fact, state the outcome you want and let the model judge the instance. This does not apply to safety rules, tool or version facts, or a house style you hold deliberately — those are the reason the claim is not machine-checkable.",
"confidence": "confirmed",
"category": "judgment-fit",
"lensCheck": null,
"note": "KNOWLEDGE ONLY — no detector, by measurement (docs/b2-judgment-lens-fasit.local.md §9). Across 409 real CLAUDE.md files (38488 lines, 8689 prose blocks) the class fired 7 times and all 7 were false positives; an independent grep in both word orders found 5 lines and 1 line respectively, none an instruction. Where the shape does occur — 45 matching lines across 4755 skill/agent/command files — it is the author's editorial policy (emoji, sentence length, slide titles), which nothing in the text separates from a vendor's over-tight guardrail. Precision-first: no CA-OPT code was allocated and OPT next-free stays 2.",
"source": {
"url": "https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models",
"title": "The new rules of context engineering for Claude 5 generation models",
"published": "2026-07-24",
"verified": "2026-08-12"
}
},
{
"id": "BP-PROMPT-001",
"claim": "Claude Opus 5 catches and fixes its own mistakes without being told to, and over-verifies when explicitly instructed to double-check or to delegate verification to a subagent — this adds token cost without improving output quality. The source states both halves: \"Avoid instructing re-checks it already performs ('double-check your answer,' 're-verify before responding')\" and \"If your prompt contains explicit verification instructions ('include a final verification step for any non-trivial task,' 'use a subagent to verify'), remove them\".",
"mechanism": "deletion",
"appliesTo": "claude-md",
"recommendation": "Remove explicit self-verification or delegate-to-subagent-for-verification instructions when targeting Claude Opus 5; re-add only if the model actually stumbles. The claim is model-scoped, not universal — a config that runs a different model in a different session still needs them.",
"confidence": "confirmed",
"severity": "low",
"category": "prompting-fit",
"modelScope": ["opus-5"],
"lensCheck": null,
"note": "lensCheck deliberately null, same discipline as BP-JUDG-001: this is a knowledge-cited ANNOTATION on an existing BP-SUB-001 candidate (scanners/lib/prompting-model-scope.mjs), gated behind an explicit --for-model flag, not a second competing detector. It never widens the candidate set. Auto-detection is not viable — a CLAUDE.md carries no frontmatter and no statically-resolvable target model. source.published is absent because the page carries no visible publish or last-updated date (re-checked 2026-08-12); both quoted sentences were verified verbatim on that date. MEASURED 2026-08-12 across 409 real CLAUDE.md files: 392 BP-SUB-001 candidates, 31 of them (7.9%) carry a verify verb, and 0 also carry a reflexive or delegated target — so the annotation fires 0 times on this corpus. Two independent raw-text greps (reflexive phrasings; subagent-near-verify in both word orders) also found 0, so the zero is the corpus, not an over-narrow regex. The TARGET requirement is what earns its place: without it the same 31 verb-only blocks — 'sjekk relevante config-filer', 'Type-sjekk: pyright', 'To verify plugin functionality' — would all have been tagged, which is the BP-JUDG-001 failure mode (7/7 false positives) arriving one lens over. Precision on the corpus is 0 wrong out of 31 chances to be wrong; recall is untested there because the class is absent — the detector's true positives are the source doc's own example phrasings, pinned in tests/lib/prompting-model-scope.test.mjs.",
"source": {
"url": "https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-opus-5",
"title": "Prompting Claude Opus 5 — Self-correction / Task scope and over-verification",
"verified": "2026-08-12"
}
}
]
}

View file

@ -65,6 +65,7 @@ export async function scan(_targetPath, _discovery) {
findings.push(finding({
scanner: SCANNER,
code: 'description-bloat',
severity: SEVERITY.low,
title: 'Agent description is long (re-sent every turn in the always-loaded listing)',
description:
@ -91,6 +92,7 @@ export async function scan(_targetPath, _discovery) {
if (aggregate.overBudget) {
findings.push(finding({
scanner: SCANNER,
code: 'aggregate-listing-budget',
severity: SEVERITY.low,
title: 'Aggregate agent listing may exceed the always-loaded budget',
description:

View file

@ -30,7 +30,28 @@ const SCANNER = 'CPS';
// hits per turn, not to chase every inline date in a long backlog file.
const CACHED_PREFIX_LINES = 150;
// Volatile-pattern set (extends token-hotspots.mjs Pattern A).
// CC-provided substitution variables that resolve to a stable per-install or
// per-project path (e.g. "${CLAUDE_PLUGIN_ROOT}/hooks/x.mjs"). CC expands them
// to the same value every turn, so they never break the prompt cache — unlike a
// runtime ${TIMESTAMP}. Excluded from the ${VAR} volatile flag (M-BUG-7).
const STABLE_CC_VARS = new Set(['CLAUDE_PLUGIN_ROOT', 'CLAUDE_PROJECT_DIR']);
// Matches every ${VAR} occurrence on a line so a line carrying only stable CC
// vars is not mistaken for a runtime cache-buster.
const VAR_RX = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
/** True when a line contains at least one non-CC-stable ${VAR} substitution. */
function hasVolatileVar(line) {
VAR_RX.lastIndex = 0;
let m;
while ((m = VAR_RX.exec(line)) !== null) {
if (!STABLE_CC_VARS.has(m[1])) return true;
}
return false;
}
// Volatile-pattern set (extends token-hotspots.mjs Pattern A). The ${VAR} entry
// is `varAware` — flagged via hasVolatileVar() so CC-stable vars are excluded.
const VOLATILE_PATTERNS = [
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
{ rx: /\{uuid\}/i, label: '{uuid} placeholder' },
@ -41,7 +62,7 @@ const VOLATILE_PATTERNS = [
{ rx: /^\s*\[\d{4}-\d{2}-\d{2}/, label: 'dated log line [YYYY-MM-DD ...]' },
// v5 N3 extensions:
{ rx: /^\s*!/, label: 'shell-exec line (! prefix)' },
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution' },
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution', varAware: true },
];
/**
@ -68,18 +89,35 @@ function findVolatileLines(content) {
const out = [];
if (!content) return out;
const lines = content.split('\n').slice(0, CACHED_PREFIX_LINES);
let inFence = false;
for (let i = 0; i < lines.length; i++) {
for (const { rx, label } of VOLATILE_PATTERNS) {
if (rx.test(lines[i])) {
const line = lines[i];
// Fenced code blocks (``` or ~~~) hold illustrative, byte-stable literal
// text — a ${VAR} or timestamp shown inside one is documentation, not a
// runtime cache-buster — so the fence delimiters and their content are
// skipped (M-BUG-7).
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
// Strip `inline code` spans before pattern-testing: a {date} or ${VAR}
// shown inside backticks is literal documentation text, byte-stable, not a
// runtime cache-buster (M-BUG-7). The original line is still reported as the
// snippet so context is preserved.
const probe = line.replace(/`[^`]*`/g, '');
for (const { rx, label, varAware } of VOLATILE_PATTERNS) {
// The ${VAR} pattern flags only non-CC-stable substitutions; every other
// pattern keeps its plain line test.
if (varAware ? !hasVolatileVar(probe) : !rx.test(probe)) continue;
out.push({
line: i + 1,
label,
snippet: lines[i].length > 120 ? lines[i].slice(0, 117) + '...' : lines[i],
snippet: line.length > 120 ? line.slice(0, 117) + '...' : line,
});
break;
}
}
}
return out;
}
@ -119,6 +157,7 @@ export async function scan(targetPath, discovery) {
.join('; ');
findings.push(finding({
scanner: SCANNER,
code: 'volatile-in-prefix',
severity: SEVERITY.medium,
title: 'Volatile content inside cached prefix breaks reuse',
description:
@ -161,6 +200,7 @@ export async function scan(targetPath, discovery) {
.join('; ');
findings.push(finding({
scanner: SCANNER,
code: 'volatile-in-import',
severity: SEVERITY.medium,
title: 'Volatile content in @imported file breaks cached prefix',
description:

View file

@ -23,7 +23,8 @@
*/
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { findArgError } from './lib/cli-args.mjs';
import {
loadLedger,
validateLedger,
@ -32,20 +33,32 @@ import {
defaultLedgerPath,
} from './lib/campaign-ledger.mjs';
/**
* Usage error. Throws rather than calling process.exit(): exit() discards
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
* `Error: ` text and sets the same exit code 3, so callers see no difference.
*/
class CliUsageError extends Error {}
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
throw new CliUsageError(message);
}
/** Flag surface, measured 2026-08-09. The gate runs BEFORE the loop below, so the
* loop no longer needs to re-check that a value followed its flag. */
const ARG_SPEC = { value: ['--ledger-file', '--output-file'] };
async function main() {
const args = process.argv.slice(2);
const argError = findArgError(args, ARG_SPEC);
if (argError) fail(argError);
let ledgerFile = null;
let outputFile = null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--ledger-file' && args[i + 1]) ledgerFile = args[++i];
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
if (a === '--ledger-file') ledgerFile = args[++i];
else if (a === '--output-file') outputFile = args[++i];
}
const ledgerPath = resolve(ledgerFile || defaultLedgerPath());
@ -96,17 +109,18 @@ async function main() {
}
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeFile(outputFile, json, 'utf-8');
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exit(exitCode);
process.exitCode = exitCode;
}
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);
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
});
}

View file

@ -35,18 +35,41 @@
import { resolve, join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import {
loadLedger,
validateLedger,
defaultLedgerPath,
} from './lib/campaign-ledger.mjs';
import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Flag surface. This was the last hand-rolled parser of the fifteen CLIs the
* command layer calls, and the only one that misclassified its own argv: the
* chain below guards each value flag with `argv[i + 1] !== undefined`, so a
* trailing `--repo` fell past every branch to the `startsWith('--')` catch-all
* and was reported as an **unknown flag** about the one flag this CLI
* requires. The shared gate runs first and names the real fault; valid argv
* reaches the loop below byte-for-byte unchanged.
*/
const ARG_SPEC = {
boolean: ['--write', '--approve-scope'],
value: ['--repo', '--ledger-file', '--sessions-dir', '--reference-date', '--output-file', '--session-root'],
};
/**
* Usage error. Throws rather than calling process.exit(): exit() discards
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
* `Error: ` text and sets the same exit code 3, so callers see no difference.
*/
class CliUsageError extends Error {}
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
throw new CliUsageError(message);
}
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
@ -55,7 +78,7 @@ function defaultSessionsDir() {
}
function parseArgs(argv) {
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false };
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false, approveScope: false, sessionRoot: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo' && argv[i + 1] !== undefined) flags.repo = argv[++i];
@ -64,6 +87,8 @@ function parseArgs(argv) {
else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i];
else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i];
else if (a === '--write') flags.write = true;
else if (a === '--approve-scope') flags.approveScope = true;
else if (a === '--session-root' && argv[i + 1] !== undefined) flags.sessionRoot = argv[++i];
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
else fail(`unexpected argument "${a}"`);
}
@ -72,13 +97,16 @@ function parseArgs(argv) {
async function emit(payload, outputFile, exitCode) {
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeFile(outputFile, json, 'utf-8');
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exit(exitCode);
process.exitCode = exitCode;
}
async function main() {
const flags = parseArgs(process.argv.slice(2));
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const flags = parseArgs(args);
if (!flags.repo) fail('--repo <path> is required');
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
@ -140,6 +168,32 @@ async function main() {
now,
});
// Q1 — the scope gate, in code rather than in commands/campaign.md's prose.
//
// `--repo` here names the repo being EXPORTED TO, which is the write target's
// repo, not the session's. The session root is where the operator stands, so
// it comes from `--session-root` (default cwd) — reading it off `--repo`
// would make every export look "in-repo" and silence the gate by
// construction (#63).
//
// Export into another project is `cross-repo`, whose gate is `disclose`, NOT
// `require-ok`: campaign export is cross-repo BY DESIGN, and tightening it
// into a refusal breaks the feature. So the disclosure always rides in the
// payload, and only a `require-ok` class (machine-wide config, or a path in
// no project at all) actually withholds the write.
const scope = evaluateWriteTargets([targetPath], resolve(flags.sessionRoot ?? process.cwd()));
if (flags.write && scope.requiresApproval && !flags.approveScope) {
return emit(
{ status: 'refused', action: 'export', reason: 'scope-gate', repo: repoInfo,
sessionId: repo.sessionId, sourcePlanPath, exportable: true, problems: [],
written: false, targetPath, gate: scope.gate, requiresApproval: true,
disclosures: scope.disclosures },
flags.outputFile,
0,
);
}
let written = false;
if (flags.write) {
await mkdir(dirname(targetPath), { recursive: true });
@ -149,7 +203,9 @@ async function main() {
return emit(
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
exportable: true, problems: [], written, targetPath, document },
exportable: true, problems: [], written, targetPath, document,
gate: scope.gate, requiresApproval: scope.requiresApproval,
disclosures: scope.disclosures },
flags.outputFile,
0,
);
@ -159,7 +215,8 @@ 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);
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
});
}

View file

@ -43,7 +43,8 @@
*/
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import {
createLedger,
addRepo,
@ -57,32 +58,63 @@ import {
} from './lib/campaign-ledger.mjs';
import { readActiveConfig } from './lib/active-config-reader.mjs';
import { buildManifest, splitManifestByOwnership } from './manifest.mjs';
import { findArgError } from './lib/cli-args.mjs';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Usage error. Throws rather than calling process.exit(): exit() discards
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
* `Error: ` text and sets the same exit code 3, so callers see no difference.
*/
class CliUsageError extends Error {}
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
throw new CliUsageError(message);
}
/** Flag surface, measured 2026-08-09. The gate runs BEFORE the loop below, so the
* loop no longer needs to re-check that a value followed its flag. */
const ARG_SPEC = {
value: ['--ledger-file', '--reference-date', '--output-file', '--name', '--findings', '--session'],
};
/** Parse argv into a subcommand, positional args, and the flag map. */
function parseArgs(argv) {
const argError = findArgError(argv, ARG_SPEC);
if (argError) fail(argError);
const positionals = [];
const flags = { ledgerFile: null, referenceDate: null, outputFile: null, name: null, findings: null, session: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i];
else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i];
else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i];
else if (a === '--name' && argv[i + 1] !== undefined) flags.name = argv[++i];
else if (a === '--findings' && argv[i + 1] !== undefined) flags.findings = argv[++i];
else if (a === '--session' && argv[i + 1] !== undefined) flags.session = argv[++i];
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
if (a === '--ledger-file') flags.ledgerFile = argv[++i];
else if (a === '--reference-date') flags.referenceDate = argv[++i];
else if (a === '--output-file') flags.outputFile = argv[++i];
else if (a === '--name') flags.name = argv[++i];
else if (a === '--findings') flags.findings = argv[++i];
else if (a === '--session') flags.session = argv[++i];
else positionals.push(a);
}
return { subcommand: positionals[0], rest: positionals.slice(1), flags };
}
/**
* Can this path actually be read as a repo right now?
*
* `readActiveConfig` resolves any string and its sub-readers all tolerate ENOENT, so a repo
* that does not exist yields an EMPTY config instead of an error. Without this check the
* sweep records a phantom repo as successfully swept with 0 tokens, and the machine-wide
* bill claims coverage it does not have. A missing path is reported, never rejected an
* unmounted volume is a legitimate reason for a tracked repo to be absent today.
*/
async function isReadableRepoDir(path) {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
/** Load an existing ledger, treating a parse error as a hard failure (never clobber corrupt data). */
async function loadOrFail(path) {
try {
@ -94,9 +126,9 @@ async function loadOrFail(path) {
async function emit(payload, outputFile, exitCode) {
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeFile(outputFile, json, 'utf-8');
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exit(exitCode);
process.exitCode = exitCode;
}
async function main() {
@ -140,6 +172,7 @@ async function main() {
let ledger = loaded === null ? createLedger({ now }) : loaded;
const added = [];
const addedUnverified = [];
const skipped = [];
for (const p of paths) {
const resolved = resolve(p);
@ -147,13 +180,17 @@ async function main() {
// --name applies only to a lone path; multi-add lets the lib derive each basename.
const name = paths.length === 1 ? flags.name || undefined : undefined;
ledger = addRepo(ledger, { path: p, name }, { now });
(present ? skipped : added).push(resolved);
if (present) skipped.push(resolved);
else if (await isReadableRepoDir(resolved)) added.push(resolved);
// Tracked either way, but never silently vouched for: the command reports these
// separately so a typo does not become a permanent phantom row in the backlog.
else addedUnverified.push(resolved);
}
await saveLedger(ledgerPath, ledger);
return emit(
{
status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath,
added, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
added, addedUnverified, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
},
flags.outputFile,
0,
@ -210,6 +247,13 @@ async function main() {
let sharedSummary = null;
for (const repo of ledger0.repos) {
// Check readability FIRST: readActiveConfig returns an empty config for a path that
// does not exist rather than throwing, so the catch below would never see it and the
// repo would be recorded as swept with a 0-token delta — a bill that looks complete.
if (!(await isReadableRepoDir(repo.path))) {
skipped.push({ path: repo.path, reason: 'repo path is not readable (does not exist or is not a directory)' });
continue;
}
let split;
try {
const activeConfig = await readActiveConfig(repo.path, { verbose: false });
@ -246,7 +290,8 @@ 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);
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
});
}

View file

@ -5,12 +5,13 @@
*/
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
import { lineCount, truncate } from './lib/string-utils.mjs';
import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, LARGE_CONTEXT_SCALE, scaleForWindow, withCommas } from './lib/context-window.mjs';
import { dirname } from 'node:path';
import { dirname, resolve as resolvePath, sep } from 'node:path';
import { stat } from 'node:fs/promises';
const SCANNER = 'CML';
const MAX_RECOMMENDED_LINES = 200;
@ -30,6 +31,134 @@ const CHAR_BUDGET_RECOMMENDATION =
const CLAUDE_MD_CHAR_WARN_ANCHOR = 40_000; // chars @ 200k context (CC startup warning)
const CLAUDE_MD_CHAR_WARN_LARGE = CLAUDE_MD_CHAR_WARN_ANCHOR * LARGE_CONTEXT_SCALE; // 200,000 @ 1M
// ── C3: dead prose references ───────────────────────────────────────────────
// `import-resolver` resolves @import targets; a path written in prose is not
// checked by anything. The whole design here is the SILENCE taxonomy — a
// precision-first check whose failure mode must be a miss, never a false alarm.
// Each rule below was measured against 407 real CLAUDE.md files, not reasoned
// about; the numbers live in docs/c3-deadref-fasit.local.md §2.
const KNOWN_EXTENSIONS = /\.(?:md|mjs|js|ts|tsx|jsx|json|ya?ml|sh|py|toml|txt|html|css)$/i;
// How many dead references the evidence names before it summarises the rest.
const MAX_LISTED_DEAD_REFS = 5;
/**
* Inline-code spans that sit in prose, i.e. outside fenced code blocks.
* Fenced code is illustrative a dead path in a `bash` sample is a sample,
* not a reference (silence class S1).
* @param {string} content
* @returns {Array<{text: string, line: number}>}
*/
export function extractInlineSpans(content) {
const spans = [];
const lines = String(content == null ? '' : content).split('\n');
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
if (/^\s*(?:```|~~~)/.test(raw)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const re = /`([^`\n]+)`/g;
let m;
while ((m = re.exec(raw)) !== null) {
const text = m[1].trim();
if (text) spans.push({ text, line: i + 1 });
}
}
return spans;
}
/**
* Lexical half of the taxonomy: is this token even a path reference?
* Order is load-bearing the FIRST matching rule is the reported reason, so
* `npm test` is silenced as `whitespace` (a command) rather than as
* `no-separator`, and the taxonomy keeps describing what actually happened.
*
* @param {string} token - the text inside one backtick span
* @returns {{rule: string|null}} rule name, or null when the token is a
* candidate that still needs resolving against the filesystem
*/
export function classifyProseReference(token) {
const t = String(token == null ? '' : token);
// S2 — a command invocation, not a path.
if (/\s/.test(t)) return { rule: 'whitespace' };
// S3 — an external resource; on-disk existence is meaningless.
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || /^(?:www\.|mailto:)/i.test(t)) return { rule: 'url' };
// S4 — a pattern or template: resolves to many, or to nothing until expanded.
if (/[*?[\]{}<>$]/.test(t)) return { rule: 'glob-or-placeholder' };
// S5 — outside project scope, and machine-dependent.
if (t.startsWith('/') || t.startsWith('~')) return { rule: 'absolute-or-home' };
// S6 — a config key or a CLI flag.
if (t.endsWith(':') || t.startsWith('-')) return { rule: 'key-or-flag' };
// S7 — a bare filename in prose is a concept or a tool name, not a reference.
// Measured: admitting bare names triples the output, and its top entries are
// name-drops of tools that exist elsewhere on the machine.
if (!t.includes('/')) return { rule: 'no-separator' };
// S8 — has a separator but no unambiguous path shape: org/repo slugs, npm
// packages, pytest node ids, prose enumerations. Also swallows S10, a path
// carrying a trailing `:54-56` locator — a known v1 gap, and a miss rather
// than a false alarm.
if (!t.endsWith('/') && !KNOWN_EXTENSIONS.test(t)) return { rule: 'ambiguous-slug' };
// S8b — a BARE folder name is a concept one level up from a bare filename,
// and the same D-A reasoning applies. Measured on the same corpus: 183 of 699
// fires (26 %) are single-segment directory tokens, led by `open/` (39x, a
// remote namespace prefix) and generic names — `tests/`, `src/`, `docs/`,
// `scripts/` — that prose almost always MENTIONS rather than references. A
// specific path like `tools/wiki_ingest/` still qualifies.
if (t.endsWith('/') && t.replace(/^\.\//, '').split('/').filter(Boolean).length === 1) {
return { rule: 'single-segment-directory' };
}
return { rule: null };
}
/** @returns {Promise<boolean>} */
async function pathExists(p) {
try {
await stat(p);
return true;
} catch {
return false;
}
}
/** Is `p` the root itself or below it? */
function isInside(p, root) {
return p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
}
/**
* Filesystem half of the taxonomy. Two bases, because a nested CLAUDE.md
* routinely writes repo-root-relative paths.
*
* Containment is checked against the SCAN ROOT, not the file's own directory:
* a legitimate `../docs/x.md` inside the same repo must still resolve, while a
* `..` chain that leaves the tree must not. Measured: without this,
* `../../../../etc/passwd` resolved to the real /etc/passwd and silenced the
* finding by accident. A base a `..` chain can escape is not a base.
*
* @param {string} token
* @param {{fileDir: string, scanRoot: string}} bases
* @returns {Promise<{rule: string|null}>} null means the reference is dead
*/
export async function resolveProseReference(token, { fileDir, scanRoot }) {
const root = resolvePath(scanRoot);
const ownAbs = resolvePath(fileDir, token);
if (!isInside(ownAbs, root)) return { rule: 'outside-scan-tree' };
if (await pathExists(ownAbs)) return { rule: 'resolves-own-dir' };
const rootAbs = resolvePath(root, token);
if (isInside(rootAbs, root) && await pathExists(rootAbs)) return { rule: 'resolves-scan-root' };
return { rule: null };
}
/** Recommended sections for a project CLAUDE.md */
const RECOMMENDED_SECTIONS = [
{ pattern: /project|overview|description|what/i, label: 'Project overview' },
@ -62,6 +191,7 @@ export async function scan(targetPath, discovery, opts = {}) {
return scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
code: 'no-claude-md',
severity: SEVERITY.high,
title: 'No CLAUDE.md found',
description: 'No CLAUDE.md files were discovered. This is the primary configuration surface for Claude Code.',
@ -91,6 +221,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (file.scope === 'project' && relDir !== '.' && relDir !== '.claude' && lines > 5) {
findings.push(finding({
scanner: SCANNER,
code: 'nested-not-reinjected',
severity: SEVERITY.low,
title: 'Nested CLAUDE.md is not re-injected after compaction',
description: `${file.relPath} is a nested (subdirectory) CLAUDE.md. It loads when Claude reads a file in that directory, but after a context compaction it is not re-injected (only the project-root CLAUDE.md is) — its instructions silently drop until a file in that directory is read again.`,
@ -109,6 +240,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (lines > MAX_ABSOLUTE_LINES) {
findings.push(finding({
scanner: SCANNER,
code: 'over-500-lines',
severity: SEVERITY.medium,
title: 'CLAUDE.md exceeds 500 lines',
description: `${file.relPath} has ${lines} lines. A file this size loads in full on every turn (token cost) and, on smaller-context models, can crowd out instructions. Large-context models tolerate longer files when the cache prefix stays stable — raw line count is no longer an absolute adherence threshold (CC 2.1.169 scales it by context window).`,
@ -120,6 +252,7 @@ export async function scan(targetPath, discovery, opts = {}) {
} else if (lines > MAX_RECOMMENDED_LINES) {
findings.push(finding({
scanner: SCANNER,
code: 'over-200-lines',
severity: SEVERITY.medium,
title: 'CLAUDE.md exceeds recommended 200 lines',
description: `${file.relPath} has ${lines} lines. Under ~200 lines is the safe default across models; larger is fine on large-context models when the cache prefix stays stable. A long file still costs tokens every turn.`,
@ -142,6 +275,7 @@ export async function scan(targetPath, discovery, opts = {}) {
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
findings.push(finding({
scanner: SCANNER,
code: 'over-char-budget',
severity: SEVERITY.medium,
title: 'CLAUDE.md exceeds Claude Code\'s performance-warning threshold',
description: `${file.relPath} is ${withCommas(chars)} chars. Claude Code shows a startup warning ("Large CLAUDE.md will impact performance ... chars > 40.0k") once a CLAUDE.md passes ~40.0k chars on a 200k-context model — it loads in full on every turn. CC 2.1.169 scales that threshold with the context window, so on a ${withCommas(LARGE_CONTEXT_WINDOW)}-token model it relaxes to ~${withCommas(CLAUDE_MD_CHAR_WARN_LARGE)} chars and you are likely within it.`,
@ -156,6 +290,7 @@ export async function scan(targetPath, discovery, opts = {}) {
const threshLabel = withCommas(charThreshold);
findings.push(finding({
scanner: SCANNER,
code: 'over-char-budget',
severity: advisory ? SEVERITY.info : SEVERITY.medium,
title: 'CLAUDE.md exceeds Claude Code\'s performance-warning threshold',
description: `${file.relPath} is ${withCommas(chars)} chars, over the ~${threshLabel}-char performance-warning threshold Claude Code applies at a ${winLabel}-token context window (it scales the ~40.0k-char @ 200k warning by the context window, CC 2.1.169) — it loads in full on every turn.` +
@ -172,6 +307,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (lines < 3) {
findings.push(finding({
scanner: SCANNER,
code: 'nearly-empty',
severity: SEVERITY.medium,
title: 'CLAUDE.md is nearly empty',
description: `${file.relPath} has only ${lines} lines.`,
@ -197,6 +333,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (missingSections.length > 0) {
findings.push(finding({
scanner: SCANNER,
code: 'missing-sections',
severity: SEVERITY.low,
title: 'Missing recommended sections',
description: `${file.relPath} is missing: ${missingSections.join(', ')}`,
@ -212,6 +349,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (sections.length === 0 && lines > 10) {
findings.push(finding({
scanner: SCANNER,
code: 'no-headings',
severity: SEVERITY.medium,
title: 'CLAUDE.md has no markdown headings',
description: `${file.relPath} has ${lines} lines but no ## headings. Structured content with headers improves Claude's ability to find and follow instructions.`,
@ -228,6 +366,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (imp.path.includes('..') && imp.path.split('..').length > 3) {
findings.push(finding({
scanner: SCANNER,
code: 'deep-relative-import',
severity: SEVERITY.low,
title: '@import with deep relative path',
description: `${file.relPath}:${imp.line} imports "${truncate(imp.path, 60)}" with multiple parent traversals.`,
@ -245,6 +384,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (htmlComments > 0) {
findings.push(finding({
scanner: SCANNER,
code: 'html-comments',
severity: SEVERITY.info,
title: 'Uses HTML comments',
description: `${file.relPath} uses ${htmlComments} HTML comment(s). These are stripped before injection, saving tokens.`,
@ -266,6 +406,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (duplicates.length > 0) {
findings.push(finding({
scanner: SCANNER,
code: 'repeated-content',
severity: SEVERITY.low,
title: 'Repeated content detected',
description: `${file.relPath} has ${duplicates.length} line(s) repeated 3+ times.`,
@ -281,6 +422,7 @@ export async function scan(targetPath, discovery, opts = {}) {
if (todos.length > 0) {
findings.push(finding({
scanner: SCANNER,
code: 'todo-markers',
severity: SEVERITY.info,
title: 'Contains TODO/FIXME markers',
description: `${file.relPath} has ${todos.length} TODO/FIXME/HACK marker(s).`,
@ -288,6 +430,40 @@ export async function scan(targetPath, discovery, opts = {}) {
evidence: truncate(todos[0].trim(), 80),
}));
}
// --- Dead prose references (C3) ---
// One finding per FILE, matching the idiom of the two checks above: a
// machine-wide scan measured 699 dead references across 128 files, and
// per-token emission would bury the file that has ten of them.
const deadRefs = [];
for (const span of extractInlineSpans(content)) {
if (classifyProseReference(span.text).rule !== null) continue;
const { rule } = await resolveProseReference(span.text, {
fileDir: dirname(file.absPath),
scanRoot: targetPath,
});
if (rule === null) deadRefs.push(span);
}
if (deadRefs.length > 0) {
const listed = deadRefs
.slice(0, MAX_LISTED_DEAD_REFS)
.map(r => `${r.text} (line ${r.line})`)
.join(', ');
const rest = deadRefs.length - Math.min(deadRefs.length, MAX_LISTED_DEAD_REFS);
findings.push(finding({
scanner: SCANNER,
code: 'dead-prose-reference',
severity: SEVERITY.low,
title: 'CLAUDE.md points at files that are not there',
description: `${file.relPath} has ${deadRefs.length} backtick-quoted path reference(s) in prose that resolve to nothing — neither next to the file nor from the scan root. Anyone following them, human or Claude, finds nothing.`,
file: file.absPath,
line: deadRefs[0].line,
evidence: `${listed}${rest > 0 ? `, +${rest} more` : ''}`,
recommendation: 'Point each reference at where the file actually lives, or drop it. Only unambiguous relative paths are checked — URLs, globs, absolute paths and bare filenames are left alone.',
autoFixable: false,
}));
}
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);

View file

@ -74,6 +74,7 @@ export async function scan(_targetPath, _discovery) {
];
findings.push(finding({
scanner: SCANNER,
code: 'skill-user-vs-plugin',
severity: SEVERITY.medium,
title: `Skill name "${name}" collides between user-level and plugin sources`,
description:
@ -97,6 +98,7 @@ export async function scan(_targetPath, _discovery) {
const pluginNames = pluginSkills.map(s => s.pluginName);
findings.push(finding({
scanner: SCANNER,
code: 'skill-multi-plugin',
severity: SEVERITY.low,
title: `Skill name "${name}" used by multiple plugins`,
description:

View file

@ -5,6 +5,7 @@
* Finding IDs: CA-CNF-NNN
*/
import { sep } from 'node:path';
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
@ -17,6 +18,22 @@ const SCANNER = 'CNF';
// Keys checked separately or not meaningful to compare
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
// Files under `.claude/plugins/` are shipped by installed plugins — the plugin's
// own settings.json/hooks.json plus bundled test fixtures and examples. They are
// not the user's authored cascade and a "conflict" between them is not something
// the user can resolve, so they must be excluded from cross-scope conflict
// analysis. (Other scanners still need active plugin config, so this exclusion is
// CNF-local, not a discovery-level skip. M-BUG-2.)
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
/**
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
* @returns {boolean} true if the file is shipped by an installed plugin
*/
function isPluginBundled(file) {
return file.absPath.includes(PLUGIN_TREE_MARKER);
}
/**
* Flatten an object's top-level keys into a simple keyvalue map.
* Only first level we compare top-level settings, not nested.
@ -63,10 +80,10 @@ export async function scan(targetPath, discovery) {
const start = Date.now();
const findings = [];
// Collect settings files
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
// Collect hooks files
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
// Collect settings files (excluding plugin-bundled — see PLUGIN_TREE_MARKER)
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json' && !isPluginBundled(f));
// Collect hooks files (excluding plugin-bundled)
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json' && !isPluginBundled(f));
const totalFiles = settingsFiles.length + hooksFiles.length;
@ -112,6 +129,7 @@ export async function scan(targetPath, discovery) {
findings.push(finding({
scanner: SCANNER,
code: 'settings-key-conflict',
severity: SEVERITY.medium,
title: `Settings key conflict: "${key}"`,
description: `Key "${key}" has different values across scopes. ${details}`,
@ -143,6 +161,7 @@ export async function scan(targetPath, discovery) {
if (rulesIntersect(allowRule, denyRule)) {
findings.push(finding({
scanner: SCANNER,
code: 'permission-allow-deny',
severity: SEVERITY.high,
title: 'Permission allow/deny conflict',
description: `"${allowRule}" is allowed in ${a.scope} (${a.file}) but denied in ${b.scope} (${b.file}).`,
@ -160,6 +179,7 @@ export async function scan(targetPath, discovery) {
if (rulesIntersect(allowRule, denyRule)) {
findings.push(finding({
scanner: SCANNER,
code: 'permission-allow-deny',
severity: SEVERITY.high,
title: 'Permission allow/deny conflict',
description: `"${allowRule}" is allowed in ${b.scope} (${b.file}) but denied in ${a.scope} (${a.file}).`,
@ -210,6 +230,7 @@ export async function scan(targetPath, discovery) {
const [event, matcher] = key.split(':');
findings.push(finding({
scanner: SCANNER,
code: 'duplicate-hook',
severity: SEVERITY.low,
title: 'Duplicate hook definition',
description: `Hook "${event}" with matcher "${matcher}" is defined in ${uniqueSources.length} sources.`,

View file

@ -113,6 +113,7 @@ export async function scan(targetPath, discovery) {
.join('; ');
findings.push(finding({
scanner: SCANNER,
code: 'deny-and-allow',
severity: SEVERITY.low,
title: 'Tool listed in both permissions.deny and permissions.allow',
description:
@ -134,6 +135,7 @@ export async function scan(targetPath, discovery) {
const evidence = `allow: ${ineffective.slice(0, 5).map(e => `"${e}"`).join(', ')}`;
findings.push(finding({
scanner: SCANNER,
code: 'ineffective-allow-wildcard',
severity: SEVERITY.low,
title: 'Ineffective allow wildcard — Claude Code ignores this rule',
description:
@ -160,6 +162,7 @@ export async function scan(targetPath, discovery) {
.join('; ');
findings.push(finding({
scanner: SCANNER,
code: 'forbidden-param-deny',
severity: SEVERITY.medium,
title: 'Permission rule silently ignored — deny/ask uses a forbidden param key',
description:
@ -184,6 +187,7 @@ export async function scan(targetPath, discovery) {
.join('; ');
findings.push(finding({
scanner: SCANNER,
code: 'forbidden-param-allow',
severity: SEVERITY.low,
title: 'Permission rule silently ignored — allow uses a forbidden param key (dead config)',
description:

View file

@ -5,16 +5,22 @@
* Compare current configuration against a saved baseline.
* Usage:
* node drift-cli.mjs <path> --save [--name my-baseline]
* node drift-cli.mjs <path> [--baseline my-baseline] [--json]
* node drift-cli.mjs <path> [--baseline my-baseline] [--json] [--output-file path]
* node drift-cli.mjs --list
* Unknown options and value-less --name/--baseline/--output-file exit 3.
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
import { humanizeFindings, humanizeFinding } from './lib/humanizer.mjs';
const BOOL_FLAGS = ['--save', '--list', '--json', '--raw', '--global'];
const VALUE_FLAGS = ['--name', '--baseline', '--output-file'];
async function main() {
const args = process.argv.slice(2);
@ -25,30 +31,50 @@ async function main() {
let jsonMode = false;
let rawMode = false;
let includeGlobal = false;
let outputFile = null;
// M-BUG-21: this loop used to end in `else if (!arg.startsWith('-')) targetPath = arg`,
// with no unknown-flag branch. An unrecognised flag was dropped silently and its
// VALUE fell through to targetPath — so `--output-file /tmp/x.json` scanned
// /tmp/x.json, a path that does not exist, yielding a near-empty scan and
// therefore permanent phantom drift. A missing value for --name was equally
// silent and destructive: it left baselineName at 'default' and OVERWROTE the
// default baseline. Both now fail loudly (exit 3) instead.
for (let i = 0; i < args.length; i++) {
if (args[i] === '--save') {
save = true;
} else if (args[i] === '--name' && args[i + 1]) {
baselineName = args[++i];
} else if (args[i] === '--baseline' && args[i + 1]) {
baselineName = args[++i];
} else if (args[i] === '--list') {
list = true;
} else if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--raw') {
rawMode = true;
} else if (args[i] === '--global') {
includeGlobal = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
const arg = args[i];
if (BOOL_FLAGS.includes(arg)) {
if (arg === '--save') save = true;
else if (arg === '--list') list = true;
else if (arg === '--json') jsonMode = true;
else if (arg === '--raw') rawMode = true;
else if (arg === '--global') includeGlobal = true;
} else if (VALUE_FLAGS.includes(arg)) {
const value = args[i + 1];
if (value === undefined || value.startsWith('-')) {
throw new Error(`Option ${arg} requires a value.`);
}
if (arg === '--name' || arg === '--baseline') baselineName = value;
else outputFile = value;
i++;
} else if (arg.startsWith('-')) {
throw new Error(
`Unknown option: ${arg}\n` +
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
);
} else {
targetPath = arg;
}
}
// --- List mode ---
if (list) {
const result = await listBaselines();
// commands/drift.md runs this with `2>/dev/null` (ux-rules rule 2). The
// human listing below goes to stderr, so without --output-file the command
// received 0 bytes and could render nothing. The flag was already accepted
// by the arg parser; only list mode ignored it.
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
if (jsonMode || rawMode) {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
@ -65,7 +91,12 @@ async function main() {
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
}
process.exit(0);
return;
}
if (!(await requireTargetDir(resolve(targetPath)))) {
process.exitCode = 3;
return;
}
// --- Save mode ---
@ -84,7 +115,7 @@ async function main() {
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
}
process.exit(0);
return;
}
// --- Drift mode (default) ---
@ -103,7 +134,28 @@ async function main() {
process.stderr.write(`Baseline "${baselineName}" not found.\n`);
process.stderr.write(`Save one first: node drift-cli.mjs <path> --save\n`);
}
process.exit(1);
process.exitCode = 1;
return;
}
// M-BUG-27: a baseline carries the path it was saved from, but nothing ever
// compared it against the current target. Diffing a repo against a baseline
// anchored elsewhere produced 100% phantom drift — every baseline finding
// "resolved", every current finding "new" — and reported it as trend
// "improving": a reassuring and entirely false signal, on the DEFAULT
// baseline. The warning goes to stderr in every mode; stdout stays
// byte-identical to the frozen v5.0.0 shape.
const baselineTarget = baseline._baseline?.target_path || '';
const currentTarget = resolve(targetPath);
const anchorMatches = !baselineTarget || baselineTarget === currentTarget;
if (baselineTarget && baselineTarget !== currentTarget) {
process.stderr.write(
`\nWarning: baseline "${baselineName}" was saved from a different target path.\n` +
` baseline: ${baselineTarget}\n` +
` current: ${currentTarget}\n` +
` The two scans cover different trees, so this diff is not a drift signal.\n` +
` Re-anchor with: drift-cli.mjs ${currentTarget} --save --name ${baselineName}\n\n`
);
}
// Run current scan
@ -115,25 +167,49 @@ async function main() {
// Diff
const diff = diffEnvelopes(baseline, current);
// Default mode: humanize finding-bearing diff fields before report rendering.
// `_baselineAnchor` rides here and NOT in the raw shape: commands/drift.md runs
// the CLI under `2>/dev/null`, so the stderr warning above never reaches the
// caller that has to act on it. --json/--raw stay v5.0.0-shaped.
//
// movedFindings holds {from, to} PAIRS, not flat findings — humanizeFindings()
// builds a brand-new object from named finding fields only, so running it
// directly over the pairs silently drops `from`/`to` and formatDiffReport's
// `m.from.severity` crashes on undefined. Humanize each side of the pair.
const humanizedDiff = {
...diff,
_baselineAnchor: { matches: anchorMatches, baselineTarget, currentTarget },
newFindings: humanizeFindings(diff.newFindings || []),
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
movedFindings: (diff.movedFindings || []).map((m) => ({
from: humanizeFinding(m.from),
to: humanizeFinding(m.to),
})),
};
if (jsonMode || rawMode) {
// --json and --raw both write the raw v5.0.0-shape diff (byte-identical).
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
} else {
// Default mode: humanize finding-bearing diff fields before report rendering.
const humanizedDiff = {
...diff,
newFindings: humanizeFindings(diff.newFindings || []),
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
movedFindings: humanizeFindings(diff.movedFindings || []),
};
const report = formatDiffReport(humanizedDiff);
process.stderr.write('\n' + report + '\n');
}
// ux-rules rule 2: every scanner Bash call uses `--output-file <path>` and the
// command reads the file with the Read tool. drift-cli had no such flag, and
// its default-mode report goes to stderr — which commands/drift.md discarded
// via `2>/dev/null` while instructing the agent to "read stdout". The command
// captured nothing. Matches posture.mjs: raw diff in --json/--raw, humanized
// otherwise; stdout is unaffected.
if (outputFile) {
const fileDiff = (jsonMode || rawMode) ? diff : humanizedDiff;
await writeOutputFile(outputFile, JSON.stringify(fileDiff, null, 2), 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
// Exit code: 0=stable/improving, 1=degrading
if (diff.summary.trend === 'degrading') process.exit(1);
process.exit(0);
process.exitCode = diff.summary.trend === 'degrading' ? 1 : 0;
}
// Only run CLI if invoked directly
@ -141,6 +217,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -1,13 +1,14 @@
/**
* GAP Scanner Feature Gap Scanner
* Compares actual configuration against complete Claude Code feature register.
* 25 gap dimensions across 4 tiers, plus a conditional disableBundledSkills
* budget-lever check (remediation companion to SKL CA-SKL-002, fires only under
* measured skill-listing pressure). Always runs with includeGlobal: true.
* 24 gap dimensions across 4 tiers, plus four conditional levers (bundled-skills
* budget, CLI-over-MCP, hook-output filtering, agent model/effort routing) which
* fire only under a measured condition and are therefore NOT dimensions: they
* stay out of the scoring denominators. Always runs with includeGlobal: true.
* Finding IDs: CA-GAP-NNN
*/
import { resolve } from 'node:path';
import { resolve, join, sep } from 'node:path';
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
@ -46,6 +47,68 @@ function isTargetLocal(ctx, f) {
return f.absPath.startsWith(ctx.targetPath);
}
// Files that are test/demo/vendored config — NOT part of the user's authored
// cascade — must not satisfy "is feature X present?" checks, or they mask real
// gaps. The canonical case: this plugin's own examples/optimal-setup sets
// outputStyle/statusLine/worktree/model/keybindings/.lsp.json, and (because GAP
// always runs includeGlobal) its copies vendored under ~/.claude/plugins/cache
// drive every tier-3 presence check to "present" — hiding the user's real gaps
// on ANY target. Two classes to exclude:
// - plugin-bundled: anything under ~/.claude/plugins/ (absPath marker, mirrors
// the CNF conflict-detector exclusion from M-BUG-2).
// - nested demo/test data: a file whose path RELATIVE TO THE SCAN TARGET sits
// under an examples/ or tests/fixtures/ subtree. relPath (not absPath) is
// deliberate: a fixture scanned AS the target keeps its own files, so the
// frozen v5.0.0 byte-snapshots (scanned from tests/fixtures/marketplace-medium)
// are untouched. (M-BUG-13)
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
/**
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
* @returns {boolean} true if the file is part of the user's authored config
*/
function isAuthoredConfig(file) {
if (file.absPath.includes(PLUGIN_TREE_MARKER)) return false;
const segs = (file.relPath || '').split(sep);
if (segs.includes('examples')) return false;
const ti = segs.indexOf('tests');
if (ti !== -1 && segs[ti + 1] === 'fixtures') return false;
return true;
}
/**
* Read the userprojectlocal settings cascade directly from the filesystem.
* The settings-key gap checks ask "does the USER's resolved config set X?" a
* question the includeGlobal discovery answers unreliably on a real machine: the
* top-level ~/.claude/settings.json is missed (its relPath carries no `.claude`
* segment when the walk root IS ~/.claude) and, when many vendored plugins flood
* the walk, dropped by the discovery file cap. Reading the canonical cascade
* paths directly is immune to both. Merged INTO (not replacing) the discovery
* settings so any non-canonical project settings still count and the frozen
* snapshots stay byte-stable. (M-BUG-13)
* @param {string} targetPath
* @returns {Promise<Array<{ key: string, parsed: object }>>}
*/
async function readSettingsCascade(targetPath) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const paths = [];
if (home) {
paths.push(['user', join(home, '.claude', 'settings.json')]);
paths.push(['user-local', join(home, '.claude', 'settings.local.json')]);
}
paths.push(['project', join(targetPath, '.claude', 'settings.json')]);
paths.push(['local', join(targetPath, '.claude', 'settings.local.json')]);
const out = [];
for (const [scope, p] of paths) {
const content = await readTextFile(p);
if (!content) continue;
const parsed = parseJson(content);
if (parsed && typeof parsed === 'object') out.push({ key: `cascade:${scope}:${p}`, parsed });
}
return out;
}
const TIER_SEVERITY = {
t1: SEVERITY.medium,
t2: SEVERITY.low,
@ -53,6 +116,36 @@ const TIER_SEVERITY = {
t4: SEVERITY.info,
};
/**
* Titles of the conditional levers findings this scanner emits that are NOT
* dimensions in GAP_CHECKS. They fire only under a measured condition, so they
* carry no tier and never enter the scoring denominators (TIER_COUNTS /
* TOTAL_DIMENSIONS) or the scoring TITLE_TO_ID map.
*
* Exported as the single source of both the code and the title: the
* finding-code registry guard needs the codes, the humanizer coverage guard
* needs the titles, and a hand-maintained copy of either list in a test is the
* two-copies-drift class. One object so the two cannot disagree.
*/
export const LEVERS = {
bundledSkills: {
code: 'bundled-skills-lever',
title: 'Bundled skills add to an over-budget skill listing',
},
cliOverMcp: {
code: 'cli-over-mcp-lever',
title: 'Prefer CLI over MCP for common operations',
},
filterHookOutput: {
code: 'filter-hook-output-lever',
title: 'Filter hook output before it enters context',
},
agentModelRouting: {
code: 'agent-model-routing-lever',
title: 'Subagents pin neither model nor effort',
},
};
/**
* Lazily read and cache file content.
* @param {CheckContext} ctx
@ -115,7 +208,8 @@ export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
return finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Bundled skills add to an over-budget skill listing',
code: LEVERS.bundledSkills.code,
title: LEVERS.bundledSkills.title,
description:
`Your ${aggregate.scanned} active skills already carry ~${aggregate.aggregateTokens} tokens of ` +
`description text, over the ${aggregate.budgetTokens}-token listing budget Claude Code allots the ` +
@ -159,7 +253,8 @@ export function cliOverMcpLeverFinding({ assessment } = {}) {
return finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Prefer CLI over MCP for common operations',
code: LEVERS.cliOverMcp.code,
title: LEVERS.cliOverMcp.title,
description:
`Your active project MCP tool schemas (~${assessment.aggregateTokens} tokens) are forced into the ` +
'always-loaded prefix every turn rather than deferred (see CA-TOK-006). CLI tools (gh, aws, gcloud, …) ' +
@ -196,7 +291,8 @@ export function filterHookLeverFinding({ flaggedHooks } = {}) {
return finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'Filter hook output before it enters context',
code: LEVERS.filterHookOutput.code,
title: LEVERS.filterHookOutput.title,
description:
`${hooks.length} active hook${hooks.length === 1 ? '' : 's'} build hookSpecificOutput.additionalContext ` +
"from un-grepped command output (see HKV advisory). That field enters Claude's context on every fire, " +
@ -211,8 +307,103 @@ export function filterHookLeverFinding({ flaggedHooks } = {}) {
});
}
/**
* Agent model/effort routing lever (C4) cites BP-MODEL-001/002.
*
* A LEVER rather than a GAP_CHECKS dimension, and deliberately so. The question
* "do your subagents route model/effort?" has no meaningful reading on a config
* with no subagents the `No custom subagents` dimension (t2_6) owns that case,
* and firing here too would just double-report it. A dimension can only express
* "not applicable" as "present", which would also inflate the utilization
* denominator for every agent-less config.
*
* ONE check across BOTH axes, not two: it fires only when NEITHER `model:` nor
* `effort:` appears on ANY authored agent. A deliberate all-on-one-model setup
* therefore stays silent, which is the precision the opportunity framing needs.
* The cost is recall a config that pins `model:` everywhere but never uses
* `effort:` gets no nudge. That trade is the v1 boundary, not an oversight.
*
* Pure and exported for unit testing.
*
* @param {{ agentCount: number, modelPinned: number, effortPinned: number }} counts
* @returns {object|null} a GAP finding, or null when there is no opportunity
*/
export function agentModelRoutingLeverFinding({ agentCount, modelPinned, effortPinned }) {
if (!agentCount) return null;
if (modelPinned > 0 || effortPinned > 0) return null;
return finding({
scanner: SCANNER,
severity: SEVERITY.info,
code: LEVERS.agentModelRouting.code,
title: LEVERS.agentModelRouting.title,
description:
`All ${agentCount} of your subagents name neither a \`model:\` nor an \`effort:\` in their frontmatter. ` +
'The `model` field defaults to `inherit`, so each one runs on the main conversation\'s model — omitting ' +
'it is not a neutral default but a choice to pay the session\'s rate for every delegated task ' +
'(BP-MODEL-001, https://code.claude.com/docs/en/sub-agents). Reasoning effort is a separate axis with ' +
'its own frontmatter field and its own default, so a subagent can be routed on either or both ' +
'(BP-MODEL-002, https://code.claude.com/docs/en/model-config).',
evidence:
`authored_agents=${agentCount}; model_pinned=${modelPinned}; effort_pinned=${effortPinned}; ` +
'lever=agent frontmatter `model:` / `effort:` (plugin-bundled and fixture agents excluded)',
recommendation:
'Pin a cheaper `model:` on the subagents whose work is mechanical or read-only (search, extraction, ' +
'summarisation) and leave the orchestrating session on the stronger model; pin a lower `effort:` on the ' +
'same ones and reserve the high levels for work whose product is judgement. If running everything on one ' +
'model is a deliberate policy, suppress this with `CA-GAP-028` in `.config-audit-ignore`.',
category: 'model-fit',
});
}
/**
* Count authored agents and how many pin each routing axis.
* Frontmatter-only read; an unparseable or frontmatter-less file counts as an
* agent that pins nothing, matching what Claude Code would load.
* @param {CheckContext} ctx
* @returns {Promise<{ agentCount: number, modelPinned: number, effortPinned: number }>}
*/
async function countAgentRouting(ctx) {
let agentCount = 0;
let modelPinned = 0;
let effortPinned = 0;
for (const file of ctx.files.filter(f => f.type === 'agent-md')) {
agentCount++;
const content = await getContent(ctx, file.absPath);
if (!content) continue;
const { frontmatter } = parseFrontmatter(content);
if (!frontmatter) continue;
if (isRoutingValue(frontmatter.model) && !isDefaultModel(frontmatter.model)) modelPinned++;
if (isRoutingValue(frontmatter.effort)) effortPinned++;
}
return { agentCount, modelPinned, effortPinned };
}
/**
* True for a frontmatter value that actually names something. An empty or
* whitespace-only `model:` is a no-op in Claude Code, so it must not read as a pin.
* @param {*} v
* @returns {boolean}
*/
function isRoutingValue(v) {
return typeof v === 'string' ? v.trim().length > 0 : v != null && v !== false;
}
/**
* `inherit` IS the documented default for a subagent's `model` (BP-MODEL-001),
* so writing it explicitly routes nothing the agent still runs on the main
* conversation's model. Spelling out a default must not buy silence, or a config
* can opt out of the opportunity without changing a single thing about cost.
* Effort has no documented sentinel of this kind, so it has no counterpart here.
* @param {*} v
* @returns {boolean}
*/
function isDefaultModel(v) {
return typeof v === 'string' && v.trim().toLowerCase() === 'inherit';
}
/** @type {GapCheck[]} */
const GAP_CHECKS = [
export const GAP_CHECKS = [
// --- Tier 1: Foundation ---
{
id: 't1_1', tier: 't1',
@ -418,12 +609,11 @@ const GAP_CHECKS = [
return false;
},
},
{
id: 't3_8', tier: 't3',
title: 'No autoMode classifier',
recommendation: 'Configure autoMode in user/local settings with environment context and allow/deny rules.',
check: async (ctx) => anySettingsHas(ctx, 'autoMode'),
},
// t3_8 ('No autoMode classifier') retired in v5.14: CC 2.1.226's /doctor
// Check 8 covers auto mode with usage-weighted judgement, so an "adopt this
// feature" nudge is a pure duplicate under the binding /doctor positioning.
// The DETERMINISTIC side stays: SET still validates autoMode structure and
// flags it as dead config in shared project settings.
// --- Tier 4: Team/Enterprise ---
{
@ -479,18 +669,30 @@ export async function scan(targetPath, sharedDiscovery) {
? sharedDiscovery
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
// Parse all settings files upfront
// Presence checks ("does the user have feature X?") must see only the user's
// authored cascade — not bundled/vendored/demo config, which masks real gaps
// (M-BUG-13, see isAuthoredConfig).
const authoredFiles = discovery.files.filter(isAuthoredConfig);
// Parse all settings files upfront (authored discovery files) ...
const parsedSettings = new Map();
for (const file of discovery.files.filter(f => f.type === 'settings-json')) {
for (const file of authoredFiles.filter(f => f.type === 'settings-json')) {
const content = await readTextFile(file.absPath);
if (content) {
const parsed = parseJson(content);
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
}
}
// ... plus the real user→project→local cascade read directly, so settings-key
// checks see the true resolved config regardless of the discovery cap/gotcha
// (M-BUG-13). Merged, not replacing — keeps non-canonical project settings and
// the frozen byte-snapshots unchanged.
for (const { key, parsed } of await readSettingsCascade(resolve(targetPath))) {
parsedSettings.set(key, parsed);
}
const ctx = {
files: discovery.files,
files: authoredFiles,
targetPath: resolve(targetPath),
parsedSettings,
fileContents: new Map(),
@ -501,6 +703,7 @@ export async function scan(targetPath, sharedDiscovery) {
if (!present) {
findings.push(finding({
scanner: SCANNER,
code: gap.id,
severity: TIER_SEVERITY[gap.tier],
title: gap.title,
description: `Feature gap: ${gap.title}. ${gap.recommendation}`,
@ -531,6 +734,13 @@ export async function scan(targetPath, sharedDiscovery) {
const hookLever = filterHookLeverFinding({ flaggedHooks });
if (hookLever) findings.push(hookLever);
// Agent model/effort routing lever (C4) — fires only when authored agents
// exist and not one of them uses either routing axis. Reads the SAME authored
// set as the presence checks, so plugin-bundled and fixture agents cannot
// make a machine look routed (M-BUG-13).
const routingLever = agentModelRoutingLeverFinding(await countAgentRouting(ctx));
if (routingLever) findings.push(routingLever);
const filesScanned = discovery.files.length;
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}

View file

@ -9,11 +9,19 @@
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
import { createBackup } from './lib/backup.mjs';
import { humanizeFinding } from './lib/humanizer.mjs';
// `--dry-run` is a no-op alias: dry-run is already the default. It exists because
// commands/fix.md documents it in argument-hint, and a documented flag that the
// CLI silently drops is the same fail-silent class as the unknown-flag sink below.
const BOOL_FLAGS = ['--apply', '--dry-run', '--json', '--raw', '--global', '--approve-scope'];
const VALUE_FLAGS = ['--output-file', '--repo'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
@ -21,18 +29,44 @@ async function main() {
let jsonMode = false;
let rawMode = false;
let includeGlobal = false;
let outputFile = null;
let approveScope = false;
// The session's root, never the scan target (#63). `--global` fixes files
// under `~/.claude` while the session still stands somewhere else, so reading
// the root off the target would classify a machine-wide write as "in-repo"
// and silence the strongest gate exactly where it matters.
let repoRoot = process.cwd();
// Same defect class as M-BUG-21 in drift-cli: this loop used to end in
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
// unknown-flag branch, so an unrecognised flag was dropped silently and its
// VALUE became the scan target. Here that is worse than in drift: combined
// with --apply it silently moves the WRITE target to another tree.
for (let i = 0; i < args.length; i++) {
if (args[i] === '--apply') {
apply = true;
} else if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--raw') {
rawMode = true;
} else if (args[i] === '--global') {
includeGlobal = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
const arg = args[i];
if (BOOL_FLAGS.includes(arg)) {
if (arg === '--apply') apply = true;
else if (arg === '--json') jsonMode = true;
else if (arg === '--raw') rawMode = true;
else if (arg === '--global') includeGlobal = true;
else if (arg === '--approve-scope') approveScope = true;
// --dry-run: default behaviour, accepted so it is not silently dropped.
} else if (VALUE_FLAGS.includes(arg)) {
const value = args[i + 1];
if (value === undefined || value.startsWith('-')) {
throw new Error(`Option ${arg} requires a value.`);
}
if (arg === '--repo') repoRoot = value;
else outputFile = value;
i++;
} else if (arg.startsWith('-')) {
throw new Error(
`Unknown option: ${arg}\n` +
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
);
} else {
targetPath = arg;
}
}
@ -41,6 +75,11 @@ async function main() {
const resolvedPath = resolve(targetPath);
if (!(await requireTargetDir(resolvedPath))) {
process.exitCode = 3;
return;
}
if (!machineMode) {
process.stderr.write(`Config-Audit Fix CLI v2.1.0\n`);
process.stderr.write(`Target: ${resolvedPath}\n`);
@ -103,18 +142,29 @@ async function main() {
let verified = [];
let regressions = [];
let backupId = null;
// The engine's scope verdict, carried out to the payload. A `disclose` class
// writes without withholding anything, so the ONLY place the command can
// learn that a write left the project is here — stderr is discarded by
// `2>/dev/null` (ux-rules rule 2), which is F3's defect class.
let scopeGate = null;
let scopeDisclosures = [];
if (fixes.length === 0) {
if (machineMode) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
if (machineMode) {
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
process.exit(0);
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
return;
}
if (apply) {
// Create backup first
const filesToBackup = [...new Set(fixes.filter(f => f.type !== 'file-rename').map(f => f.file))];
// Create backup first. file-rename used to be excluded here, so a rule file
// whose only defect was its extension was renamed with NO backup entry —
// while commands/fix.md promised "every fix creates a backup first" and
// handed the user a backupId that could not restore it. The source file is
// backed up like any other; rollback recreates it at its original path.
const filesToBackup = [...new Set(fixes.map(f => f.file))];
const backup = createBackup(filesToBackup);
backupId = backup.backupId;
@ -123,9 +173,43 @@ async function main() {
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
}
const result = await applyFixes(fixes, { dryRun: false, backupDir: backup.backupPath });
const result = await applyFixes(fixes, {
dryRun: false,
backupDir: backup.backupPath,
repoRoot,
approveScope,
});
applied = result.applied;
failed = result.failed;
scopeGate = result.gate ?? null;
scopeDisclosures = result.disclosures ?? [];
// A refused set is a verdict about a config that WAS examined, not a tool
// failure — so it rides in the payload and keeps the normal exit contract
// (#62). Anything a command must act on has to reach it through
// `--output-file`; stderr alone is invisible to the command layer (F3).
if (result.requiresApproval && result.refused.length > 0) {
const payload = {
status: 'refused',
reason: 'scope-gate',
gate: result.gate,
requiresApproval: true,
disclosures: result.disclosures,
refused: result.refused,
backupId,
};
const json = JSON.stringify(payload, null, 2) + '\n';
if (machineMode) process.stdout.write(json);
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
if (!machineMode) {
for (const line of result.disclosures) process.stderr.write(`\n ${line}\n`);
process.stderr.write(
`\n Refused ${result.refused.length} fix(es) pending your go-ahead.`
+ ' Re-run with --approve-scope to apply them.\n',
);
}
return;
}
if (!machineMode) {
process.stderr.write(` Results: ${applied.length} applied, ${failed.length} failed\n`);
@ -142,7 +226,10 @@ async function main() {
process.stderr.write(`\n Verifying...\n`);
}
const verification = await verifyFixes(envelope, applied);
// Verification must re-scan the scope the fix run used. It hardcoded
// includeGlobal:false, so with --global every untouched global-scope
// finding fell out of the re-scan and was reported as verified.
const verification = await verifyFixes(envelope, applied, { includeGlobal });
verified = verification.verified;
regressions = verification.regressions;
@ -151,7 +238,11 @@ async function main() {
if (regressions.length > 0) {
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
}
process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${backupId}\n`);
// The user-facing entry, not the CLI: `/config-audit rollback` renders
// the scope disclosures and asks before a restore that leaves the repo.
// `scanners/rollback-cli.mjs` exists now (R1) and is what the command
// runs, but naming it here would hand the user the ungated half.
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
}
}
} else {
@ -165,7 +256,7 @@ async function main() {
}
// JSON output (both --json and --raw write byte-equal v5.0.0-shape stdout)
if (machineMode) {
{
const output = {
planned: fixes.map(f => ({
findingId: f.findingId,
@ -192,8 +283,20 @@ async function main() {
recommendation: m.recommendation,
})),
backupId,
gate: scopeGate,
disclosures: scopeDisclosures,
};
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
const serialized = JSON.stringify(output, null, 2) + '\n';
if (machineMode) process.stdout.write(serialized);
// --output-file carries the same payload to disk. ux-rules rule 2 requires
// it: commands run scanners with `2>/dev/null`, so anything the command has
// to act on must ride in a file, not in stdout or stderr.
if (outputFile) await writeOutputFile(outputFile, serialized, 'utf-8');
// Exit code follows the convention the other scanners use: 0 PASS,
// 2 FAIL, 3 tool error. A failed fix used to exit 0, so a caller could not
// tell a clean run from one that silently lost a fix.
if (failed.length > 0) process.exitCode = 2;
}
}
@ -202,6 +305,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -8,7 +8,9 @@ import { readFile, writeFile, rename, stat } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { createBackup } from './lib/backup.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { VALID_EFFORT_LEVELS as SETTINGS_EFFORT_LEVELS } from './settings-validator.mjs';
/**
* Fix type constants.
@ -22,8 +24,8 @@ const FIX_TYPES = {
FILE_RENAME: 'file-rename',
};
/** Valid effortLevel values for nearest-match */
const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
/** Valid effortLevel values for nearest-match — the validator's list, not a copy. */
const VALID_EFFORT_LEVELS = [...SETTINGS_EFFORT_LEVELS];
/**
* Plan fixes from a scanner envelope.
@ -56,9 +58,21 @@ export function planFixes(envelope) {
}
}
// Sort fixes by severity weight (critical first)
// Sort fixes by severity weight (critical first), but a file-rename always
// sorts after every other fix. A rename moves the file out from under any
// later fix that still addresses the old path: a rule file with both
// `globs:` and a non-.md extension had the rename applied first, and the
// frontmatter fix then failed with ENOENT while the run still exited 0.
// `?? 4`, not `|| 4`: critical weighs 0, and `0 || 4` evaluates to 4 — so
// critical fixes sorted LAST, the exact opposite of this function's contract
// (M-BUG-30). The old test used the same falsy fallback and agreed with the bug.
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));
fixes.sort((a, b) => {
const aRename = a.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
const bRename = b.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
if (aRename !== bRename) return aRename - bRename;
return (severityOrder[a.severity] ?? 4) - (severityOrder[b.severity] ?? 4);
});
return { fixes, skipped, manual };
}
@ -224,6 +238,42 @@ export async function applyFixes(fixPlans, opts = {}) {
throw new Error('backupDir is required when not in dryRun mode');
}
// Q1 — the scope gate, in code rather than in the command template's prose.
//
// A file-rename writes TWO paths: the source disappears and `newPath`
// appears. Classifying only `plan.file` would let a rename move a repo file
// to a machine-wide destination under a `silent` gate.
//
// `!dryRun` is load-bearing and is the same rule the subtraction axis
// settled (#63): the gate guards a WRITE, and a dry run is not one.
// `requiresApproval` is reported either way, so a caller planning a run still
// learns that approval will be owed before anything is applied.
const scope = evaluateWriteTargets(
fixPlans.flatMap((p) => (p.newPath ? [p.file, p.newPath] : [p.file])),
opts.repoRoot ?? null,
opts.home ? { home: opts.home } : {},
);
if (scope.requiresApproval && !opts.approveScope && !opts.dryRun) {
// A refused write is a VERDICT about a config that was examined, not a
// tool failure (#62) — the caller renders the disclosure and asks. Nothing
// is applied, and this is not an exit-3 situation.
return {
applied: [],
failed: [],
gate: scope.gate,
requiresApproval: true,
disclosures: scope.disclosures,
refused: fixPlans.map((plan) => ({
findingId: plan.findingId,
file: plan.file,
status: 'refused',
reason: 'scope-gate',
type: plan.type,
})),
};
}
for (const plan of fixPlans) {
if (opts.dryRun) {
applied.push({
@ -256,7 +306,14 @@ export async function applyFixes(fixPlans, opts = {}) {
}
}
return { applied, failed };
return {
applied,
failed,
gate: scope.gate,
requiresApproval: scope.requiresApproval,
disclosures: scope.disclosures,
refused: [],
};
}
/**
@ -600,20 +657,29 @@ function extractEventFromDescription(description) {
* Verify fixes by re-running affected scanners.
* @param {object} originalEnvelope - Original scanner envelope
* @param {object[]} appliedResults - Results from applyFixes()
* @param {object} [opts]
* @param {boolean} [opts.includeGlobal=false] - Must match the scope the fix run scanned
* @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
*/
export async function verifyFixes(originalEnvelope, appliedResults) {
export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
const targetPath = originalEnvelope.meta.target;
const verified = [];
const regressions = [];
const newFindings = [];
// Re-scan the target
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false });
// Re-scan the target in the SAME scope the fix run used. This was hardcoded
// to includeGlobal:false: after a --global run, every global-scope finding
// was absent from the re-scan and therefore counted as verified — a clean
// "fixed" report for files nothing had touched.
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: opts.includeGlobal === true });
// Build set of original finding IDs that were fixed
const fixedIds = new Set(
appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
// Build the set of fixed finding INSTANCES. A finding ID names the check, so
// one check failing in two files yields two findings sharing an ID; keying on
// the ID alone marks both fixed when one was, and the untouched sibling — still
// present in the re-scan — is then reported as a regression (M-BUG-28).
const instanceKey = (findingId, file) => `${findingId}::${file || ''}`;
const fixedInstances = new Set(
appliedResults.filter(r => r.status === 'applied').map(r => instanceKey(r.findingId, r.file)),
);
// Build set of new finding titles for comparison
@ -627,11 +693,13 @@ export async function verifyFixes(originalEnvelope, appliedResults) {
// Check that fixed findings are gone
for (const scanner of originalEnvelope.scanners) {
for (const f of scanner.findings) {
if (!fixedIds.has(f.id)) continue;
if (!fixedInstances.has(instanceKey(f.id, f.file))) continue;
const key = `${f.scanner}:${f.title}:${f.file}`;
// For file-rename fixes, the original file path won't exist anymore
const fixResult = appliedResults.find(r => r.findingId === f.id);
const fixResult = appliedResults.find(
r => instanceKey(r.findingId, r.file) === instanceKey(f.id, f.file),
);
if (fixResult && fixResult.type === 'file-rename') {
// Check that the finding doesn't reappear at the new path
verified.push(f.id);

View file

@ -70,6 +70,7 @@ export async function scan(targetPath, discovery) {
if (parsed === null) {
findings.push(finding({
scanner: SCANNER,
code: 'invalid-json',
severity: SEVERITY.critical,
title: 'Invalid JSON in hooks.json',
description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`,
@ -120,6 +121,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (typeof hooks !== 'object' || Array.isArray(hooks)) {
findings.push(finding({
scanner: SCANNER,
code: 'hooks-not-object',
severity: SEVERITY.critical,
title: 'Hooks must be an object with event keys',
description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`,
@ -135,6 +137,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (!VALID_EVENTS.has(event)) {
findings.push(finding({
scanner: SCANNER,
code: 'unknown-event',
severity: SEVERITY.high,
title: 'Unknown hook event',
description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
@ -149,6 +152,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (!Array.isArray(handlers)) {
findings.push(finding({
scanner: SCANNER,
code: 'handlers-not-array',
severity: SEVERITY.high,
title: 'Hook handlers must be an array',
description: `${file.relPath}: handlers for "${event}" is not an array.`,
@ -166,6 +170,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (typeof handlerGroup.matcher === 'object') {
findings.push(finding({
scanner: SCANNER,
code: 'matcher-not-string',
severity: SEVERITY.high,
title: 'Matcher must be a string, not an object',
description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`,
@ -180,6 +185,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) {
findings.push(finding({
scanner: SCANNER,
code: 'missing-hooks-array',
severity: SEVERITY.high,
title: 'Missing hooks array in handler group',
description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`,
@ -195,6 +201,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (!hook.type || !VALID_TYPES.has(hook.type)) {
findings.push(finding({
scanner: SCANNER,
code: 'invalid-handler-type',
severity: SEVERITY.high,
title: 'Invalid hook handler type',
description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`,
@ -216,6 +223,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
} catch {
findings.push(finding({
scanner: SCANNER,
code: 'script-not-found',
severity: SEVERITY.high,
title: 'Hook script not found',
description: `${file.relPath}: "${event}" references script that does not exist.`,
@ -232,6 +240,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (verboseCount > VERBOSE_HOOK_LINE_THRESHOLD) {
findings.push(finding({
scanner: SCANNER,
code: 'verbose-output',
severity: SEVERITY.low,
title: 'Verbose hook output (loud script)',
description:
@ -259,6 +268,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (ac.flagged) {
findings.push(finding({
scanner: SCANNER,
code: 'unfiltered-additional-context',
severity: SEVERITY.info,
title: 'Hook injects unfiltered output into context',
description:
@ -287,6 +297,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
if (typeof hook.timeout !== 'number') {
findings.push(finding({
scanner: SCANNER,
code: 'timeout-not-number',
severity: SEVERITY.medium,
title: 'Hook timeout must be a number',
description: `${file.relPath}: "${event}" has non-numeric timeout.`,
@ -298,6 +309,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
} else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) {
findings.push(finding({
scanner: SCANNER,
code: 'timeout-out-of-range',
severity: SEVERITY.low,
title: 'Hook timeout outside recommended range',
description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`,

View file

@ -74,6 +74,7 @@ async function walkImports(file, chain, reported, findings) {
reported.add(`tilde::${resolved}`);
findings.push(finding({
scanner: SCANNER,
code: 'tilde-path',
severity: SEVERITY.medium,
title: 'Tilde path in @import',
description: `@${imp.path} uses ~ which may not expand correctly in all contexts.`,
@ -91,6 +92,7 @@ async function walkImports(file, chain, reported, findings) {
reported.add(reportKey);
findings.push(finding({
scanner: SCANNER,
code: 'broken-link',
severity: SEVERITY.high,
title: 'Broken @import link',
description: `@${imp.path} references a file that does not exist.`,
@ -111,6 +113,7 @@ async function walkImports(file, chain, reported, findings) {
const cycle = chain.slice(cycleStart).map(f => basename(f)).join(' → ');
findings.push(finding({
scanner: SCANNER,
code: 'circular-reference',
severity: SEVERITY.medium,
title: 'Circular @import reference',
description: `@${imp.path} creates a circular import chain.`,
@ -129,6 +132,7 @@ async function walkImports(file, chain, reported, findings) {
reported.add(`deep::${resolved}`);
findings.push(finding({
scanner: SCANNER,
code: 'deep-chain',
severity: SEVERITY.low,
title: 'Deep @import chain',
description: `@${imp.path} is at depth ${chain.length} (>${MAX_CHAIN_DEPTH} hops).`,

View file

@ -24,19 +24,32 @@
*/
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { loadRegister, REGISTER_PATH } from './lib/best-practices-register.mjs';
import { assessFreshness, STALE_AFTER_DAYS_DEFAULT } from './lib/knowledge-refresh.mjs';
import { findArgError } from './lib/cli-args.mjs';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Usage error. Throws rather than calling process.exit(): exit() discards
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
* `Error: ` text and sets the same exit code 3, so callers see no difference.
*/
class CliUsageError extends Error {}
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
throw new CliUsageError(message);
}
/** Flag surface, measured 2026-08-09. The gate runs BEFORE the loop below, so the
* loop no longer needs to re-check that a value followed its flag. */
const ARG_SPEC = { boolean: ['--dry-run'], value: ['--output-file', '--stale-after', '--reference-date'] };
async function main() {
const args = process.argv.slice(2);
const argError = findArgError(args, ARG_SPEC);
if (argError) fail(argError);
let outputFile = null;
let staleAfterDays = STALE_AFTER_DAYS_DEFAULT;
let referenceDate = null; // null → today
@ -45,12 +58,12 @@ async function main() {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--dry-run') dryRun = true;
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
else if (a === '--stale-after' && args[i + 1] !== undefined) {
else if (a === '--output-file') outputFile = args[++i];
else if (a === '--stale-after') {
const n = Number.parseInt(args[++i], 10);
if (!Number.isInteger(n) || n < 0) fail('--stale-after must be a non-negative integer (days)');
staleAfterDays = n;
} else if (a === '--reference-date' && args[i + 1]) {
} else if (a === '--reference-date') {
referenceDate = args[++i];
if (!DATE_RE.test(referenceDate)) fail('--reference-date must be YYYY-MM-DD');
}
@ -87,17 +100,18 @@ async function main() {
};
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeFile(outputFile, json, 'utf-8');
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exit(assessment.counts.stale > 0 ? 1 : 0);
process.exitCode = assessment.counts.stale > 0 ? 1 : 0;
}
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);
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
});
}

View file

@ -53,6 +53,76 @@ export function estimateTokens(bytes, kind = 'markdown', opts = {}) {
return Math.ceil(bytes / 4);
}
/**
* Strip block-level HTML comments (`<!-- ... -->`) that lie OUTSIDE fenced code
* blocks. Claude Code strips these before injecting a CLAUDE.md / memory file
* into context (code.claude.com/docs/en/memory: "block-level HTML comments are
* stripped before the content is injected"), preserving them only inside fenced
* code blocks (``` / ~~~). A byte-accurate token estimate must therefore discount
* them. (M-BUG-6)
*
* Conservative scope only *block-level* comments are removed (a comment that
* occupies its own line(s)); inline comments sharing a line with other text are
* retained, since the verified CC behavior covers block-level stripping only.
*
* @param {string} content
* @returns {string} content with out-of-fence block comments removed
*/
export function stripInjectedHtmlComments(content) {
if (typeof content !== 'string' || content === '') return '';
const lines = content.split('\n');
const out = [];
let inFence = false;
let inComment = false;
for (const line of lines) {
if (inComment) {
// Inside a multi-line block comment: drop lines until the closing `-->`,
// keeping any real content that trails the close on the same line.
const end = line.indexOf('-->');
if (end !== -1) {
inComment = false;
const rest = line.slice(end + 3);
if (rest.trim() !== '') out.push(rest);
}
continue;
}
// Fence delimiters (``` / ~~~) toggle a preserve-verbatim region.
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
out.push(line);
continue;
}
if (inFence) {
out.push(line);
continue;
}
// Whole line is a single self-contained block comment → CC strips it.
if (/^\s*<!--[\s\S]*?-->\s*$/.test(line)) continue;
// Block comment opening with nothing but whitespace before it and no close
// on this line → runs onto following lines.
const openIdx = line.indexOf('<!--');
if (openIdx !== -1 && line.indexOf('-->', openIdx) === -1 && line.slice(0, openIdx).trim() === '') {
inComment = true;
continue;
}
out.push(line);
}
return out.join('\n');
}
/**
* Effective injected byte length of a CLAUDE.md / memory source: raw UTF-8 bytes
* minus the block-level HTML comments CC strips before injection. Used wherever a
* CLAUDE.md token estimate must reflect what actually enters context. (M-BUG-6)
*
* @param {string} content
* @returns {number}
*/
export function effectiveMemoryBytes(content) {
if (typeof content !== 'string') return 0;
return Buffer.byteLength(stripInjectedHtmlComments(content), 'utf8');
}
// ─────────────────────────────────────────────────────────────────────────
// Load-pattern model (v5.6 Foundation)
// ─────────────────────────────────────────────────────────────────────────
@ -202,7 +272,11 @@ export async function walkClaudeMdCascade(repoPath) {
const totalBytes = files.reduce((sum, f) => sum + f.bytes, 0);
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
const estimatedTokens = estimateTokens(totalBytes, 'markdown');
// Token estimate is computed from the *effective* (injected) byte count — CC
// strips block-level HTML comments before injection — while totalBytes stays
// the honest on-disk figure. (M-BUG-6)
const effectiveBytes = files.reduce((sum, f) => sum + (f.effectiveBytes ?? f.bytes), 0);
const estimatedTokens = estimateTokens(effectiveBytes, 'markdown');
return { files, totalBytes, totalLines, estimatedTokens };
}
@ -217,6 +291,7 @@ async function tryAddClaudeMd(absPath, scope, parent, files, seen) {
path: absPath,
scope,
bytes: s.size,
effectiveBytes: effectiveMemoryBytes(content),
lines: lineCount(content),
parent,
};
@ -572,14 +647,20 @@ async function countPluginItems(pluginRoot) {
return counts;
}
async function listMarkdownFiles(dir) {
async function listMarkdownFiles(dir, recursive = false) {
const out = [];
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
// Opt-in recursion (M-BUG-3): CC scans agents dirs recursively, so agents
// organized into subfolders must be enumerated too. Other callers stay flat.
if (recursive) out.push(...await listMarkdownFiles(full, true));
continue;
}
if (!e.isFile()) continue;
if (!e.name.endsWith('.md')) continue;
const full = join(dir, e.name);
try {
const s = await stat(full);
out.push({ path: full, size: s.size });
@ -662,6 +743,11 @@ export async function enumerateSkills(pluginList = []) {
// Rules, agents, output styles (v5.6 Foundation enumeration)
// ─────────────────────────────────────────────────────────────────────────
/** True when `v` is a non-empty, non-whitespace string (a usable frontmatter field). */
function hasText(v) {
return typeof v === 'string' && v.trim().length > 0;
}
/**
* Build the project/user/plugin directory list for a per-kind enumerator.
* Project + user dirs live under `.claude/<dir>`; plugins under each of the
@ -669,8 +755,16 @@ export async function enumerateSkills(pluginList = []) {
*/
function configDirs(repoPath, pluginList, subdir, pluginSubdirs = [subdir]) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const dirs = [{ dir: join(repoPath, '.claude', subdir), source: 'project', pluginName: null }];
if (home) dirs.push({ dir: join(home, '.claude', subdir), source: 'user', pluginName: null });
const projectDir = join(repoPath, '.claude', subdir);
const userDir = home ? join(home, '.claude', subdir) : null;
const dirs = [];
// M-BUG-4: when repoPath === $HOME (the `manifest --global` self-scan), the
// project dir resolves to the same path as the user dir. Count it once, as
// user scope, instead of enumerating the same directory twice.
if (!(userDir && userDir === projectDir)) {
dirs.push({ dir: projectDir, source: 'project', pluginName: null });
}
if (userDir) dirs.push({ dir: userDir, source: 'user', pluginName: null });
for (const p of pluginList) {
for (const sub of pluginSubdirs) {
dirs.push({ dir: join(p.path, sub), source: 'plugin', pluginName: p.name });
@ -723,15 +817,24 @@ export async function enumerateRules(repoPath, pluginList = []) {
*
* @param {string} repoPath
* @param {Array<{name:string, path:string}>} [pluginList]
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, model:string|null, effort:string|null, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
*/
export async function enumerateAgents(repoPath, pluginList = []) {
const out = [];
const lp = deriveLoadPattern('agent');
const dirs = configDirs(repoPath, pluginList, 'agents');
for (const { dir, source, pluginName } of dirs) {
const files = await listMarkdownFiles(dir);
const files = await listMarkdownFiles(dir, true); // M-BUG-3: CC scans agents dirs recursively
for (const f of files) {
// M-BUG-5: CC registers a subagent only when its frontmatter declares both
// `name` and `description` (docs: identity comes only from `name`; both are
// required). Frontmatter-less / incomplete files are registration no-ops
// that cost zero always-loaded tokens — don't count them as agents.
let frontmatter;
try {
({ frontmatter } = parseFrontmatter(await readFile(f.path, 'utf-8')));
} catch { continue; }
if (!hasText(frontmatter && frontmatter.name) || !hasText(frontmatter && frontmatter.description)) continue;
out.push({
name: basename(f.path).replace(/\.md$/, ''),
source,
@ -739,6 +842,11 @@ export async function enumerateAgents(repoPath, pluginList = []) {
path: f.path,
bytes: f.size,
estimatedTokens: estimateTokens(f.size, 'frontmatter'),
// Routing axes (C4). Explicit null rather than an absent key: `model`
// defaults to `inherit` and `effort` to the session level, so a consumer
// must be able to read "not pinned" without guessing (BP-MODEL-001/002).
model: hasText(frontmatter && frontmatter.model) ? frontmatter.model.trim() : null,
effort: hasText(frontmatter && frontmatter.effort) ? frontmatter.effort.trim() : null,
...lp,
});
}

View file

@ -10,15 +10,29 @@ import { join, basename } from 'node:path';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
const BACKUP_ROOT = join(homedir(), '.config-audit', 'backups');
const MAX_BACKUPS = 10;
/**
* Get the backup root directory path.
*
* Canonical location is `~/.claude/config-audit/backups` the path every
* command, agent and doc uses. `CONFIG_AUDIT_BACKUP_ROOT` overrides it so tests
* never write into the operator's real home.
* @returns {string}
*/
export function getBackupDir() {
return BACKUP_ROOT;
return process.env.CONFIG_AUDIT_BACKUP_ROOT
|| join(homedir(), '.claude', 'config-audit', 'backups');
}
/**
* Get the pre-v2.2.0 backup root. Read-only: nothing writes here any more, but
* backups made before the move must stay listable and restorable.
* @returns {string}
*/
export function getLegacyBackupDir() {
return process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT
|| join(homedir(), '.config-audit', 'backups');
}
/**
@ -56,22 +70,35 @@ export function checksum(content) {
/**
* Create a backup of the specified files.
*
* `opts.created` records paths the caller is about to CREATE. No backup can
* hold a file that does not exist yet, so these are not copied they are
* written into the manifest so `rollback` can tell the user which files it is
* leaving behind. That list used to exist only in `commands/implement.md`,
* typed out by hand next to a manifest the template also typed out by hand;
* moving it here is what lets the template stop owning the format (R2).
*
* @param {string[]} files - Array of absolute file paths to back up
* @param {object} [opts]
* @param {string} [opts.backupId] - Override backup ID (for testing)
* @returns {{ backupId: string, backupPath: string, manifest: object }}
* @param {string[]} [opts.created] - Paths this run will create (recorded, not copied)
* @returns {{ backupId: string, backupPath: string, manifest: object, skipped: string[] }}
*/
export function createBackup(files, opts = {}) {
const backupId = opts.backupId || generateBackupId();
const backupPath = join(BACKUP_ROOT, backupId);
const backupPath = join(getBackupDir(), backupId);
const filesDir = join(backupPath, 'files');
mkdirSync(filesDir, { recursive: true });
const manifestFiles = [];
const skipped = [];
for (const file of files) {
if (!existsSync(file)) continue;
// A target that is not there is reported, never silently dropped: the
// caller asked for a backup of N files and must be able to learn it got
// fewer, before it edits anything.
if (!existsSync(file)) { skipped.push(file); continue; }
const safeName = safeFileName(file);
copyFileSync(file, join(filesDir, safeName));
@ -92,6 +119,7 @@ export function createBackup(files, opts = {}) {
created_at: new Date().toISOString(),
backup_id: backupId,
files: manifestFiles,
created: [...(opts.created || [])],
};
// Write manifest as YAML-like format
@ -101,7 +129,7 @@ export function createBackup(files, opts = {}) {
// Cleanup old backups
cleanupOldBackups();
return { backupId, backupPath, manifest };
return { backupId, backupPath, manifest, skipped };
}
/**
@ -119,6 +147,14 @@ function serializeManifest(manifest) {
yaml += ` checksum: "${f.checksum}"\n`;
yaml += ` size_bytes: ${f.sizeBytes}\n`;
}
// Emitted only when non-empty, and read back by `parseManifest`'s `created:`
// branch — the bare-key form, which is why the implement flow's
// `created: <timestamp>` (a VALUE, meaning the backup id) never collides
// with it.
if (manifest.created && manifest.created.length > 0) {
yaml += `created:\n`;
for (const c of manifest.created) yaml += ` - ${c}\n`;
}
return yaml;
}
@ -128,7 +164,7 @@ function serializeManifest(manifest) {
* @returns {object}
*/
export function parseManifest(content) {
const result = { created_at: '', backup_id: '', files: [] };
const result = { created_at: '', backup_id: '', files: [], created: [] };
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
if (createdMatch) result.created_at = createdMatch[1];
@ -136,7 +172,7 @@ export function parseManifest(content) {
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
if (idMatch) result.backup_id = idMatch[1];
// Parse file entries
// Parse file entries — engine format (quoted `original_path:` …).
const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1);
for (const block of fileBlocks) {
const origMatch = block.match(/^\s*"([^"]+)"/);
@ -154,6 +190,48 @@ export function parseManifest(content) {
}
}
// Parse file entries — implement-flow format. Until R2, `commands/implement.md`
// had the agent hand-build the backup dir, so manifests written by that flow
// use unquoted `- backup:` / `original:` / `sha256:`. Reading only the engine
// format made restoreBackup a success-shaped no-op on every backup implement
// produced (M-BUG-25). The template no longer writes this format, but the
// branch stays: backups already on disk in it must remain restorable — the
// same reason `getLegacyBackupDir()` is still read.
if (result.files.length === 0) {
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
for (const block of implBlocks) {
const bpMatch = block.match(/^\s*(\S+)/);
const origMatch = block.match(/original:\s*(\S+)/);
const csMatch = block.match(/sha256:\s*(\S+)/);
if (origMatch && bpMatch && csMatch) {
result.files.push({
originalPath: origMatch[1],
backupPath: bpMatch[1],
checksum: csMatch[1],
sizeBytes: 0,
});
}
}
if (!result.backup_id) {
const implId = content.match(/^created:\s*(\S+)\s*$/m);
if (implId) result.backup_id = implId[1];
}
}
// Files the implement step CREATED. A backup cannot hold a file that did not
// exist, so rollback can never restore these — but it must be able to say so.
const lines = content.split('\n');
const createdAt = lines.findIndex(l => /^created:[ \t]*$/.test(l));
if (createdAt !== -1) {
for (const line of lines.slice(createdAt + 1)) {
const item = line.match(/^[ \t]+-[ \t]+(\S+)[ \t]*$/);
if (!item) break;
result.created.push(item[1]);
}
}
return result;
}
@ -161,9 +239,10 @@ export function parseManifest(content) {
* Remove old backups beyond MAX_BACKUPS.
*/
function cleanupOldBackups() {
if (!existsSync(BACKUP_ROOT)) return;
const backupRoot = getBackupDir();
if (!existsSync(backupRoot)) return;
const dirs = readdirSync(BACKUP_ROOT, { withFileTypes: true })
const dirs = readdirSync(backupRoot, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
.sort();
@ -171,7 +250,7 @@ function cleanupOldBackups() {
if (dirs.length > MAX_BACKUPS) {
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
for (const dir of toDelete) {
rmSync(join(BACKUP_ROOT, dir), { recursive: true, force: true });
rmSync(join(backupRoot, dir), { recursive: true, force: true });
}
}
}

85
scanners/lib/cli-args.mjs Normal file
View file

@ -0,0 +1,85 @@
/**
* Argv precondition shared by the CLIs the companion to `require-target-dir`.
*
* Every CLI here parses argv with a chain of `if (a === '--x') … else if …`.
* Two things fall through that chain silently:
*
* 1. **An unknown flag.** With no `else` branch, `--zzz` leaves no trace: the
* CLI exits 0 with a full payload a confident answer to a question the
* caller did not ask. Measured live (#51): `knowledge-refresh`'s only knob
* reached the CLI malformed, was ignored, and the command reported "all 14
* entries re-verified within the last 90 days" about a threshold the user
* had just overridden.
*
* 2. **A value-taking flag whose value is another flag.** The guard
* `a === '--output-file' && args[i + 1]` asks only whether a next token
* exists, never whether it is a *value*. Measured live (#57):
* `manifest --output-file --json` wrote a file literally named `--json`
* into the caller's working directory, exit 0, with `--json` mode silently
* dropped. A wrong answer is bad; an unintended file on disk is worse.
*
* This runs BEFORE the CLI's own loop and does not replace it. That is
* deliberate: valid argv reaches the existing parser byte-for-byte unchanged, so
* no frozen snapshot can move. Malformed argv never reaches it at all.
*
* By the exit-code contract, a malformed argument is exit **3** the scanner
* did not get to do its job never 0/1/2, which are verdicts about a
* configuration that WAS examined.
*/
/**
* Find the first thing wrong with `args`.
*
* @param {string[]} args - argv slice (no node/script entries).
* @param {{ boolean?: string[], value?: string[] }} spec - the CLI's flag surface.
* @returns {string|null} diagnostic, or null when argv is well-formed.
*/
export function findArgError(args, spec) {
const booleanFlags = new Set(spec.boolean || []);
const valueFlags = new Set(spec.value || []);
for (let i = 0; i < args.length; i++) {
const a = args[i];
// Positionals and subcommands are the CLI's own business.
if (!a.startsWith('-')) continue;
if (booleanFlags.has(a)) continue;
if (valueFlags.has(a)) {
const next = args[i + 1];
if (next === undefined) {
return `flag "${a}" needs a value, but nothing followed it`;
}
if (next.startsWith('-')) {
return `flag "${a}" needs a value, but the next argument was another flag: "${next}"`;
}
i++; // consume the value so it is never re-examined as a positional
continue;
}
return `unknown flag "${a}"`;
}
return null;
}
/**
* Gate a CLI on well-formed argv. Writes the diagnostic and sets the exit code
* itself, so callers stay a two-line guard:
*
* if (!requireValidArgs(args, ARG_SPEC)) return;
*
* Never throws, and never calls `process.exit()` an abrupt exit discards
* unflushed stdout when the CLI is on a pipe.
*
* @param {string[]} args - argv slice.
* @param {{ boolean?: string[], value?: string[] }} spec - the CLI's flag surface.
* @returns {boolean} true when the CLI may proceed.
*/
export function requireValidArgs(args, spec) {
const error = findArgError(args, spec);
if (error === null) return true;
process.stderr.write(`Error: ${error}\n`);
process.exitCode = 3;
return false;
}

View file

@ -11,6 +11,11 @@ const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__',
'.next', '.nuxt', '.output', '.cache', '.turbo', '.parcel-cache',
'vendor', 'venv', '.venv', '.tox',
// A `backups` dir holds backup COPIES, not live config — auditing it as if
// live produces stale findings. config-audit's own session backups
// (~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md) are the canonical
// case (M-BUG-8), but the rule is general: backups are never live config.
'backups',
]);
// Path marker for the plugin install cache (~/.claude/plugins/cache).

View file

@ -0,0 +1,321 @@
/**
* Finding-code registry the authority for the {NNN} in `CA-{SCANNER}-{NNN}`.
*
* A finding ID names the CHECK, not the finding's position in a run (M-BUG-28).
* Before this registry, `{NNN}` came from an emission counter, so the same check
* carried different IDs on different configurations: fixing an unrelated earlier
* gap silently renumbered every later one, and a `.config-audit-ignore` entry
* retargeted to a neighbouring finding without the user changing anything.
*
* Rules for editing this file:
*
* 1. A number, once published, belongs to its check forever. Adding a check
* takes the next free number for that scanner never the next source-order
* position, and never a number listed in RETIRED.
* 2. Removing a check moves its key to RETIRED. The number is never reissued;
* a user's suppression must go dead rather than quietly point at a
* different finding. (D1 retired GAP `t3_8` under the old scheme, which is
* the incident that motivated the registry.)
* 3. Several call sites may share one code when they are arms of one check
* e.g. the forward/reverse arms of a permission conflict. Duplicate
* emission is legal; a finding is identified by (id, file, line).
* 4. Numbers below are NOT all source order: the ones marked "documented"
* are pinned by README / command copy that shipped before the registry.
*
* GAP keys are the `GAP_CHECKS[].id` values from `feature-gap-scanner.mjs`,
* which were already stable. They are declared here rather than derived, so
* numbers live in exactly one place; `tests/lib/finding-codes.test.mjs` binds
* the two together instead of a second copy of the table drifting.
*/
/**
* @type {Record<string, Record<string, number>>}
* scanner prefix check key number
*/
export const FINDING_CODES = {
// ── CML: claude-md-linter (source order) ────────────────────────────────
// `over-char-budget` has two call sites: the conservative 200k anchor and the
// `--context-window` calibrated variant. One check, one code.
CML: {
'no-claude-md': 1,
'nested-not-reinjected': 2,
'over-500-lines': 3,
'over-200-lines': 4,
'over-char-budget': 5,
'nearly-empty': 6,
'missing-sections': 7,
'no-headings': 8,
'deep-relative-import': 9,
'html-comments': 10,
'repeated-content': 11,
'todo-markers': 12,
'dead-prose-reference': 13,
},
// ── SET: settings-validator (source order) ──────────────────────────────
SET: {
'invalid-json': 1,
'key-typo': 2,
'deprecated-key': 3,
'type-mismatch': 4,
'invalid-effort-level': 5,
'missing-schema': 6,
'no-deny-rules': 7,
'no-allow-rules': 8,
'many-additional-dirs': 9,
'automode-not-object': 10,
'automode-unknown-subkey': 11,
'automode-subkey-not-string-array': 12,
'automode-in-shared-settings': 13,
'hooks-as-array': 14,
},
// ── HKV: hook-validator (source order) ──────────────────────────────────
HKV: {
'invalid-json': 1,
'hooks-not-object': 2,
'unknown-event': 3,
'handlers-not-array': 4,
'matcher-not-string': 5,
'missing-hooks-array': 6,
'invalid-handler-type': 7,
'script-not-found': 8,
'verbose-output': 9,
'unfiltered-additional-context': 10,
'timeout-not-number': 11,
'timeout-out-of-range': 12,
},
// ── RUL: rules-validator (source order) ─────────────────────────────────
RUL: {
'no-frontmatter': 1,
'globs-instead-of-paths': 2,
'pattern-matches-nothing': 3,
'nearly-empty': 4,
'large-unscoped': 5,
'large-scoped-lost-after-compaction': 6,
'not-markdown': 7,
},
// ── MCP: mcp-config-validator (source order) ────────────────────────────
MCP: {
'invalid-json': 1,
'unknown-server-type': 2,
'sse-transport': 3,
'unreferenced-env-var': 4,
'unknown-server-field': 5,
},
// ── IMP: import-resolver (source order) ─────────────────────────────────
IMP: {
'tilde-path': 1,
'broken-link': 2,
'circular-reference': 3,
'deep-chain': 4,
},
// ── CNF: conflict-detector ──────────────────────────────────────────────
// `permission-allow-deny` covers both arms (allow-in-A/deny-in-B and reverse).
CNF: {
'settings-key-conflict': 1,
'permission-allow-deny': 2,
'duplicate-hook': 3,
},
// ── DIS: disabled-in-schema-scanner (source order) ──────────────────────
DIS: {
'deny-and-allow': 1,
'ineffective-allow-wildcard': 2,
'forbidden-param-deny': 3,
'forbidden-param-allow': 4,
},
// ── CPS: cache-prefix-scanner (CPS-001 documented) ──────────────────────
CPS: {
'volatile-in-prefix': 1,
'volatile-in-import': 2,
},
// ── COL: collision-scanner (source order) ───────────────────────────────
COL: {
'skill-user-vs-plugin': 1,
'skill-multi-plugin': 2,
},
// ── AGT: agent-listing-scanner (both documented; source order matches) ──
AGT: {
'description-bloat': 1,
'aggregate-listing-budget': 2,
},
// ── OST: output-style-scanner (all three documented) ────────────────────
OST: {
'strips-coding-instructions': 1,
'plugin-forces-style': 2,
'style-not-found': 3,
},
// ── OPT: optimization-lens-scanner (documented) ─────────────────────────
OPT: {
'procedure-should-be-skill': 1,
},
// ── SKL: skill-listing-scanner (all three documented) ───────────────────
// `aggregate-listing-budget` has two call sites: the conservative 200k anchor
// and the calibrated `--context-window` variant. One check, one code.
SKL: {
'description-over-cap': 1,
'aggregate-listing-budget': 2,
'oversized-body': 3,
},
// ── TOK: token-hotspots ─────────────────────────────────────────────────
// 1/2/3/5/6 are documented (README + commands/tokens.md). `mcp-schema-deferral`
// is documented as 006 although it is the 8th call site in source order, so
// `cascade-over-budget` and `stale-plugin-cache` take the free 7 and 8.
TOK: {
'volatile-top': 1,
'redundant-permissions': 2,
'deep-import-chain': 3,
'bloated-skill-description': 4,
'mcp-schema-budget': 5,
'mcp-schema-deferral': 6,
'cascade-over-budget': 7,
'stale-plugin-cache': 8,
},
// ── PLH: plugin-health-scanner ──────────────────────────────────────────
// 15 and 16 are documented (README v5.4.0 entry) but sit at source positions
// 3 and 4; the remaining checks take {1…14, 17, 18, 19} in source order.
PLH: {
'invalid-plugin-json': 1,
'missing-required-field': 2,
'missing-plugin-json': 3,
'claude-md-missing-section': 4,
'missing-claude-md': 5,
'command-missing-frontmatter': 6,
'command-missing-field': 7,
'agent-missing-frontmatter': 8,
'agent-missing-field': 9,
'agent-ignored-key': 10,
'hooks-json-invalid-structure': 11,
'hooks-json-array': 12,
'hooks-json-invalid': 13,
'unknown-plugin-file': 14,
'plugin-json-shadows-default': 15,
'skills-array-entry': 16,
'no-plugins-found': 17,
'command-name-collision': 18,
'namespace-collision': 19,
},
// ── GAP: feature-gap-scanner ────────────────────────────────────────────
// Keys are GAP_CHECKS[].id for dimensions, and the lever code for the
// conditional levers the scanner emits after the loop. Numbers 124 happen to
// follow the current table order because that is how the dimensions were first
// published — NOT because position determines the number. A new check takes the
// next free number wherever it sits in the file (M-BUG-28).
GAP: {
t1_1: 1,
t1_2: 2,
t1_3: 3,
t1_4: 4,
t1_5: 5,
t2_1: 6,
t2_2: 7,
t2_3: 8,
t2_4: 9,
t2_5: 10,
t2_6: 11,
t2_7: 12,
t3_1: 13,
t3_2: 14,
t3_3: 15,
t3_4: 16,
t3_5: 17,
t3_6: 18,
t3_7: 19,
t4_1: 20,
t4_2: 21,
t4_3: 22,
t4_4: 23,
t4_5: 24,
'bundled-skills-lever': 25,
'cli-over-mcp-lever': 26,
'filter-hook-output-lever': 27,
'agent-model-routing-lever': 28,
},
};
/**
* Keys withdrawn from a scanner. Their numbers are never reissued, so a stale
* suppression goes dead instead of silently naming a different check.
* @type {Record<string, string[]>}
*/
export const RETIRED_CODES = {
// D1 (4027cdc, 2026-08-09): "No autoMode classifier" — /doctor Check 8 covers
// auto mode with usage-weighted judgement, so the nudge went. The number it
// occupied under the old counter scheme is not reused.
GAP: ['t3_8'],
};
/**
* Resolve a check key to its published number.
* Throws rather than falling back: a fallback would let a half-converted scanner
* ship IDs that look valid, which is the silent-degradation class this registry
* exists to remove.
* @param {string} scanner - scanner prefix, e.g. 'GAP'
* @param {string} code - check key, e.g. 't3_7'
* @returns {number}
*/
export function codeNumber(scanner, code) {
const table = FINDING_CODES[scanner];
if (!table) {
throw new Error(`finding(): unknown scanner "${scanner}" — add it to FINDING_CODES`);
}
if (code === undefined || code === null || code === '') {
throw new Error(`finding(): missing "code" for scanner ${scanner} — every finding must name its check`);
}
if (!Object.prototype.hasOwnProperty.call(table, code)) {
const retired = (RETIRED_CODES[scanner] || []).includes(code);
throw new Error(
retired
? `finding(): check "${code}" is RETIRED for ${scanner} — retired numbers are never reissued`
: `finding(): undeclared check "${code}" for ${scanner} — add it to FINDING_CODES with the next free number`
);
}
return table[code];
}
/**
* Render a finding ID from a check key.
* @param {string} scanner
* @param {string} code
* @returns {string} e.g. 'CA-GAP-019'
*/
export function findingId(scanner, code) {
return `CA-${scanner}-${String(codeNumber(scanner, code)).padStart(3, '0')}`;
}
/**
* Every declared ID, as a flat set used to validate suppression patterns so a
* stale pin is reported instead of silently matching nothing.
* @returns {Set<string>}
*/
export function allFindingIds() {
const ids = new Set();
for (const [scanner, table] of Object.entries(FINDING_CODES)) {
for (const n of Object.values(table)) {
ids.add(`CA-${scanner}-${String(n).padStart(3, '0')}`);
}
}
return ids;
}
/**
* Scanner prefixes the registry knows about.
* @returns {string[]}
*/
export function knownScanners() {
return Object.keys(FINDING_CODES);
}

View file

@ -0,0 +1,126 @@
/**
* floor-exclusion the deterministic veto that runs BEFORE the subtraction
* judge sees anything (v5.13, brief §6.0 / §7 q2).
*
* The subtraction lens asks "what no longer earns its always-loaded rent?", and
* that question is only safe to ask because this module answers a prior one
* first: **which blocks are not eligible to be asked about at all?**
*
* Brief §6.0 splits CLAUDE.md content on one axis:
*
* compensatory corrects model *behaviour* ("think before you code").
* A more capable model does it unprompted. Deletable.
* load-bearing a *local fact* no amount of intelligence derives from the
* codebase ("only Forgejo at git.example.test", "system bash
* is 3.2"). FLOOR. Never a deletion candidate.
*
* Why this is deterministic and not the judge's call: precision is asymmetric.
* A missed dead line costs a few tokens per turn; a deleted load-bearing line
* costs a wrong remote, a broken script, or a lost afternoon. A blocking
* guarantee must not rest on a probabilistic prose judgement, so the floor is
* decided here, in code, and the judge only ever ranks what survives.
*
* The veto keys on **underivable local literals** the textual fingerprints of
* a fact that came from this machine rather than from general engineering
* knowledge: an inline code span, a path, a domain, a version pin, a concrete
* filename. Plus §6.0's explicit carve-out: policy invariants (secrets,
* credentials, production, destructive operations) are floor *by decision, not
* by classification* the model would probably honour them unprompted, but the
* cost of being wrong is asymmetric and their token cost is trivial.
*
* Deliberately over-broad. A false veto costs recall (a dead line survives
* another turn); a false clearance costs the guarantee. When in doubt: floor.
*
* Zero external dependencies. Pure: input text boolean.
*/
/** An inline code span — the single strongest local-literal signal. */
const CODE_SPAN_RE = /`[^`\n]+`/;
/** A URL or a bare hostname. `.test`/`.local` included for fixtures. */
const URL_RE = /https?:\/\/|\b[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+\.(?:com|org|net|io|dev|sh|no|test|local|ai)\b/i;
/**
* A rooted path (`~/x`, `./x`, `/Users/x`) or a glob. Deliberately does NOT
* match a bare `word/word`: "pros/cons" is not a path, and treating it as one
* vetoed the single largest deletable block in the dogfood run. Real filenames
* are FILENAME_RE's job, and backticked paths are CODE_SPAN_RE's.
*/
const PATH_RE = /(?:^|[\s(«"'])(?:~|\.{1,2})?\/[\w.~/*-]+|\*\*?\//;
/** A concrete filename with a known extension (STATE.md, .zshenv, foo.sh). */
const FILENAME_RE =
/\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|md|json|ya?ml|toml|go|rs|java|rb|php|c|cpp|h|hpp|sh|sql|css|scss|html|env|template|lock)\b|(?:^|\s)\.\w+rc\b|\bzshenv\b/;
/**
* A version pin "bash 3.2", "Opus 4.8", "v5.12.5". A number that specific is
* a fact about this machine's world, not general knowledge.
*/
const VERSION_RE = /\bv?\d+\.\d+(?:\.\d+)?\b/;
/**
* §6.0's carve-out. These stay in the floor by decision: the downside is
* asymmetric and the lines are cheap. Do not let a "the model knows this now"
* argument reach them.
*/
const POLICY_RE =
/\b(?:secret|secrets|credential|credentials|password|passphrase|api[\s-]?key|access[\s-]?token|keychain|\.env|production|prod|force[\s-]push|rm\s+-rf|destructive|hemmelighet|passord|untrusted|injection|prompt[\s-]injection|angrepsflate|attack[\s-]surface|exfiltrat\w*)\b/i;
/**
* An unresolved local entity: a mixed-case capitalized word appearing
* mid-sentence. In config prose that is almost always a product, service or
* tool name "push til deres egne Forgejo-remotes", "Bruk Explore for søk"
* i.e. exactly the local vocabulary that makes a line underivable, but with no
* literal syntax for the other markers to key on.
*
* This marker is the CONSERVATIVE DEFAULT, and it is deliberately blunt: the
* mechanism cannot tell "Forgejo" from an ordinary capitalized word without a
* dictionary, so it declines to decide and keeps the block. Any finer rule
* (lowercase-form-appears-elsewhere, curated entity lists) is a proxy for a
* dictionary that would be tuned against one machine's config and fail silently
* on the next one. Paying in recall is the direction brief §6.0 mandates.
*
* All-caps tokens are exempt: this config's emphasis convention is ALDRI /
* ALLTID / FØR / , and acronyms like AI and TDD are generic, not local.
*
* "Mid-sentence" is keyed on a preceding LOWERCASE letter (or comma) not on
* "anything that is not a full stop". The looser version cost 4 of 11 deletable
* groups in the dogfood run by firing on `**Bold labels:**` and on quoted
* sentence starts (`"Som AI kan jeg ikke…"`), both of which are sentence
* openings dressed in punctuation rather than local vocabulary.
*/
const ENTITY_RE = /[a-zæøå,;]\s+(?![A-ZÆØÅ]{2,}\b)[A-ZÆØÅ][a-zæøå][\wæøåÆØÅ-]*/;
/** The ordered veto table — exported so a finding can cite *why* it was floored. */
export const FLOOR_MARKERS = Object.freeze([
{ name: 'code-span', re: CODE_SPAN_RE, why: 'contains an inline code literal' },
{ name: 'url', re: URL_RE, why: 'names a specific host or URL' },
{ name: 'filename', re: FILENAME_RE, why: 'names a concrete file' },
{ name: 'path', re: PATH_RE, why: 'names a concrete path' },
{ name: 'version', re: VERSION_RE, why: 'pins a specific version' },
{ name: 'policy', re: POLICY_RE, why: 'is a policy invariant (floor by decision, §6.0)' },
{ name: 'unresolved-entity', re: ENTITY_RE, why: 'names a capitalized entity the mechanism cannot resolve' },
]);
/**
* The first floor marker present in `text`, or null if the text carries no
* underivable local fact.
* @param {string} text
* @returns {{name:string, why:string}|null}
*/
export function floorMarker(text) {
const s = String(text == null ? '' : text);
for (const m of FLOOR_MARKERS) {
if (m.re.test(s)) return { name: m.name, why: m.why };
}
return null;
}
/**
* True when the block must never be proposed for deletion.
* @param {string} text
* @returns {boolean}
*/
export function isLoadBearing(text) {
return floorMarker(text) !== null;
}

View file

@ -77,6 +77,11 @@ export const TRANSLATIONS = {
description: 'HTML comments still count as text sent to Claude on every turn — they don\'t actually hide anything.',
recommendation: 'Delete the comment text if you don\'t want it sent, or convert it to a regular note.',
},
'CLAUDE.md points at files that are not there': {
title: 'Your instructions file links to files that are not there',
description: 'Some file paths written in `CLAUDE.md` point at files that do not exist — not next to the file, and not from your project root. Anyone following them finds nothing.',
recommendation: 'Point each path at where the file actually lives, or drop the reference. Only clear relative paths are checked; web links, wildcards and plain file names are left alone.',
},
'Contains TODO/FIXME markers': {
title: 'Your file has TODO or FIXME notes',
description: 'These notes are sent to Claude on every turn even when they\'re internal reminders.',
@ -96,10 +101,10 @@ export const TRANSLATIONS = {
// ─────────────────────────────────────────────────────────────
SET: {
static: {
'Unknown settings key': {
title: 'A settings key isn\'t recognized',
description: 'A key in your settings file isn\'t one Claude Code understands. It will be ignored.',
recommendation: 'Check the key name for typos, or remove the key if it\'s no longer in use.',
'Possible typo in settings key': {
title: 'A settings key looks like a typo',
description: 'A key in your settings file isn\'t recognized, but it\'s very close to a real one — likely a typo. Claude Code forwards unrecognized keys unchanged rather than rejecting them, so a misspelled key silently has no effect.',
recommendation: 'Check the suggested key name. Fix the spelling, or keep the key if it\'s intentional (e.g. a newer key this audit doesn\'t know yet).',
},
'Deprecated settings key': {
title: 'A settings key is no longer supported',
@ -435,12 +440,12 @@ export const TRANSLATIONS = {
recommendation: 'Consider moving team-wide settings to project scope and keeping personal ones at user or local scope.',
},
'CLAUDE.md not modular': {
title: 'Your instructions file is one big block',
description: 'Splitting long instructions into smaller linked files makes them easier to maintain and easier on the loading time.',
title: 'Your instructions all live in one file',
description: 'Splitting your instructions into smaller linked files with `@import` or `.claude/rules/` keeps each part focused and easier to maintain.',
recommendation: 'Break out long sections into separate files and link them with `@import`.',
},
'No path-scoped rules': {
title: 'Your rules all load on every conversation',
title: 'You haven\'t set up path-scoped rules yet',
description: 'Path-scoped rules only load when you\'re working with files that match — keeps each conversation focused.',
recommendation: 'Add scoping to your rules so they only load for the files they apply to.',
},
@ -490,7 +495,7 @@ export const TRANSLATIONS = {
recommendation: 'Add fields like `model`, `tools`, or `description` to your skill files where useful.',
},
'No subagent isolation': {
title: 'Your subagents share Claude\'s main work folder',
title: 'You haven\'t set up subagent isolation yet',
description: 'Isolated subagents run in their own copy of the repo so they can\'t accidentally disturb your main work.',
recommendation: 'Add `isolation: worktree` to subagents that do destructive or experimental work.',
},
@ -499,11 +504,6 @@ export const TRANSLATIONS = {
description: 'Dynamic context lets a skill see fresh information (file contents, command output) at the moment it runs, not at the time it was written.',
recommendation: 'Use the dynamic-context block in skills that need up-to-date information.',
},
'No autoMode classifier': {
title: 'You haven\'t set up auto-mode classification',
description: 'Auto-mode classification helps Claude decide when to act on its own vs. ask you, based on the kind of task.',
recommendation: 'Add an auto-mode classifier in your settings if you want this nuance.',
},
'No project .mcp.json in git': {
title: 'Your team has no shared list of connected services',
description: 'Without a project-level connected-services file, every teammate has to set up their own connections.',
@ -529,6 +529,30 @@ export const TRANSLATIONS = {
description: 'Language-server connections let Claude see types, error messages, and definitions the same way your editor does.',
recommendation: 'Set up LSP integration if you work in a typed language.',
},
// Conditional levers. These are not "a feature you haven't set up" — they
// fire only under a measured condition, so the generic _default would
// misdescribe them. Every title the scanner can emit needs an entry here
// (guarded in tests/scanners/feature-gap-scanner.test.mjs).
'Bundled skills add to an over-budget skill listing': {
title: 'Built-in skills are crowding an already-full skill list',
description: 'Claude Code loads its own built-in skills into the same limited list as yours. Your list is already over budget, so entries risk being cut off and Claude may miss the right skill.',
recommendation: 'Turn off the built-in skills to free up room — unless you use them, in which case shorten your own skill descriptions instead.',
},
'Prefer CLI over MCP for common operations': {
title: 'Some connected services load their full tool list every turn',
description: 'Most connected services only cost tokens when used, but yours are set to load everything upfront. That weight is there whether you use them or not.',
recommendation: 'For services with a command-line equivalent (like `gh` or `aws`), the command line costs nothing until you run it.',
},
'Filter hook output before it enters context': {
title: 'An automation is pasting its full output into the conversation',
description: 'An automation that injects its output adds it to every turn that follows. Unfiltered command output can be much larger than the part that actually matters.',
recommendation: 'Trim the output inside the script itself, so only the useful lines reach the conversation.',
},
'Subagents pin neither model nor effort': {
title: 'Your helper agents all run at the same cost as your main session',
description: 'A subagent that names no model inherits the one you are using, so routine delegated work costs the same as your hardest work. Reasoning effort is a separate dial with the same default.',
recommendation: 'Give mechanical agents (search, extraction, summarizing) a smaller model or a lower effort level, and keep the strong settings for the work that needs judgement.',
},
},
patterns: [],
_default: {
@ -817,6 +841,11 @@ export const TRANSLATIONS = {
description: 'Claude Code keeps every active skill\'s description in one shared listing it reads to choose which skill to use, and that listing has a limited size. Added up, your skills\' descriptions run past that size on a smaller setup, so Claude Code may drop some of them — and stop seeing those skills. This is an estimate; a larger setup has more room.',
recommendation: 'Free up room: turn off bundled skills you do not use, collapse the heaviest ones so only their names show, or shorten the longest descriptions. The details show the measured total and the room available.',
},
'Skill body is large (loads on demand when the skill runs)': {
title: 'A skill\'s body is large (it loads only when that skill runs)',
description: 'This skill\'s instructions run longer than the rough guidance for a skill body. The body is not part of the always-loaded listing Claude reads every turn — it loads only when you invoke the skill, so it costs nothing until then. Once it loads, though, it stays in context for the rest of that session.',
recommendation: 'Move reference material into supporting files the skill opens only when needed, so the body stays lean. For a heavy skill you can also run its body in a separate context with `context: fork` in the skill\'s settings.',
},
},
patterns: [],
_default: {

View file

@ -38,6 +38,7 @@ const SCANNER_TO_CATEGORY = {
TOK: 'Wasted tokens',
CPS: 'Wasted tokens',
SKL: 'Wasted tokens',
AGT: 'Wasted tokens',
DIS: 'Dead config',
GAP: 'Missed opportunity',
PLH: 'Configuration mistake',

View file

@ -16,6 +16,14 @@
/** Default re-verify cadence: a confirmed best-practice older than this needs a re-check. */
export const STALE_AFTER_DAYS_DEFAULT = 90;
/**
* Default evidence cadence: when the NEWEST `published` date across an entry's
* sources is older than this, the entry is flagged even if recently re-verified.
* Re-verifying the old source does not clear it only a newer source does
* (the BP-SUB-001 defect class: green stamp, substantially outdated evidence).
*/
export const EVIDENCE_STALE_AFTER_DAYS_DEFAULT = 365;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 86_400_000;
@ -46,15 +54,40 @@ function verifiedMs(entry) {
return Number.isNaN(ms) ? null : ms;
}
/** Parse a YYYY-MM-DD string to UTC-midnight ms, or null. */
function dateMs(v) {
if (typeof v !== 'string' || !DATE_RE.test(v)) return null;
const ms = Date.parse(`${v}T00:00:00Z`);
return Number.isNaN(ms) ? null : ms;
}
/**
* Newest `published` across the primary `source` and any corroborating `sources[]`.
* Null when no source carries a parseable published date (the evidence-age rule
* is then silent for that entry it cannot judge evidence it cannot date).
*/
function newestEvidenceMs(entry) {
const candidates = [];
if (entry && entry.source) candidates.push(entry.source);
if (entry && Array.isArray(entry.sources)) candidates.push(...entry.sources);
let newest = null;
for (const s of candidates) {
const ms = dateMs(s && s.published);
if (ms !== null && (newest === null || ms > newest)) newest = ms;
}
return newest;
}
/**
* Classify every register entry as fresh or stale by the age of its source.verified stamp.
*
* @param {{entries:object[]}} register
* @param {{ referenceDate: string|Date, staleAfterDays?: number }} opts
* @param {{ referenceDate: string|Date, staleAfterDays?: number, evidenceStaleAfterDays?: number }} opts
* @returns {{
* referenceDate: string,
* staleAfterDays: number,
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined}>,
* evidenceStaleAfterDays: number,
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined, reasons:string[]}>,
* fresh: Array<{id:string, verified:string|undefined, ageDays:number}>,
* counts: { total:number, stale:number, fresh:number }
* }}
@ -63,6 +96,10 @@ export function assessFreshness(register, opts = {}) {
const ref = normalizeReferenceDate(opts.referenceDate);
const staleAfterDays =
typeof opts.staleAfterDays === 'number' ? opts.staleAfterDays : STALE_AFTER_DAYS_DEFAULT;
const evidenceStaleAfterDays =
typeof opts.evidenceStaleAfterDays === 'number'
? opts.evidenceStaleAfterDays
: EVIDENCE_STALE_AFTER_DAYS_DEFAULT;
const entries = (register && Array.isArray(register.entries)) ? register.entries : [];
const stale = [];
@ -71,14 +108,30 @@ export function assessFreshness(register, opts = {}) {
for (const e of entries) {
const verified = e && e.source ? e.source.verified : undefined;
const vms = verifiedMs(e);
const reasons = [];
let ageDays = null;
if (vms === null) {
// No re-checkable date → needs attention. Stale with ageDays null.
stale.push({ id: e && e.id, verified, ageDays: null, url: e && e.source && e.source.url, claim: e && e.claim });
continue;
// No re-checkable date → needs attention.
reasons.push('no-verified-date');
} else {
ageDays = Math.floor((ref.ms - vms) / DAY_MS);
if (ageDays > staleAfterDays) reasons.push('verified-age');
}
const ageDays = Math.floor((ref.ms - vms) / DAY_MS);
if (ageDays > staleAfterDays) {
stale.push({ id: e.id, verified, ageDays, url: e.source && e.source.url, claim: e.claim });
// A source explicitly marked as superseded is stale no matter how fresh the
// verified stamp is — the stamp certifies the OLD source.
if (e && e.source && e.source.supersededBy) reasons.push('superseded');
// Evidence age: keyed on the newest published date across all sources, so a
// re-read of the old source never clears it — only newer evidence does.
const evMs = newestEvidenceMs(e);
if (evMs !== null && Math.floor((ref.ms - evMs) / DAY_MS) > evidenceStaleAfterDays) {
reasons.push('evidence-age');
}
if (reasons.length > 0) {
stale.push({ id: e && e.id, verified, ageDays, url: e && e.source && e.source.url, claim: e && e.claim, reasons });
} else {
fresh.push({ id: e.id, verified, ageDays });
}
@ -87,6 +140,7 @@ export function assessFreshness(register, opts = {}) {
return {
referenceDate: ref.iso,
staleAfterDays,
evidenceStaleAfterDays,
stale,
fresh,
counts: { total: entries.length, stale: stale.length, fresh: fresh.length },

View file

@ -5,18 +5,13 @@
*/
import { riskScore, riskBand, verdict } from './severity.mjs';
let findingCounter = 0;
/** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */
export function resetCounter() {
findingCounter = 0;
}
import { findingId } from './finding-codes.mjs';
/**
* Create a finding object with auto-incremented ID.
* Create a finding object. The ID names the CHECK see `finding-codes.mjs`.
* @param {object} opts
* @param {string} opts.scanner - 3-letter scanner prefix (CML, SET, HKV, RUL, etc.)
* @param {string} opts.code - check key declared in FINDING_CODES for this scanner
* @param {string} opts.severity - critical | high | medium | low | info
* @param {string} opts.title
* @param {string} opts.description
@ -30,10 +25,8 @@ export function resetCounter() {
* @returns {object}
*/
export function finding(opts) {
findingCounter++;
const id = `CA-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
const result = {
id,
id: findingId(opts.scanner, opts.code),
scanner: opts.scanner,
severity: opts.severity,
title: opts.title,

View file

@ -0,0 +1,109 @@
/**
* Model-scoped ANNOTATION on top of the subtraction lens.
*
* This module generates no candidates of its own. `optimize --subtract` already
* surfaces compensatory instructions through the single `compensatory-instruction`
* detector (`BP-SUB-001`); this answers a narrower question over text that
* already passed it: is this specifically the class of instruction a named model
* documents as redundant self-verification, or verification delegated to a
* subagent?
*
* A second competing detector is deliberately NOT what this is. The register
* entry it cites carries `lensCheck: null`, the same discipline `BP-JUDG-001`
* shipped under: a plausible-looking detector for "instruction a model no longer
* needs" measured 7/7 false positives across 409 real CLAUDE.md files, so this
* claim class rides an existing measured detector rather than widening the
* candidate set.
*
* Precision comes from requiring a reflexive/delegate TARGET alongside the
* verify verb, never from narrowing the verb list. A bare "check"/"verify" also
* matches EXTERNAL verification ("check the CI status") which stays a true
* negative here even though it is, correctly, still a `BP-SUB-001` candidate
* upstream.
*
* Pure: text annotation or null. Zero external dependencies.
*/
import { LB, RB } from './subtraction-prefilter.mjs';
/**
* The reflexive self-verification target. This is what narrows a bare
* verify/check imperative down to the specific claim the model's documented
* self-correction contradicts.
*/
const SELF_TARGET_RE = new RegExp(
LB +
'(?:your (?:own )?(?:work|output|answer|changes)|yourself|' +
'before (?:responding|submitting|finalizing)|dine egne?|deg selv|før du svarer)' +
RB,
'i',
);
/**
* Verify verbs deliberately as broad as `subtraction-prefilter.mjs`'s
* `IMPERATIVE_RE`. Breadth here is safe because a co-occurring target is
* required; narrowing the list would only lose true positives.
*/
const VERIFY_VERB_RE = new RegExp(
LB +
'(?:double-check|re-verify|re-check|confirm|verify|review|check|' +
'dobbeltsjekk|verifiser|sjekk)' +
RB,
'i',
);
/**
* Delegated verification. Order-free on purpose: it must match both
* "verify X with a subagent" and "use a subagent to verify X".
*/
const DELEGATE_VERIFY_RE = new RegExp(
LB + '(?:subagent|sub-agent|another (?:agent|instance)|task tool)' + RB,
'i',
);
/**
* Does this text carry a self- or delegate-targeted verification instruction?
*
* @param {string} text
* @returns {boolean}
*/
export function isModelContradictedVerification(text) {
return (
VERIFY_VERB_RE.test(text) &&
(SELF_TARGET_RE.test(text) || DELEGATE_VERIFY_RE.test(text))
);
}
/**
* Model names are compared on their alphanumeric skeleton, so "opus-5",
* "Opus 5" and "OPUS5" are one model. A typo'd name simply fails to match
* the CLI reports that it did not recognize the name rather than reporting a
* silent zero.
*
* @param {unknown} s
* @returns {string}
*/
const normalizeModel = (s) =>
String(s || '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
/**
* Annotate a subtraction candidate with a model-scoped register citation.
*
* @param {string} text candidate block text (already a `BP-SUB-001` candidate)
* @param {string|null|undefined} targetModel the model named by `--for-model`
* @param {Array<{id: string, claim: string, modelScope?: string[]}>} entries
* confirmed register entries with `category: 'prompting-fit'`
* @returns {{registerId: string, claim: string, requestedModel: string}|null}
*/
export function matchModelScope(text, targetModel, entries) {
if (!targetModel || !isModelContradictedVerification(text)) return null;
const wanted = normalizeModel(targetModel);
const entry = (entries || []).find((e) =>
(e.modelScope || []).some((m) => normalizeModel(m) === wanted),
);
if (!entry) return null;
return { registerId: entry.id, claim: entry.claim, requestedModel: targetModel };
}
export { normalizeModel };

View file

@ -0,0 +1,49 @@
/**
* Target-path precondition shared by the target-taking CLIs.
*
* A scan target is a scan ROOT. If it does not exist, or is not a directory,
* the scanner cannot do its job and by the plugin's exit-code contract that
* is exit 3, not a verdict. Codes 0/1/2 are PASS/WARNING/FAIL *about a
* configuration that was examined*; every command template gates on exactly
* that distinction, so returning a verdict for an unreadable target sends a
* typo'd path through the whole workflow as a clean result.
*
* Measured before this guard existed (session #56):
* node scanners/posture.mjs /nonexistent/path/xyz exit 0,
* "Health: B (86/100) — Good shape — a few items to address"
*
* The message and exit code here are not new: `manifest.mjs`,
* `token-hotspots-cli.mjs`, `whats-active.mjs` and `optimize-lens-cli.mjs`
* already carried this exact block inline. This module is where the CLIs that
* lacked it get it from; the four that have their own copies are left alone
* (consolidating them is a cleanup, not part of this fix).
*/
import { stat } from 'node:fs/promises';
/**
* Verify that `absPath` is an existing directory.
*
* Writes the diagnostic to stderr itself, so callers stay a two-line guard:
*
* if (!(await requireTargetDir(resolvedPath))) { process.exitCode = 3; return; }
*
* Never throws, and never calls `process.exit()` an abrupt exit discards
* unflushed stdout when the CLI is on a pipe.
*
* @param {string} absPath - Resolved absolute target path.
* @returns {Promise<boolean>} true when the target is usable as a scan root.
*/
export async function requireTargetDir(absPath) {
try {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
return false;
}
return true;
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
return false;
}
}

View file

@ -20,17 +20,17 @@ const GRADE_CONTEXT = {
// --- Tier weights for utilization calculation ---
const TIER_WEIGHTS = { t1: 3, t2: 2, t3: 1, t4: 1 };
const TIER_COUNTS = { t1: 5, t2: 7, t3: 8, t4: 5 };
const TOTAL_DIMENSIONS = 25;
const TIER_COUNTS = { t1: 5, t2: 7, t3: 7, t4: 5 };
const TOTAL_DIMENSIONS = 24;
const MAX_WEIGHTED = Object.entries(TIER_COUNTS).reduce(
(sum, [tier, count]) => sum + count * TIER_WEIGHTS[tier],
0,
); // 5*3 + 7*2 + 8*1 + 5*1 = 42
); // 5*3 + 7*2 + 7*1 + 5*1 = 41
/**
* Calculate weighted utilization from GAP scanner findings.
* @param {object[]} gapFindings - Array of GAP scanner findings (each has .category = t1|t2|t3|t4)
* @param {number} [totalDimensions=25]
* @param {number} [totalDimensions=24]
* @returns {{ score: number, overhang: number }}
*/
export function calculateUtilization(gapFindings, totalDimensions = TOTAL_DIMENSIONS) {
@ -102,7 +102,7 @@ function findGapId(finding) {
return TITLE_TO_ID[finding.title] || 'unknown';
}
/** Title→ID mapping for all 25 gap checks */
/** Title→ID mapping for all 24 gap checks */
const TITLE_TO_ID = {
'No CLAUDE.md file': 't1_1',
'No permissions configured': 't1_2',
@ -123,7 +123,6 @@ const TITLE_TO_ID = {
'No advanced skill frontmatter': 't3_5',
'No subagent isolation': 't3_6',
'No dynamic skill context': 't3_7',
'No autoMode classifier': 't3_8',
'No project .mcp.json in git': 't4_1',
'No custom plugin': 't4_2',
'Agent teams not enabled': 't4_3',
@ -404,4 +403,4 @@ export function generateHealthScorecard(areaScores, opportunityCount, options =
return lines.join('\n');
}
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, TOTAL_DIMENSIONS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };

View file

@ -36,6 +36,23 @@ export const DESCRIPTION_CAP = 1536;
// The 200k/1M window constants live in context-window.mjs (single source of
// truth, shared with the CML CLAUDE.md char-budget check); re-exported here so
// existing importers of this module keep working.
// D2 re-verification (2026-08-09, CC 2.1.226). CC 2.1.226's /doctor reports a
// combined skill+command+agent listing and puts the budget near ~1% (~10,000
// tok on a 1M window) — half of ours. Checked against the primary source
// before touching the number: the CC changelog contains EXACTLY ONE
// budget-fraction statement (L3786, under 2.1.32) and no later entry
// supersedes it, so 2% stands and CA-SKL-002 is NOT a /doctor duplicate
// carrying a stale figure. /doctor's arithmetic could not be reconciled from
// the changelog, and /doctor discloses its own numbers as disk estimates
// (chars÷4), so its ~1% is recorded, not adopted.
//
// NOT VERIFIED, deliberately left alone: L3786 says "skill CHARACTER budget
// now scales with context window (2% of context)". We express the budget in
// TOKENS (0.02 × 200k = 4000 tok). Whether CC's budget is 2% counted in
// characters or in tokens is not resolvable from the changelog wording, and no
// primary source settles it — a 4× difference rides on the answer. Changing
// the constant on that ambiguity would be a guess; it stays until a primary
// source decides it.
export const BUDGET_FRACTION = 0.02;
export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000

View file

@ -43,6 +43,41 @@ export function isSimilar(a, b, threshold = 0.8) {
return similarity >= threshold;
}
/**
* Levenshtein edit distance between two strings (insertions, deletions,
* substitutions; a transposition counts as 2). Used for typo detection on
* settings keys. Zero external dependencies, O(a*b) with two rolling rows.
* @param {string} a
* @param {string} b
* @returns {number}
*/
export function levenshtein(a, b) {
if (a === b) return 0;
const al = a.length;
const bl = b.length;
if (al === 0) return bl;
if (bl === 0) return al;
let prev = new Array(bl + 1);
let curr = new Array(bl + 1);
for (let j = 0; j <= bl; j++) prev[j] = j;
for (let i = 1; i <= al; i++) {
curr[0] = i;
const ac = a.charCodeAt(i - 1);
for (let j = 1; j <= bl; j++) {
const cost = ac === b.charCodeAt(j - 1) ? 0 : 1;
curr[j] = Math.min(
prev[j] + 1, // deletion
curr[j - 1] + 1, // insertion
prev[j - 1] + cost, // substitution
);
}
const tmp = prev;
prev = curr;
curr = tmp;
}
return prev[bl];
}
/**
* Extract all key-like patterns from a settings.json or similar config.
* @param {object} obj

View file

@ -0,0 +1,350 @@
/**
* subtraction-prefilter deterministic candidate generator for the v5.13
* subtraction lens (`/config-audit optimize --subtract`, BP-SUB-001).
*
* Every other command in this plugin asks an ADDITION question what could you
* add, what would fit a better mechanism, how expensive is what you have. This
* module asks the inverse: **what is no longer earning its always-loaded rent?**
*
* It is the mirror image of `lens-prefilter` in one important way. That module
* is recall-first, because a false candidate only costs the judge a moment's
* thought. Here a false candidate is a proposal to DELETE something, so the
* polarity flips: precision-first, and a hard deterministic floor
* (`floor-exclusion`) that the judge is not allowed to override.
*
* ## Granularity: leaf blocks
*
* The one design choice the hand-built fasit deliberately left open. A block is
* one markdown *leaf*: a list item including its wrapped continuation lines, or
* a paragraph. Headings, table rows and fenced code are structural, never
* candidates.
*
* Both halves of that choice are load-bearing, and the fasit tests both:
* - It must SPLIT. A numbered list whose steps 23 are local facts and whose
* steps 1 and 4 are filler is a mixed block; section granularity would have
* to keep or drop all four.
* - It must NOT split further. A bullet's load-bearing literal often sits on a
* wrapped continuation line ("…— `coord-send` er mekanismen"). A
* line-granular mechanism severs the first line from the fact that protects
* it and proposes a floor block for deletion the exact failure the gate
* exists to prevent.
*
* ## Two independent guarantees, not one
*
* A load-bearing block fails to become a candidate for either of two reasons,
* and both are needed:
* 1. it is *declarative* "Language: Norwegian for dialogue" states a fact
* about the human and corrects no behaviour, so no detector fires; or
* 2. `floor-exclusion` vetoes it for carrying an underivable local literal.
* Group 1 never reaches the veto at all, which is why the contract is asserted
* on the candidate list rather than on either mechanism alone.
*
* Norwegian and English are both first-class: the config this was designed
* against is Norwegian prose carrying English identifiers.
*
* Zero external dependencies. Pure: input text candidate array.
*/
import { floorMarker } from './floor-exclusion.mjs';
/**
* The subtraction detector, kept in its OWN table. `LENS_DETECTORS` drives the
* plain `optimize` payload's register block, and the subtraction axis must not
* fire on a plain run it asks a different question and the operator has to
* opt into it with `--subtract`.
*/
export const SUBTRACT_DETECTORS = Object.freeze([
{ lensCheck: 'compensatory-instruction', registerId: 'BP-SUB-001', mechanism: 'deletion' },
]);
/**
* Absolute / insistent phrasing. An instruction that has to shout is usually
* correcting behaviour rather than stating a fact.
*/
/**
* Word boundaries that understand æ/ø/å.
*
* JavaScript's `\b` is ASCII-only, so `/\bunngå\b/` never matches "unngå "
* the trailing "å" is not a word character, so there is no boundary after it.
* Every Norwegian keyword ending in æ/ø/å was silently dead until the dogfood
* run surfaced it. Do not reintroduce `\b` around this vocabulary.
*/
export const LB = '(?<![\\wæøåÆØÅ])';
export const RB = '(?![\\wæøåÆØÅ])';
const ABSOLUTE_RE = new RegExp(
LB +
'(?:never|always|avoid|don\'t|do not|must not|ensure|remember to|make sure|' +
'aldri|alltid|unngå|husk|sørg for|ikke)' +
RB,
'i',
);
/**
* Imperative verbs the grammatical signature of telling the model how to
* behave. Matched anywhere in the block, since Norwegian list prose puts them
* after a colon ("…oppgaver: forstå problemet, vurder alternativer").
*
* Word boundaries matter more than the list length: `\bdocument\b` must not
* match "documentation", or the declarative language-preference fact a floor
* block with no local literal to veto it would become a deletion candidate.
*/
const IMPERATIVE_RE = new RegExp(
LB +
'(?:' +
// English
'think|write|test|commit|use|read|check|ask|verify|stop|start|summarize|' +
'wait|match|change|fix|present|identify|keep|prefer|declare|refactor|' +
'document|explain|split|review|' +
// Norwegian
'tenk|skriv|test|commit|bruk|les|sjekk|spør|verifiser|dokumenter|stopp|' +
'start|oppsummer|vent|gjør|match|endre|fiks|presenter|identifiser|forstå|' +
'vurder|hold|siter|jobb|gjett|push|del|sett|forklar|utfør|følg' +
')' +
RB,
'i',
);
/**
* Minimum words for a block whose ONLY signal is an absolute marker. A bare
* "Haiku: aldri." is a declarative policy fact wearing the word "aldri", not an
* instruction about how to behave the same reason "Tone: direct and technical"
* never fires. Blocks carrying a real imperative verb are exempt from the floor,
* so "Test inkrementelt" still surfaces at two words.
*/
const ABSOLUTE_ONLY_MIN_WORDS = 6;
const HEADING_RE = /^\s*#{1,6}\s/;
const TABLE_RE = /^\s*\|/;
const FENCE_RE = /^\s*(?:```|~~~)/;
const LIST_ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
const ORDERED_ITEM_RE = /^\s*\d+[.)]\s+/;
const CONTINUATION_RE = /^\s+\S/;
/** A paragraph that introduces the list beneath it ("…tre lag med hver sin ene jobb:"). */
const STEM_RE = /:\s*$/;
/**
* Split markdown into leaf blocks.
*
* @param {string} text
* @returns {Array<{startLine:number, endLine:number, text:string, type:string}>}
* `type` is one of paragraph | list-item | heading | table | code.
*/
export function splitLeafBlocks(text) {
const lines = String(text == null ? '' : text).split('\n');
const blocks = [];
let current = null;
let inFence = false;
const flush = () => {
if (current) blocks.push(current);
current = null;
};
const indentOf = (line) => (line.match(/^\s*/) || [''])[0].length;
const open = (type, i, line) => {
current = { startLine: i + 1, endLine: i + 1, text: line, type, indent: indentOf(line) };
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (FENCE_RE.test(line)) {
flush();
inFence = !inFence;
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
continue;
}
if (inFence) {
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
continue;
}
if (line.trim() === '') {
flush();
continue;
}
if (HEADING_RE.test(line)) {
flush();
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'heading' });
continue;
}
if (TABLE_RE.test(line)) {
flush();
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'table' });
continue;
}
if (LIST_ITEM_RE.test(line)) {
// Structural exception 1: a paragraph ending in ':' is this list's stem —
// it introduces the items rather than standing alone, so it merges with
// them. Deleting a stem without its list is meaningless, and a stem often
// carries no literal of its own to protect it.
//
// A list item can be a stem too ("5. …alltid eksplisitt:" over its
// sub-bullets) — but only when the following item is nested deeper, or
// sibling bullets would glue together.
const isStem =
current &&
STEM_RE.test(current.text) &&
(current.type === 'paragraph' || indentOf(line) > current.indent);
if (isStem) {
current.type = 'list-item';
if (current.ordered === undefined) current.ordered = ORDERED_ITEM_RE.test(line);
current.endLine = i + 1;
current.text += '\n' + line;
current.stemMerged = true;
current.stemIndent = indentOf(line);
continue;
}
if (current && current.stemMerged && current.endLine === i && indentOf(line) >= current.stemIndent) {
// Subsequent items of the same stemmed list join it too.
current.endLine = i + 1;
current.text += '\n' + line;
continue;
}
// Otherwise a new list item always ends the previous block, even mid-list.
flush();
open('list-item', i, line);
current.ordered = ORDERED_ITEM_RE.test(line);
continue;
}
if (current && CONTINUATION_RE.test(line)) {
// Indented wrap — belongs to the block it continues.
current.endLine = i + 1;
current.text += '\n' + line;
continue;
}
if (current && current.type === 'paragraph') {
current.endLine = i + 1;
current.text += '\n' + line;
continue;
}
flush();
open('paragraph', i, line);
}
flush();
return blocks.sort((a, b) => a.startLine - b.startLine);
}
/** Blocks that can carry a deletable instruction at all. */
const isProse = (block) => block.type === 'paragraph' || block.type === 'list-item';
const wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length;
/**
* Does the block instruct behaviour at all? A declarative fact does not, however
* absolute its wording: "Haiku: aldri." and "Tone: direct and technical" both
* state a decision rather than correcting how the model works.
*/
function correctsBehaviour(text) {
if (IMPERATIVE_RE.test(text)) return true;
return ABSOLUTE_RE.test(text) && wordCount(text) >= ABSOLUTE_ONLY_MIN_WORDS;
}
/**
* Structural exception 2: an ordered list is a CONTRACT. Numbered steps are a
* sequence whose items reference each other, so a floor marker on any step
* floors the whole run deleting step 2 of a five-step session protocol is not
* the same kind of act as deleting one bullet from a list of platitudes.
*
* Unordered lists deliberately do NOT inherit. B-32a and B-32b are opposite
* calls inside one bullet list, and "the container decides" is precisely the
* reasoning the fasit exists to refute.
*
* @returns {Set<number>} startLine of every block floored by inheritance
*/
function orderedContractFloor(blocks, floored) {
const inherited = new Set();
let run = [];
const closeRun = () => {
if (run.length > 1 && run.some((b) => floored.has(b.startLine))) {
for (const b of run) inherited.add(b.startLine);
}
run = [];
};
for (const block of blocks) {
const contiguous = run.length > 0 && block.startLine === run[run.length - 1].endLine + 1;
if (block.ordered && (run.length === 0 || contiguous)) {
run.push(block);
} else {
closeRun();
if (block.ordered) run.push(block);
}
}
closeRun();
return inherited;
}
/**
* Compensatory-phrasing candidates that survived floor-exclusion.
*
* @param {string} text
* @returns {Array<{lensCheck:string, registerId:string, mechanism:string,
* line:number, startLine:number, endLine:number, lineCount:number, text:string}>}
*/
export function subtractionCandidates(text) {
const detector = SUBTRACT_DETECTORS[0];
const out = [];
const blocks = splitLeafBlocks(text).filter(isProse);
// 1. The blocking floor veto, evaluated over the WHOLE leaf block — so a
// literal on a wrapped continuation line still protects its opening line.
const floored = new Set();
for (const block of blocks) {
if (floorMarker(block.text)) floored.add(block.startLine);
}
// 2. …then propagated across ordered-list contracts.
const inherited = orderedContractFloor(blocks, floored);
for (const block of blocks) {
// 3. Does it correct behaviour at all? A declarative local fact does not.
if (!correctsBehaviour(block.text)) continue;
if (floored.has(block.startLine) || inherited.has(block.startLine)) continue;
out.push({
lensCheck: detector.lensCheck,
registerId: detector.registerId,
mechanism: detector.mechanism,
line: block.startLine,
startLine: block.startLine,
endLine: block.endLine,
lineCount: block.endLine - block.startLine + 1,
text: block.text.trim(),
});
}
return out;
}
/**
* Diagnostics for the floor gate: every prose block that fired the detector but
* was vetoed, with the marker that saved it. Not user-facing this is how a
* later narrowing of the veto can be checked against the fasit.
*
* @param {string} text
* @returns {Array<{startLine:number, endLine:number, marker:string, why:string}>}
*/
export function floorExcluded(text) {
const blocks = splitLeafBlocks(text).filter(isProse);
const floored = new Set();
for (const block of blocks) {
if (floorMarker(block.text)) floored.add(block.startLine);
}
const inherited = orderedContractFloor(blocks, floored);
const out = [];
for (const block of blocks) {
if (!correctsBehaviour(block.text)) continue;
const marker = floorMarker(block.text);
if (marker) {
out.push({ startLine: block.startLine, endLine: block.endLine, marker: marker.name, why: marker.why });
} else if (inherited.has(block.startLine)) {
out.push({
startLine: block.startLine,
endLine: block.endLine,
marker: 'ordered-contract',
why: 'is a step of an ordered list whose sibling carries a local fact',
});
}
}
return out;
}

View file

@ -0,0 +1,242 @@
/**
* subtraction-write the write half of `optimize --subtract` (§C6, chunk #63).
*
* This is the only path in the plugin that REMOVES configuration, so the split
* of labour matters more here than anywhere else: **the judgement is the
* agent's, the execution is deterministic.** Everything below is mechanical
* it verifies that the block it was told to remove is still exactly the block
* that is there, and refuses otherwise.
*
* ## Why this is not a `fix-engine` action
*
* Measured (#63): the subtraction axis appears nowhere in `scan-orchestrator`
* or `optimization-lens-scanner` it is computed inside `optimize-lens-cli`
* under `--subtract`. `fix-engine.verifyFixes()` marks a fix `verified` when
* the finding is absent from a re-scan, so a subtraction removal would be
* verified **whether or not the write happened**: a success-shaped no-op, the
* class that made `restoreBackup` silently do nothing before `parseManifest`
* learned the second manifest format. And `planFixes` keys on
* `finding.autoFixable` + `finding.title` from an envelope, neither of which an
* agent prose judgement has.
*
* Nor is it a `plan`/`implement` step: that pipeline runs on findings, and
* `finding-codes.mjs` declares exactly one `OPT` code for the deterministic
* check. Minting a code for a prose judgement breaks that module's invariant
* that a code names a deterministic CHECK.
*
* ## The floor, repeated rather than moved
*
* §C6 forbids migrating the floor into the write path. That forbids *moving*
* the veto, not *repeating* it: `subtraction-prefilter` still consults
* `floor-exclusion` before anything is ever proposed, and this module refuses a
* load-bearing block again as the last red line before an irreversible-by-
* reading delete. A caller that hand-builds an approval therefore cannot route
* around the floor.
*
* ## The archive question
*
* "`mv` to `_archive/`, never `rm`" is a FILE-level rule; nothing here deletes
* a file. The timestamped backup is the recovery artifact it holds the whole
* pre-removal file and `rollback` already restores it. The removed text also
* rides back in the payload so the caller can show and log it. A second archive
* copy with no restorer behind it would be worse than none.
*
* Zero external dependencies.
*/
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { isLoadBearing } from './floor-exclusion.mjs';
import { createBackup } from './backup.mjs';
import { classifyWriteTarget, strongestGate } from './write-scope.mjs';
/** Why a removal did not happen. Every refusal carries exactly one of these. */
export const REFUSAL_REASONS = Object.freeze({
/** The file no longer reads the way the approval says it does. */
BLOCK_MISMATCH: 'block-mismatch',
/** `floor-exclusion` vetoes the block — never removable, at any layer. */
FLOOR: 'floor',
/** The target's scope class needs an explicit go-ahead that was not given. */
SCOPE_GATE: 'scope-gate',
/** The file could not be read, so it can be neither backed up nor excised. */
UNREADABLE: 'unreadable',
/** The backup does not cover a file the run was about to write. */
BACKUP_INCOMPLETE: 'backup-incomplete',
});
/** True for a line that is empty or whitespace only. */
const isBlank = (line) => line === undefined || /^\s*$/.test(line);
/**
* Remove approved blocks from one file's content.
*
* Pure: no filesystem, no clock. Every span is validated against the ORIGINAL
* content and the removals are then applied in descending line order, so an
* earlier removal cannot shift a later span out from under itself the shape
* that made `fix-engine` apply a file-rename before a fix that still addressed
* the old path, failing with ENOENT while the run exited 0.
*
* @param {string} content - The file as it is on disk right now.
* @param {Array<{line:number, endLine:number, text:string}>} removals
* @returns {{content: string, applied: object[], refused: object[]}}
*/
export function exciseBlocks(content, removals) {
const lines = content.split('\n');
const applied = [];
const refused = [];
for (const removal of removals) {
const { line, endLine } = removal;
const inRange =
Number.isInteger(line) && Number.isInteger(endLine) &&
line >= 1 && endLine >= line && endLine <= lines.length;
if (!inRange) {
refused.push({ ...removal, reason: REFUSAL_REASONS.BLOCK_MISMATCH });
continue;
}
// The pre-filter reports `text: block.text.trim()`, so compare trimmed —
// a raw slice comparison would refuse every genuine approval.
const actual = lines.slice(line - 1, endLine).join('\n');
if (actual.trim() !== String(removal.text ?? '').trim()) {
refused.push({ ...removal, reason: REFUSAL_REASONS.BLOCK_MISMATCH });
continue;
}
// Judged on what is actually in the file, not on what the caller claims is.
if (isLoadBearing(actual)) {
refused.push({ ...removal, reason: REFUSAL_REASONS.FLOOR });
continue;
}
applied.push({ ...removal, text: actual.trim() });
}
const descending = [...applied].sort((a, b) => b.line - a.line);
for (const { line, endLine } of descending) {
lines.splice(line - 1, endLine - line + 1);
// A leaf block sits between blank lines; removing it leaves two in a row.
if (line >= 2 && isBlank(lines[line - 2]) && isBlank(lines[line - 1])) {
lines.splice(line - 1, 1);
}
}
return { content: lines.join('\n'), applied, refused };
}
/**
* Apply an approved subtraction set to disk, behind the scope gate and a
* verified backup.
*
* The run is all-or-nothing across files: a target that cannot be read aborts
* the whole set rather than applying half of one operator decision.
*
* @param {Array<{file:string, line:number, endLine:number, text:string}>} removals
* @param {object} [opts]
* @param {string|null} [opts.repoRoot] - Repo root the session stands in.
* @param {boolean} [opts.approveScope=false] - Operator's explicit go-ahead for a `require-ok` target.
* @param {boolean} [opts.dryRun=false]
* @param {string} [opts.home] - Home override, for tests.
* @returns {Promise<object>} Verdict payload never throws for a refused write.
*/
export async function applySubtraction(removals, opts = {}) {
const { repoRoot = null, approveScope = false, dryRun = false, home } = opts;
const normalized = removals.map((r) => ({ ...r, file: resolve(r.file) }));
const files = [...new Set(normalized.map((r) => r.file))];
const classifyOpts = home ? { home } : {};
const targets = files.map((f) => classifyWriteTarget(f, repoRoot, classifyOpts));
const gate = strongestGate(targets);
const disclosures = [...new Set(targets.map((t) => t.disclosure).filter(Boolean))];
const base = {
gate,
requiresApproval: gate === 'require-ok',
disclosures,
targets,
dryRun,
backupId: null,
applied: [],
refused: [],
filesWritten: [],
};
// The gate is a verdict about a write, not a tool failure: the caller renders
// the disclosure and asks. Nothing is written, and nothing is exit 3 (#62).
//
// `!dryRun` is load-bearing. The gate guards a WRITE, and a dry run is not
// one — refusing it early bought nothing and cost the dry run its whole
// purpose on the machine-wide target, which is the mandatory v1 case: the
// operator would approve a removal whose spans had never been checked, and
// the first run able to discover a stale approval would be the one that
// writes. `requiresApproval` is reported either way, so the caller still asks.
if (gate === 'require-ok' && !approveScope && !dryRun) {
return {
...base,
refused: normalized.map((r) => ({ ...r, reason: REFUSAL_REASONS.SCOPE_GATE })),
};
}
const contents = new Map();
const unreadable = [];
for (const file of files) {
try {
contents.set(file, await readFile(file, 'utf-8'));
} catch {
unreadable.push(file);
}
}
if (unreadable.length > 0) {
return {
...base,
refused: normalized.map((r) => ({
...r,
reason: unreadable.includes(r.file)
? REFUSAL_REASONS.UNREADABLE
: REFUSAL_REASONS.BACKUP_INCOMPLETE,
})),
};
}
const perFile = new Map();
const applied = [];
const refused = [];
for (const file of files) {
const result = exciseBlocks(contents.get(file), normalized.filter((r) => r.file === file));
perFile.set(file, result);
applied.push(...result.applied.map((a) => ({ ...a, file })));
refused.push(...result.refused.map((r) => ({ ...r, file })));
}
const toWrite = files.filter((f) => perFile.get(f).applied.length > 0);
if (dryRun || toWrite.length === 0) {
return { ...base, applied, refused };
}
// `createBackup` skips a path that does not exist and still returns a
// manifest and an id, so "a backup was made" is not evidence that THIS file
// is recoverable (M-BUG-31's shape). Assert coverage before writing anything.
const backup = createBackup(toWrite);
const covered = new Set(backup.manifest.files.map((f) => f.originalPath));
const uncovered = toWrite.filter((f) => !covered.has(f));
if (uncovered.length > 0) {
return {
...base,
backupId: backup.backupId,
applied: [],
refused: normalized.map((r) => ({ ...r, reason: REFUSAL_REASONS.BACKUP_INCOMPLETE })),
};
}
const filesWritten = [];
for (const file of toWrite) {
await writeFile(file, perFile.get(file).content, 'utf-8');
filesWritten.push(file);
}
return { ...base, backupId: backup.backupId, applied, refused, filesWritten };
}

View file

@ -8,6 +8,7 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { allFindingIds, knownScanners } from './finding-codes.mjs';
/**
* Load suppressions from .config-audit-ignore files.
@ -69,6 +70,39 @@ export function parseIgnoreFile(content) {
return suppressions;
}
/**
* Find suppression patterns that can never match anything.
*
* A finding ID names a check (see `finding-codes.mjs`), so an exact pin either
* names a declared check or names nothing at all. Silently keeping a dead pin
* would reproduce, in the other direction, the very failure the check-code
* scheme removed: the user believes a finding is suppressed when it is not.
*
* Globs are validated only down to the scanner prefix `CA-GAP-*` stays valid
* however GAP's checks change, which is why a glob is the safe way to pin.
*
* @param {Array<{ pattern: string }>} suppressions
* @returns {string[]} patterns that match no declared check
*/
export function unknownSuppressions(suppressions) {
if (!suppressions || suppressions.length === 0) return [];
const ids = allFindingIds();
const scanners = new Set(knownScanners());
const unknown = [];
for (const { pattern } of suppressions) {
if (pattern.endsWith('-*')) {
const scanner = pattern.slice(3, -2); // "CA-GAP-*" → "GAP"
if (!scanners.has(scanner)) unknown.push(pattern);
continue;
}
if (!ids.has(pattern)) unknown.push(pattern);
}
return unknown;
}
/**
* Apply suppressions to a findings array.
* @param {object[]} findings - Array of finding objects with .id

View file

@ -0,0 +1,39 @@
/**
* write-output the one place a scanner's `--output-file` payload is written.
*
* Every command in this plugin follows the same contract (`.claude/rules/ux-rules.md`):
* run the scanner with `--output-file <path> 2>/dev/null`, check the exit code, then Read
* the file. The path the command chooses is frequently one it has never created e.g.
* `commands/campaign.md` writes its report to
* `~/.claude/config-audit/sessions/campaign-report.json`, which on a fresh machine does not
* exist yet. That is precisely the FIRST run, the case campaign-cli otherwise handles
* gracefully by reporting `initialized: false`.
*
* Before this helper existed, all 13 payload writers called `writeFile` directly and threw
* ENOENT there. The exit code was 3, and the command's own exit-code table reads 3 as "the
* input is missing or corrupt" so the user was told the ledger might be corrupt and
* warned off the one action that would have fixed anything. `saveLedger` had always created
* its parent directory; the payload write simply never did. The asymmetry was accidental.
*
* Creating the parent is the honest behaviour: the caller asked for a file at a path, and
* nothing about a missing intermediate directory is an error the caller can learn from.
*/
import { writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
/**
* Write a scanner payload, creating the parent directory if needed.
*
* Signature-compatible with `writeFile(path, contents, encoding)` so call sites are a pure
* rename the encoding argument is kept rather than defaulted away.
*
* @param {string} path - destination file
* @param {string} contents - serialized payload
* @param {string} [encoding='utf-8']
* @returns {Promise<void>}
*/
export async function writeOutputFile(path, contents, encoding = 'utf-8') {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, encoding);
}

View file

@ -0,0 +1,242 @@
/**
* 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 whole write SET and reduce it to one verdict (Q1).
*
* Every gated arm needs the same four things classify each target, take the
* strongest gate, de-duplicate the disclosures, report whether approval is owed
* and `lib/subtraction-write.mjs` was the only arm that had them, written
* inline. Four more call sites copying those four lines is precisely the shape
* `SCOPE_CLASSES` exists to prevent one level down: the copies drift, and the
* drift is invisible because each one still looks correct on its own.
*
* This decides nothing about whether a write is a good idea, and it never
* writes. It answers "what does this set of targets oblige you to say?".
*
* Note the strict default: `sessionRepoRoot` of `null` means the `in-repo`
* class can never match, so an omitted repo root fails toward MORE disclosure,
* not less. A caller that forgets to pass it gets a noisier gate rather than a
* silent one.
*
* @param {string[]} paths - Paths about to be written. Duplicates are fine.
* @param {string|null} sessionRepoRoot - Repo root of the current session.
* @param {object} [options] - Forwarded to `classifyWriteTarget`.
* @returns {{gate: string, requiresApproval: boolean, disclosures: string[], targets: object[]}}
*/
export function evaluateWriteTargets(paths, sessionRepoRoot, options = {}) {
const unique = [...new Set(paths.map((p) => resolve(p)))];
const targets = unique.map((p) => classifyWriteTarget(p, sessionRepoRoot, options));
const gate = strongestGate(targets);
return {
gate,
requiresApproval: gate === 'require-ok',
disclosures: [...new Set(targets.map((t) => t.disclosure).filter(Boolean))],
targets,
};
}
/**
* 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}`);
}

View file

@ -40,8 +40,13 @@
*/
import { resolve } from 'node:path';
import { writeFile, stat } from 'node:fs/promises';
import { stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = { boolean: ['--json', '--raw'], value: ['--output-file'] };
// CLAUDE.md cascade files are all discovered by walking UP from the repo, so
// each one is always-loaded; the scope only changes the derivation confidence.
@ -114,6 +119,10 @@ export function buildManifest(activeConfig) {
name: a.name,
source: sourceLabel(a, 'project'),
estimated_tokens: a.estimatedTokens || 0,
// Routing axes (C4) — named explicitly because withLoadPattern copies the
// row plus the load-pattern triple, nothing else from the enumeration.
model: a.model ?? null,
effort: a.effort ?? null,
}, a));
}
@ -240,6 +249,7 @@ function estimateClaudeMdEntryTokens(file, activeConfig) {
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let targetPath = '.';
let outputFile = null;
let jsonMode = false;
@ -259,11 +269,13 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
process.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
process.exitCode = 3;
return;
}
const start = Date.now();
@ -285,7 +297,7 @@ async function main() {
const json = JSON.stringify(output, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');
await writeOutputFile(outputFile, json, 'utf-8');
}
if (jsonMode || rawMode || !outputFile) {
@ -297,6 +309,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -56,6 +56,7 @@ export async function scan(targetPath, discovery) {
if (!parsed) {
findings.push(finding({
scanner: SCANNER,
code: 'invalid-json',
severity: SEVERITY.critical,
title: 'Invalid JSON in MCP config',
description: `${file.relPath}: Failed to parse as JSON.`,
@ -75,6 +76,7 @@ export async function scan(targetPath, discovery) {
if (config.type && !VALID_SERVER_TYPES.has(config.type)) {
findings.push(finding({
scanner: SCANNER,
code: 'unknown-server-type',
severity: SEVERITY.high,
title: 'Unknown MCP server type',
description: `${file.relPath}: Server "${name}" has unknown type "${config.type}".`,
@ -88,6 +90,7 @@ export async function scan(targetPath, discovery) {
if (config.type === 'sse') {
findings.push(finding({
scanner: SCANNER,
code: 'sse-transport',
severity: SEVERITY.info,
title: 'SSE server type — consider HTTP',
description: `${file.relPath}: Server "${name}" uses "sse" type. The "http" type is the current standard.`,
@ -110,6 +113,7 @@ export async function scan(targetPath, discovery) {
if (!hasEnvBlock) {
findings.push(finding({
scanner: SCANNER,
code: 'unreferenced-env-var',
severity: SEVERITY.medium,
title: 'Unreferenced env var in args',
description: `${file.relPath}: Server "${name}" references \${${varName}} in args but has no env block defining it.`,
@ -127,6 +131,7 @@ export async function scan(targetPath, discovery) {
if (!VALID_SERVER_FIELDS.has(key)) {
findings.push(finding({
scanner: SCANNER,
code: 'unknown-server-field',
severity: SEVERITY.medium,
title: 'Unknown MCP server field',
description: `${file.relPath}: Server "${name}" has unknown field "${key}".`,

View file

@ -122,6 +122,7 @@ export async function scan(targetPath, discovery) {
findings.push(
finding({
scanner: SCANNER,
code: 'procedure-should-be-skill',
severity: SEVERITY.low,
title: PROCEDURE_TITLE,
description: claim,

View file

@ -20,18 +20,45 @@
*
* Usage:
* node optimize-lens-cli.mjs [path] [--output-file <path>] [--global]
* [--subtract [--for-model <name>]]
*
* `--for-model <name>` annotates the subtraction candidates a named model
* documents as redundant (BP-PROMPT-001). It never widens the candidate set,
* and there is deliberately no auto-detection: a CLAUDE.md has no frontmatter
* and no statically-resolvable target model, so the model must be named.
*
* Exit codes: 0=ok, 3=unrecoverable error. Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeFile, readFile, stat } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { readFile, stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
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 { matchModelScope, normalizeModel } from './lib/prompting-model-scope.mjs';
import { scan as optScan } from './optimization-lens-scanner.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = {
boolean: ['--global', '--subtract'],
// `--for-model` is a VALUE flag, so a bare `--for-model` is reported as
// "needs a value" rather than "unknown flag" — the two are different failures
// and reporting the wrong one hides which mistake the caller made.
value: ['--output-file', '--for-model'],
};
// 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) {
@ -41,13 +68,18 @@ function confirmedEntry(register, id) {
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let targetPath = '.';
let outputFile = null;
let includeGlobal = false;
let subtract = false;
let targetModel = null;
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] === '--for-model' && args[i + 1]) targetModel = args[++i];
else if (!args[i].startsWith('-')) targetPath = args[i];
}
@ -56,11 +88,13 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
process.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
process.exitCode = 3;
return;
}
// Load the register once; tolerate its absence (deterministic half still runs).
@ -71,8 +105,13 @@ async function main() {
register = null;
}
resetCounter();
const discovery = await discoverConfigFiles(absPath, { includeGlobal });
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);
@ -80,6 +119,28 @@ async function main() {
// ── 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;
// Model-scoped annotation entries (BP-PROMPT-001 and any later sibling). These
// never produce candidates of their own — they only tag candidates the
// BP-SUB-001 detector above already surfaced.
const promptEntries =
subtract && register
? (register.entries || []).filter(
(e) => e.category === 'prompting-fit' && e.confidence === 'confirmed',
)
: [];
// `recognized` is reported separately from the match count so a typo'd model
// name is distinguishable from a config that genuinely carries nothing.
const modelRecognized =
!!targetModel &&
promptEntries.some((e) =>
(e.modelScope || []).some((m) => normalizeModel(m) === normalizeModel(targetModel)),
);
let modelMatchedCount = 0;
for (const file of claudeMdFiles) {
let content;
try {
@ -90,11 +151,42 @@ async function main() {
const parsed = parseFrontmatter(content);
const body = parsed.body || content;
const bodyStartLine = parsed.bodyStartLine || 1;
if (subtractEntry) {
for (const cand of subtractionCandidates(body)) {
const modelScope = matchModelScope(cand.text, targetModel, promptEntries);
if (modelScope) modelMatchedCount++;
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,
},
// Spread only when matched: a run without --for-model must not grow
// even a key set to undefined.
...(modelScope ? { modelScope } : {}),
});
}
}
for (const cand of prefilterClaudeMd(body)) {
const entry = register ? confirmedEntry(register, cand.registerId) : null;
if (!entry) continue; // never surface an unverifiable recommendation
candidates.push({
file: file.relPath || file.absPath,
// 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,
@ -142,9 +234,41 @@ async function main() {
},
};
// 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 })),
};
// Present ONLY when a model was named — a plain --subtract run stays
// byte-identical to the pre-flag payload.
if (targetModel) {
payload.subtract.forModel = {
requested: targetModel,
recognized: modelRecognized,
matchedCount: modelMatchedCount,
};
}
payload.counts.subtractCandidates = subtractCands.length;
}
const json = JSON.stringify(payload, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');
await writeOutputFile(outputFile, json, 'utf-8');
}
if (!outputFile) {
process.stdout.write(json + '\n');
@ -155,6 +279,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch((err) => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -100,6 +100,7 @@ export async function scan(targetPath, _discovery) {
if (kci === true) continue;
findings.push(finding({
scanner: SCANNER,
code: 'strips-coding-instructions',
severity: SEVERITY.medium,
title: 'Custom output style removes built-in coding instructions',
description:
@ -127,6 +128,7 @@ export async function scan(targetPath, _discovery) {
if (ffp !== true) continue;
findings.push(finding({
scanner: SCANNER,
code: 'plugin-forces-style',
severity: SEVERITY.low,
title: 'Plugin output style overrides your selected output style',
description:
@ -155,6 +157,7 @@ export async function scan(targetPath, _discovery) {
const customNames = styles.map(s => s.name);
findings.push(finding({
scanner: SCANNER,
code: 'style-not-found',
severity: SEVERITY.medium,
title: 'Configured output style does not exist',
description:

View file

@ -9,8 +9,10 @@
*/
import { readdir, stat, readFile } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { join, basename, resolve, sep } from 'node:path';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
@ -220,6 +222,7 @@ async function scanSinglePlugin(pluginDir) {
} catch {
findings.push(finding({
scanner: SCANNER,
code: 'invalid-plugin-json',
severity: SEVERITY.critical,
title: 'Invalid plugin.json',
description: `plugin.json is not valid JSON in ${pluginName}`,
@ -236,6 +239,7 @@ async function scanSinglePlugin(pluginDir) {
if (!parsed[field]) {
findings.push(finding({
scanner: SCANNER,
code: 'missing-required-field',
severity: SEVERITY.high,
title: `Missing required field in plugin.json: ${field}`,
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
@ -257,6 +261,7 @@ async function scanSinglePlugin(pluginDir) {
if (!(await dirExists(join(pluginDir, defaultDir)))) continue;
findings.push(finding({
scanner: SCANNER,
code: 'plugin-json-shadows-default',
severity: SEVERITY.medium,
title: `plugin.json "${key}" path shadows the default ${defaultDir}/ folder`,
description:
@ -296,6 +301,7 @@ async function scanSinglePlugin(pluginDir) {
const m = SKILLS_ENTRY_MESSAGES[problem];
findings.push(finding({
scanner: SCANNER,
code: 'skills-array-entry',
severity: SEVERITY.medium,
title: m.title(entry),
description: `Plugin "${pluginName}": ${m.description}`,
@ -311,6 +317,7 @@ async function scanSinglePlugin(pluginDir) {
} catch {
findings.push(finding({
scanner: SCANNER,
code: 'missing-plugin-json',
severity: SEVERITY.critical,
title: 'Missing plugin.json',
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
@ -336,6 +343,7 @@ async function scanSinglePlugin(pluginDir) {
if (!hasSection) {
findings.push(finding({
scanner: SCANNER,
code: 'claude-md-missing-section',
severity: SEVERITY.medium,
title: `CLAUDE.md missing ${section} section`,
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
@ -347,6 +355,7 @@ async function scanSinglePlugin(pluginDir) {
} catch {
findings.push(finding({
scanner: SCANNER,
code: 'missing-claude-md',
severity: SEVERITY.high,
title: 'Missing CLAUDE.md',
description: `Plugin "${pluginName}" has no CLAUDE.md`,
@ -370,6 +379,7 @@ async function scanSinglePlugin(pluginDir) {
if (!frontmatter) {
findings.push(finding({
scanner: SCANNER,
code: 'command-missing-frontmatter',
severity: SEVERITY.high,
title: 'Command missing frontmatter',
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
@ -383,6 +393,7 @@ async function scanSinglePlugin(pluginDir) {
if (!frontmatter[key]) {
findings.push(finding({
scanner: SCANNER,
code: 'command-missing-field',
severity: SEVERITY.medium,
title: `Command missing frontmatter field: ${display}`,
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
@ -409,6 +420,7 @@ async function scanSinglePlugin(pluginDir) {
if (!frontmatter) {
findings.push(finding({
scanner: SCANNER,
code: 'agent-missing-frontmatter',
severity: SEVERITY.high,
title: 'Agent missing frontmatter',
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
@ -422,6 +434,7 @@ async function scanSinglePlugin(pluginDir) {
if (!frontmatter[key]) {
findings.push(finding({
scanner: SCANNER,
code: 'agent-missing-field',
severity: SEVERITY.medium,
title: `Agent missing frontmatter field: ${display}`,
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
@ -437,6 +450,7 @@ async function scanSinglePlugin(pluginDir) {
if (frontmatter[key] !== undefined) {
findings.push(finding({
scanner: SCANNER,
code: 'agent-ignored-key',
severity,
title: `Plugin agent sets "${key}", which Claude Code ignores`,
description: `Agent "${file}" in plugin "${pluginName}" sets "${key}" in frontmatter, but Claude Code ignores ${key} for plugin subagents — ${key === 'permissionMode' ? 'the agent runs with default permissions, not the restricted mode this implies' : 'this configuration has no effect'}.`,
@ -459,6 +473,7 @@ async function scanSinglePlugin(pluginDir) {
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
findings.push(finding({
scanner: SCANNER,
code: 'hooks-json-invalid-structure',
severity: SEVERITY.high,
title: 'Invalid hooks.json structure',
description: `hooks.json in "${pluginName}" missing "hooks" object`,
@ -468,6 +483,7 @@ async function scanSinglePlugin(pluginDir) {
} else if (Array.isArray(parsed.hooks)) {
findings.push(finding({
scanner: SCANNER,
code: 'hooks-json-array',
severity: SEVERITY.high,
title: 'hooks.json uses array instead of object',
description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
@ -478,6 +494,7 @@ async function scanSinglePlugin(pluginDir) {
} catch {
findings.push(finding({
scanner: SCANNER,
code: 'hooks-json-invalid',
severity: SEVERITY.high,
title: 'Invalid hooks.json',
description: `hooks.json is not valid JSON in "${pluginName}"`,
@ -490,11 +507,18 @@ async function scanSinglePlugin(pluginDir) {
const pluginMetaDir = join(pluginDir, '.claude-plugin');
try {
const entries = await readdir(pluginMetaDir);
const known = new Set(['plugin.json']);
// `marketplace.json` belongs here: it is the documented, required location
// for a marketplace catalog (code.claude.com/docs plugin-marketplaces —
// "Create `.claude-plugin/marketplace.json` in your repository root"), and a
// marketplace entry with `"source": "./"` makes the repo root its own
// plugin. Such a repo legitimately carries both files, so flagging the
// catalog as an unknown file was a false positive.
const known = new Set(['plugin.json', 'marketplace.json']);
for (const entry of entries) {
if (!known.has(entry)) {
findings.push(finding({
scanner: SCANNER,
code: 'unknown-plugin-file',
severity: SEVERITY.low,
title: 'Unknown file in .claude-plugin/',
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
@ -508,27 +532,62 @@ async function scanSinglePlugin(pluginDir) {
return { name: pluginName, declaredName, findings, commandCount, agentCount };
}
/**
* Per-plugin score and grade. Single source for both the terminal report and
* the --output-file payload the grade formula used to live only inside
* `formatPluginHealthReport`, which nothing called.
* @param {number} issueCount
* @returns {{ score: number, grade: string }}
*/
export function pluginGrade(issueCount) {
const score = Math.max(0, 100 - issueCount * 10);
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
return { score, grade };
}
/**
* Scan one or more plugins and return aggregated results.
*
* The envelope is frozen at the v5.0.0 shape (byte-stable `--raw`/`--json`), so
* per-plugin rows and the cross-plugin/per-plugin split are NOT in it. Callers
* that need those the `--output-file` payload, and therefore
* `/config-audit plugin-health` use `scanDetailed`.
*
* @param {string} targetPath - Plugin dir or marketplace root
* @returns {Promise<object>} Scanner result
*/
export async function scan(targetPath) {
return (await scanDetailed(targetPath)).result;
}
/**
* Scan, and also return what `scan()`'s frozen envelope cannot carry: one row
* per plugin (name, declared namespace, component counts, grade) and the
* cross-plugin findings as a distinct set.
*
* @param {string} targetPath - Plugin dir or marketplace root
* @returns {Promise<{ result: object, plugins: object[], crossPluginFindings: object[] }>}
*/
export async function scanDetailed(targetPath) {
const start = Date.now();
resetCounter();
const pluginDirs = await discoverPlugins(resolve(targetPath));
if (pluginDirs.length === 0) {
return scannerResult(SCANNER, 'ok', [
return {
result: scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
code: 'no-plugins-found',
severity: SEVERITY.info,
title: 'No plugins found',
description: `No Claude Code plugins found under ${targetPath}`,
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
}),
], 0, Date.now() - start);
], 0, Date.now() - start),
plugins: [],
crossPluginFindings: [],
};
}
const allFindings = [];
@ -540,6 +599,12 @@ export async function scan(targetPath) {
allFindings.push(...result.findings);
}
// Everything pushed from here on is a cross-plugin finding — the boundary the
// payload uses to split them out (they are flattened into `findings` in the
// frozen envelope, where `category: 'plugin-hygiene'` cannot tell them apart
// from the per-plugin shadow/skills findings that share it).
const crossPluginStart = allFindings.length;
// Cross-plugin checks: command-name ambiguity across DIFFERENT plugin namespaces.
// Commands are namespaced by the plugin's declared name (/name:command), so a
// shared command name across DIFFERENT plugins is ambiguity — not a hard
@ -572,6 +637,7 @@ export async function scan(targetPath) {
const namespaceList = entries.map(e => e.namespace).join(', ');
allFindings.push(finding({
scanner: SCANNER,
code: 'command-name-collision',
severity: SEVERITY.low,
title: `Command name "${cmdName}" used by multiple plugins`,
description:
@ -607,6 +673,7 @@ export async function scan(targetPath) {
if (dirs.length < 2) continue;
allFindings.push(finding({
scanner: SCANNER,
code: 'namespace-collision',
severity: SEVERITY.medium,
title: `Plugin namespace collision: "${declaredName}"`,
description:
@ -632,7 +699,19 @@ export async function scan(targetPath) {
}));
}
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
return {
result: scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start),
plugins: pluginResults.map((p, idx) => ({
name: p.name,
declaredName: p.declaredName,
path: pluginDirs[idx],
commandCount: p.commandCount,
agentCount: p.agentCount,
findingCount: p.findings.length,
...pluginGrade(p.findings.length),
})),
crossPluginFindings: allFindings.slice(crossPluginStart),
};
}
/**
@ -649,9 +728,7 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
lines.push('');
for (const p of pluginResults) {
const issueCount = p.findings.length;
const score = Math.max(0, 100 - issueCount * 10);
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
const { score, grade } = pluginGrade(p.findings.length);
const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
}
@ -675,27 +752,55 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
}
// --- CLI entry point ---
const BOOL_FLAGS = ['--json', '--raw'];
const VALUE_FLAGS = ['--output-file'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let jsonMode = false;
let rawMode = false;
let outputFile = null;
// M-BUG-21, third arm: this loop used to end in
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
// unknown-flag branch. An unrecognised flag was dropped silently and its
// VALUE became the scan target, so `--output-file /tmp/x.json` scanned
// /tmp/x.json. Unlike drift-cli, the result LOOKS fine: a non-existent path
// discovers no plugins, so the scanner reported "No plugins found" (info) and
// exit 0 — a green answer to a question nobody asked. Now it fails loudly.
for (let i = 0; i < args.length; i++) {
if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--raw') {
rawMode = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
const arg = args[i];
if (BOOL_FLAGS.includes(arg)) {
if (arg === '--json') jsonMode = true;
else if (arg === '--raw') rawMode = true;
} else if (VALUE_FLAGS.includes(arg)) {
const value = args[i + 1];
if (value === undefined || value.startsWith('-')) {
throw new Error(`Option ${arg} requires a value.`);
}
outputFile = value;
i++;
} else if (arg.startsWith('-')) {
throw new Error(
`Unknown option: ${arg}\n` +
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
);
} else {
targetPath = arg;
}
}
if (!(await requireTargetDir(resolve(targetPath)))) {
process.exitCode = 3;
return;
}
const humanizedProgress = !jsonMode && !rawMode;
process.stderr.write(humanizedProgress ? `Plugin Health v2.1.0\n` : `Plugin Health Scanner v2.1.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
const result = await scan(targetPath);
const { result, plugins, crossPluginFindings } = await scanDetailed(targetPath);
if (jsonMode || rawMode) {
// --json and --raw both write the v5.0.0-shape result (byte-identical).
@ -708,6 +813,24 @@ async function main() {
for (const f of findings) {
process.stderr.write(` [${f.severity}] ${f.title}\n`);
}
// ux-rules rule 2: the command runs with `2>/dev/null`, so anything it must
// ACT on has to ride in the --output-file payload. Everything above this
// point is stderr, i.e. invisible to `/config-audit plugin-health`.
if (outputFile) {
const crossIds = new Set(crossPluginFindings.map(f => f.id));
for (const f of findings) {
if (crossIds.has(f.id)) f.crossPlugin = true;
}
const payload = {
...result,
findings,
plugins,
cross_plugin_findings: findings.filter(f => crossIds.has(f.id)),
};
await writeOutputFile(outputFile, JSON.stringify(payload, null, 2), 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
}
}
@ -715,6 +838,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -8,8 +8,11 @@
*/
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { humanizeEnvelope } from './lib/humanizer.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import {
calculateUtilization,
determineMaturityLevel,
@ -20,6 +23,12 @@ import {
generateHealthScorecard,
} from './lib/scoring.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = {
boolean: ['--json', '--raw', '--global', '--full-machine', '--include-fixtures'],
value: ['--output-file', '--context-window'],
};
/**
* Run posture assessment and return structured result.
* @param {string} targetPath
@ -57,6 +66,7 @@ export async function runPosture(targetPath, opts = {}) {
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let targetPath = '.';
let outputFile = null;
let jsonMode = false;
@ -85,6 +95,11 @@ async function main() {
}
}
if (!(await requireTargetDir(resolve(targetPath)))) {
process.exitCode = 3;
return;
}
const filterFixtures = !args.includes('--include-fixtures');
const humanizedProgress = !jsonMode && !rawMode;
const result = await runPosture(targetPath, {
@ -114,8 +129,15 @@ async function main() {
}
if (outputFile) {
const json = JSON.stringify(result, null, 2);
await writeFile(outputFile, json, 'utf-8');
// Consumers (feature-gap.md, posture.md) read scannerEnvelope.scanners[].findings
// and group on humanizer fields. posture's result nests the envelope under
// `scannerEnvelope`, so humanize THAT (not `result`, which has no top-level
// `scanners` array — humanizeEnvelope would no-op). --json/--raw stay raw.
const fileEnv = (jsonMode || rawMode)
? result
: { ...result, scannerEnvelope: humanizeEnvelope(result.scannerEnvelope) };
const json = JSON.stringify(fileEnv, null, 2);
await writeOutputFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
}
@ -125,6 +147,10 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(1);
// 3, not 1. Every command in this plugin is told that 0/1/2 are normal grades
// (PASS/WARNING/FAIL) and only 3 is a real error, so exiting 1 here made a crash
// indistinguishable from a WARNING — and the command went on to Read a payload file
// that was never written.
process.exitCode = 3;
});
}

208
scanners/rollback-cli.mjs Normal file
View file

@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* Config-Audit Rollback CLI the runnable entry to the backup/restore engine.
*
* `rollback-engine.mjs` has verified checksums before AND after each write,
* resolved the pre-v2.2.0 backup root and reported `createdNotRemoved` since
* M-BUG-22/M-BUG-25. None of it was reachable: measured at the head of this
* chunk, 16 files under `scanners/` carried a `process.argv` entry and the
* engine was not one of them. `commands/rollback.md` drove the restore as model
* prose an ESM `import` block a template cannot execute, with ad-hoc `cp`
* offered underneath as the runnable alternative and "(checksum verified)"
* pre-rendered in the success output. `cp` establishes no checksum, so the
* verification was a property of the template rather than of the run (R1).
*
* `--create` lives here for the same reason `--restore` does. The implement
* pipeline used to build its backup by hand `mkdir`, `cp`, a `date`-derived
* id and a manifest typed out in the template while `parseManifest` knew one
* frozen sample of that format, pinned by a hand-written fixture rather than by
* the template's own text. One side of that contract was maintained by editing
* prose (R2). Now both sides are `lib/backup.mjs`.
*
* Usage:
* node rollback-cli.mjs [--list]
* node rollback-cli.mjs --restore <backup-id> [--dry-run] [--approve-scope]
* node rollback-cli.mjs --delete <backup-id>
* node rollback-cli.mjs --create --target <path> [--target <path> ...]
* [--created <path> ...] [--backup-id <id>]
* ... plus [--repo <session-root>] [--output-file <path>] [--json]
*
* Exit codes:
* 0 done, nothing owed
* 1 the run completed but something is outstanding a restore the scope
* gate will not perform without `--approve-scope` (nothing was written),
* or a backup that covered fewer targets than it was given
* 2 at least one file failed to restore (checksum mismatch or write error)
* 3 the CLI could not do its job malformed argv, or a backup id that
* resolves in neither root
*
* A gated restore is exit 1, not 3: "this write leaves your project" is a
* verdict about a write that WAS examined, and it rides in the payload where a
* command can act on it. Anything that only reaches stderr is invisible to a
* command running under `2>/dev/null` (F3's class).
*
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import { createBackup, getBackupDir, getLegacyBackupDir } from './lib/backup.mjs';
import { listBackups, restoreBackup, deleteBackup } from './rollback-engine.mjs';
/** Flag surface. Anything else is exit 3. */
const ARG_SPEC = {
boolean: ['--list', '--create', '--dry-run', '--approve-scope', '--json'],
value: ['--restore', '--delete', '--target', '--created', '--backup-id', '--repo', '--output-file'],
};
/** The mode flags, in the order a diagnostic should name them. */
const MODES = ['--list', '--create', '--restore', '--delete'];
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exitCode = 3;
}
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const targets = [];
const created = [];
let mode = null;
let backupId = null;
let dryRun = false;
let approveScope = false;
let jsonMode = false;
let outputFile = null;
let overrideId = null;
// The session's root, never a path derived from the backup (#63). A restore
// writes to the ABSOLUTE originals recorded at backup time, so reading the
// root off those paths would call a machine-wide write "in-repo" and silence
// the strongest gate exactly where it matters.
let repoRoot = process.cwd();
const setMode = (flag) => {
if (mode !== null && mode !== flag) return false;
mode = flag;
return true;
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (MODES.includes(a)) {
if (!setMode(a)) {
// Silently letting the last one win is how a `--delete` rides along
// behind a `--list` the caller thought it was running.
fail(`only one mode may be given (${MODES.join(', ')}); saw "${mode}" and "${a}"`);
return;
}
if (a === '--restore' || a === '--delete') backupId = args[++i];
} else if (a === '--target') targets.push(args[++i]);
else if (a === '--created') created.push(args[++i]);
else if (a === '--backup-id') overrideId = args[++i];
else if (a === '--repo') repoRoot = args[++i];
else if (a === '--output-file') outputFile = args[++i];
else if (a === '--dry-run') dryRun = true;
else if (a === '--approve-scope') approveScope = true;
else if (a === '--json') jsonMode = true;
}
if (mode === null) mode = '--list';
const meta = {
mode: mode.slice(2),
backupRoot: getBackupDir(),
legacyBackupRoot: getLegacyBackupDir(),
repo: resolve(repoRoot),
};
let payload;
let lines = [];
if (mode === '--list') {
const { backups } = await listBackups();
payload = { meta, count: backups.length, backups };
lines = backups.map((b) => `${b.id}\t${b.files.length}\t${b.legacy ? 'legacy' : 'current'}`);
} else if (mode === '--create') {
if (targets.length === 0) {
fail('--create needs at least one --target');
return;
}
const result = createBackup(targets, {
...(overrideId ? { backupId: overrideId } : {}),
created,
});
payload = {
meta,
backupId: result.backupId,
backupPath: result.backupPath,
files: result.manifest.files,
created: result.manifest.created,
skipped: result.skipped,
};
lines = [`${result.backupId}\t${result.manifest.files.length}\t${result.skipped.length}`];
// A backup that covers fewer files than it was asked for is the state the
// caller must not mistake for a clean one: it is about to edit a file it
// cannot roll back.
if (result.skipped.length > 0) process.exitCode = 1;
} else if (mode === '--delete') {
const result = await deleteBackup(backupId);
if (!result.deleted) {
fail(result.error);
return;
}
payload = { meta, backupId, deleted: true, error: null };
lines = [`${backupId}\tdeleted`];
} else {
let result;
try {
result = await restoreBackup(backupId, { dryRun, approveScope, repoRoot });
} catch (err) {
// "Backup not found" and "unreadable manifest" are both the CLI failing to
// do its job, never a verdict about a restore that happened.
fail(err.message);
return;
}
payload = {
meta,
backupId,
dryRun,
gate: result.gate,
requiresApproval: result.requiresApproval,
disclosures: result.disclosures,
restored: result.restored,
failed: result.failed,
refused: result.refused,
createdNotRemoved: result.createdNotRemoved ?? [],
legacy: result.legacy ?? false,
};
lines = [
...result.restored.map((r) => `${r.status}\t${r.originalPath}`),
...result.failed.map((f) => `${f.status}\t${f.originalPath}`),
...result.refused.map((r) => `${r.status}\t${r.originalPath}`),
];
if (result.failed.length > 0) process.exitCode = 2;
else if (payload.requiresApproval && !approveScope && !dryRun) process.exitCode = 1;
}
const json = `${JSON.stringify(payload, null, 2)}\n`;
if (outputFile) {
await writeOutputFile(outputFile, json);
// Nothing on stdout when writing to a file — a command rendering this would
// otherwise show the user the raw payload (ux-rules rule 1).
} else if (jsonMode) {
process.stdout.write(json);
} else {
for (const line of lines) process.stdout.write(`${line}\n`);
}
}
try {
await main();
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exitCode = 3;
}

View file

@ -6,25 +6,47 @@
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getBackupDir, parseManifest, checksum } from './lib/backup.mjs';
import { getBackupDir, getLegacyBackupDir, parseManifest, checksum } from './lib/backup.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
/**
* Resolve a backup id to its directory, canonical root first, then the
* pre-v2.2.0 root. Returns null when the id exists in neither.
* @param {string} backupId
* @returns {Promise<{ path: string, legacy: boolean } | null>}
*/
async function resolveBackupPath(backupId) {
for (const [root, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
const candidate = join(root, backupId);
try {
await stat(join(candidate, 'manifest.yaml'));
return { path: candidate, legacy };
} catch {
// try the next root
}
}
return null;
}
/**
* List all available backups.
* @returns {Promise<{ backups: object[] }>}
*/
export async function listBackups() {
const backupRoot = getBackupDir();
const backups = [];
const seen = new Set();
// Canonical root first; a legacy backup with the same id must not shadow it.
for (const [backupRoot, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
let entries;
try {
entries = await readdir(backupRoot, { withFileTypes: true });
} catch {
return { backups: [] };
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.isDirectory() || seen.has(entry.name)) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
@ -33,21 +55,25 @@ export async function listBackups() {
const manifestContent = await readFile(manifestPath, 'utf-8');
const manifest = parseManifest(manifestContent);
seen.add(entry.name);
backups.push({
id: entry.name,
createdAt: manifest.created_at,
legacy,
files: manifest.files.map(f => ({
originalPath: f.originalPath,
backupPath: f.backupPath,
checksum: f.checksum,
sizeBytes: f.sizeBytes,
})),
created: manifest.created,
});
} catch {
// Skip backups without valid manifest
continue;
}
}
}
// Sort newest first
backups.sort((a, b) => b.id.localeCompare(a.id));
@ -65,22 +91,51 @@ export async function listBackups() {
*/
export async function restoreBackup(backupId, opts = {}) {
const verify = opts.verify !== false;
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const manifestPath = join(backupPath, 'manifest.yaml');
const resolved = await resolveBackupPath(backupId);
if (!resolved) throw new Error(`Backup not found: ${backupId}`);
// Read manifest
let manifestContent;
try {
manifestContent = await readFile(manifestPath, 'utf-8');
} catch {
throw new Error(`Backup not found: ${backupId}`);
}
const backupPath = resolved.path;
const manifestContent = await readFile(join(backupPath, 'manifest.yaml'), 'utf-8');
const manifest = parseManifest(manifestContent);
const restored = [];
const failed = [];
// Q1 — the scope gate, in code. `rollback` is one of the five arms M-BUG-41
// measured: it renders repo-relative-looking paths while writing to the
// ABSOLUTE originals recorded in the manifest, so what the operator reads and
// what the run touches are not the same set. A backup taken under `--global`
// restores `~/.claude/…`, which is `user-scope` / `require-ok`.
const scope = evaluateWriteTargets(
manifest.files.map((f) => f.originalPath),
opts.repoRoot ?? null,
opts.home ? { home: opts.home } : {},
);
// The gate guards a WRITE; a dry run is not one (#63). `requiresApproval` is
// returned either way, so a caller previewing a restore still learns that
// approval will be owed.
if (scope.requiresApproval && !opts.approveScope && !opts.dryRun) {
return {
restored: [],
failed: [],
gate: scope.gate,
requiresApproval: true,
disclosures: scope.disclosures,
refused: manifest.files.map((f) => ({
originalPath: f.originalPath,
status: 'refused',
reason: 'scope-gate',
})),
};
}
// A manifest with entries that parsed to nothing would restore nothing while
// reporting success. Fail loudly instead.
if (manifest.files.length === 0 && /^\s+-\s/m.test(manifestContent)) {
throw new Error(`Unreadable manifest for backup ${backupId}: entries present but none parsed`);
}
for (const fileEntry of manifest.files) {
const backupFilePath = join(backupPath, fileEntry.backupPath);
@ -139,7 +194,19 @@ export async function restoreBackup(backupId, opts = {}) {
}
}
return { restored, failed };
// Files implement CREATED are absent from the backup by definition, so they
// survive the restore. Report them — a half-restored target is only dangerous
// when it is also silent.
return {
restored,
failed,
createdNotRemoved: manifest.created,
legacy: resolved.legacy,
gate: scope.gate,
requiresApproval: scope.requiresApproval,
disclosures: scope.disclosures,
refused: [],
};
}
/**
@ -148,17 +215,11 @@ export async function restoreBackup(backupId, opts = {}) {
* @returns {Promise<{ deleted: boolean, error?: string }>}
*/
export async function deleteBackup(backupId) {
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const resolved = await resolveBackupPath(backupId);
if (!resolved) return { deleted: false, error: `Backup not found: ${backupId}` };
try {
await stat(backupPath);
} catch {
return { deleted: false, error: `Backup not found: ${backupId}` };
}
try {
await rm(backupPath, { recursive: true, force: true });
await rm(resolved.path, { recursive: true, force: true });
return { deleted: true };
} catch (err) {
return { deleted: false, error: err.message };

View file

@ -10,7 +10,7 @@ import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { lineCount, truncate } from './lib/string-utils.mjs';
import { readdir, stat } from 'node:fs/promises';
import { join, resolve, relative } from 'node:path';
import { join, resolve, relative, sep } from 'node:path';
const SCANNER = 'RUL';
@ -30,8 +30,16 @@ export async function scan(targetPath, discovery) {
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
}
// Collect all real files in the project for glob matching
const projectFiles = await collectProjectFiles(targetPath);
// Rule path patterns scope relative to the rule's OWN project root (the dir
// containing its .claude/), not the outer scan root. Resolve + cache per root.
const home = process.env.HOME || process.env.USERPROFILE || '';
const projectFilesByRoot = new Map();
async function projectFilesFor(root) {
if (!projectFilesByRoot.has(root)) {
projectFilesByRoot.set(root, await collectProjectFiles(root));
}
return projectFilesByRoot.get(root);
}
for (const file of ruleFiles) {
const content = await readTextFile(file.absPath);
@ -47,6 +55,7 @@ export async function scan(targetPath, discovery) {
if (lines > 5) {
findings.push(finding({
scanner: SCANNER,
code: 'no-frontmatter',
severity: SEVERITY.info,
title: 'Rule has no frontmatter (always active)',
description: `${file.relPath} has no YAML frontmatter. It will be loaded for ALL files. Add paths: frontmatter to scope it.`,
@ -61,6 +70,7 @@ export async function scan(targetPath, discovery) {
if (frontmatter.globs && !frontmatter.paths) {
findings.push(finding({
scanner: SCANNER,
code: 'globs-instead-of-paths',
severity: SEVERITY.low,
title: 'Rule uses "globs" instead of documented "paths"',
description: `${file.relPath} uses "globs:" for scoping. Claude Code's documentation specifies "paths:" as the rule-scoping field; "globs:" is not documented. Rename to "paths:" so the rule scopes as intended.`,
@ -74,14 +84,24 @@ export async function scan(targetPath, discovery) {
if (paths) {
const patterns = Array.isArray(paths) ? paths : [paths];
// A rule scopes relative to its own project root (parent of its .claude/),
// not the scan root. User-global rules (root === HOME) match against the
// active project at runtime, so "matches no files here" is not meaningful.
const projectRoot = deriveProjectRoot(file.absPath) || targetPath;
const isUserGlobal = home && projectRoot === home;
if (!isUserGlobal) {
const projectFiles = await projectFilesFor(projectRoot);
for (const pattern of patterns) {
if (typeof pattern !== 'string') continue;
// Check if pattern matches any real files
const matchCount = countGlobMatches(pattern, projectFiles, targetPath);
// Check if pattern matches any real files (relative to the rule's root)
const matchCount = countGlobMatches(pattern, projectFiles, projectRoot);
if (matchCount === 0) {
findings.push(finding({
scanner: SCANNER,
code: 'pattern-matches-nothing',
severity: SEVERITY.high,
title: 'Rule path pattern matches no files',
description: `${file.relPath}: pattern "${pattern}" matches 0 files. This rule will never activate.`,
@ -94,11 +114,13 @@ export async function scan(targetPath, discovery) {
}
}
}
}
// --- Content quality checks ---
if (lines < 2) {
findings.push(finding({
scanner: SCANNER,
code: 'nearly-empty',
severity: SEVERITY.low,
title: 'Rule file is nearly empty',
description: `${file.relPath} has only ${lines} line(s).`,
@ -112,6 +134,7 @@ export async function scan(targetPath, discovery) {
if (!frontmatter?.paths && !frontmatter?.globs && lines > 50) {
findings.push(finding({
scanner: SCANNER,
code: 'large-unscoped',
severity: SEVERITY.medium,
title: 'Large unscoped rule file',
description: `${file.relPath} has ${lines} lines and no path scoping. It loads into context for every file interaction.`,
@ -129,6 +152,7 @@ export async function scan(targetPath, discovery) {
if (frontmatter?.paths && lines > 50) {
findings.push(finding({
scanner: SCANNER,
code: 'large-scoped-lost-after-compaction',
severity: SEVERITY.low,
title: 'Large path-scoped rule is lost after compaction',
description: `${file.relPath} is path-scoped (${lines} lines). Path-scoped rules load only when a matching file is read, and after a context compaction they are not re-injected until a matching file is read again — so a large scoped rule carrying must-always-hold instructions can silently drop out mid-session.`,
@ -143,6 +167,7 @@ export async function scan(targetPath, discovery) {
if (!file.absPath.endsWith('.md')) {
findings.push(finding({
scanner: SCANNER,
code: 'not-markdown',
severity: SEVERITY.medium,
title: 'Rule file is not .md',
description: `${file.relPath} is not a .md file. Only .md files are loaded from rules/.`,
@ -195,6 +220,18 @@ async function collectProjectFiles(targetPath, depth = 0) {
* @param {string} basePath
* @returns {number}
*/
/**
* Resolve the project root a rule scopes against: the directory containing the
* `.claude/` dir the rule lives under. `/a/b/.claude/rules/x.md` `/a/b`.
* Returns null if the path has no `.claude` segment.
*/
function deriveProjectRoot(ruleAbsPath) {
const parts = ruleAbsPath.split(sep);
const idx = parts.lastIndexOf('.claude');
if (idx <= 0) return null;
return parts.slice(0, idx).join(sep);
}
function countGlobMatches(pattern, files, basePath) {
try {
const regex = globToRegex(pattern);
@ -221,9 +258,9 @@ function globToRegex(pattern) {
.replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}')
.replace(/\*\*/g, '{{GLOBSTAR}}')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]') // must run BEFORE placeholder restore — '(?:' would corrupt
.replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs
.replace(/\{\{GLOBSTAR\}\}/g, '.*')
.replace(/\?/g, '[^/]');
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
// Handle leading patterns
if (!regex.startsWith('.*') && !regex.startsWith('/')) {

View file

@ -9,10 +9,12 @@
import { resolve, sep } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { resetCounter } from './lib/output.mjs';
import { writeOutputFile } from './lib/write-output.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { envelope } from './lib/output.mjs';
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
import { loadSuppressions, applySuppressions, formatSuppressionSummary, unknownSuppressions } from './lib/suppression.mjs';
import { humanizeEnvelope } from './lib/humanizer.mjs';
import { resolveContextWindow } from './lib/context-window.mjs';
import { resolveActiveModel } from './lib/active-model.mjs';
@ -34,6 +36,17 @@ import { scan as scanSkillListing } from './skill-listing-scanner.mjs';
import { scan as scanAgentListing } from './agent-listing-scanner.mjs';
import { scan as scanOutputStyle } from './output-style-scanner.mjs';
import { scan as scanOptimizationLens } from './optimization-lens-scanner.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = {
boolean: [
'--json', '--raw', '--global', '--full-machine', '--no-suppress',
'--include-fixtures', '--exclude-cache', '--no-exclude-cache', '--save-baseline',
'--approve-scope',
],
value: ['--output-file', '--context-window', '--baseline'],
};
// Directory names that identify test fixture / example directories
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
@ -122,7 +135,6 @@ export async function runAllScanners(targetPath, opts = {}) {
const results = [];
for (const scanner of SCANNERS) {
resetCounter();
const scanStart = Date.now();
try {
const result = await scanner.fn(resolvedPath, discovery, { contextWindow });
@ -182,8 +194,14 @@ export async function runAllScanners(targetPath, opts = {}) {
const shouldSuppress = opts.suppress !== false;
let suppressedFindings = [];
let deadSuppressions = [];
if (shouldSuppress) {
const { suppressions } = await loadSuppressions(resolvedPath);
// A pin that names no declared check can never match. Report it: a silently
// dead suppression leaves the user believing a finding is hidden when it is
// not (M-BUG-28).
deadSuppressions = unknownSuppressions(suppressions);
if (suppressions.length > 0) {
for (const result of results) {
const { active, suppressed } = applySuppressions(result.findings, suppressions);
@ -209,15 +227,23 @@ export async function runAllScanners(targetPath, opts = {}) {
if (suppressedFindings.length > 0) {
env.suppressed_findings = suppressedFindings;
}
// ux-rules rule 2: commands run scanners with `2>/dev/null`, so anything they
// must ACT on rides in the payload, never in a stderr-only warning. Added only
// when a dead pin exists, so a config without one is byte-identical.
if (deadSuppressions.length > 0) {
env.unknown_suppressions = deadSuppressions;
}
return env;
}
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let targetPath = '.';
let outputFile = null;
let saveBaseline = false;
let approveScope = false;
let baselinePath = null;
let contextWindow = null;
@ -226,6 +252,8 @@ async function main() {
outputFile = args[++i];
} else if (args[i] === '--context-window' && args[i + 1]) {
contextWindow = args[++i];
} else if (args[i] === '--approve-scope') {
approveScope = true;
} else if (args[i] === '--save-baseline') {
saveBaseline = true;
} else if (args[i] === '--baseline' && args[i + 1]) {
@ -257,6 +285,11 @@ async function main() {
const jsonMode = args.includes('--json');
const rawMode = args.includes('--raw');
if (!(await requireTargetDir(resolve(targetPath)))) {
process.exitCode = 3;
return;
}
const humanizedProgress = !jsonMode && !rawMode;
process.stderr.write(humanizedProgress ? `Config-Audit v2.2.0\n` : `Config-Audit Scanner v2.2.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
@ -278,7 +311,7 @@ async function main() {
const json = JSON.stringify(output, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');
await writeOutputFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
} else {
process.stdout.write(json + '\n');
@ -286,11 +319,27 @@ async function main() {
if (saveBaseline) {
const bPath = baselinePath || resolve(targetPath, '.config-audit-baseline.json');
// Q1 — the scope gate, in code. This write was carried in the plan text as
// one of the plugin's own artifacts, legitimately exempt — measured false: the
// default path is derived from the SCAN TARGET, not from a plugin root, so
// `--global --save-baseline` lands `~/.claude/.config-audit-baseline.json`,
// which is `user-scope` / `require-ok`. `lib/baseline.mjs` is the genuinely
// exempt one — it writes only under `~/.config-audit/baselines`.
const scope = evaluateWriteTargets([bPath], process.cwd());
if (scope.requiresApproval && !approveScope) {
for (const line of scope.disclosures) process.stderr.write(`\n${line}\n`);
process.stderr.write(
`Baseline NOT saved to ${bPath} — re-run with --approve-scope to write it.\n`,
);
} else {
// Always save baselines as raw v5.0.0-shape envelope so future humanizer
// changes don't trigger false-positive drift findings.
await writeFile(bPath, JSON.stringify(result, null, 2), 'utf-8');
process.stderr.write(`Baseline saved to ${bPath}\n`);
}
}
// Summary
const agg = result.aggregate;
@ -299,10 +348,12 @@ async function main() {
process.stderr.write(`Risk: ${agg.risk_score}/100 (${agg.risk_band})\n`);
process.stderr.write(`Verdict: ${agg.verdict}\n`);
// Exit code
if (agg.verdict === 'FAIL') process.exit(2);
if (agg.verdict === 'WARNING') process.exit(1);
process.exit(0);
// Exit code. Set, never process.exit(): stdout is written ASYNCHRONOUSLY when
// it is a pipe, and process.exit() discards whatever is still buffered. Piping
// this envelope used to yield truncated, unparseable JSON (246 854 bytes to a
// file vs 65 536 to a pipe) — a corruption that looks like a bad file, not a
// cut-off. Letting Node exit naturally drains stdout first.
process.exitCode = agg.verdict === 'FAIL' ? 2 : agg.verdict === 'WARNING' ? 1 : 0;
}
// Only run CLI if invoked directly
@ -310,6 +361,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -20,6 +20,10 @@ import { gradeFromPassRate } from './lib/severity.mjs';
import { loadSuppressions, applySuppressions } from './lib/suppression.mjs';
import { parseJson } from './lib/yaml-parser.mjs';
import { humanizeEnvelope, humanizeFindings } from './lib/humanizer.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = { boolean: ['--json', '--fix', '--check-readme'] };
const execFileAsync = promisify(execFile);
@ -330,6 +334,7 @@ export function formatSelfAudit(result) {
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const jsonMode = args.includes('--json');
const fixMode = args.includes('--fix');
const checkReadmeMode = args.includes('--check-readme');
@ -350,6 +355,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(file
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -8,11 +8,11 @@ import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseJson } from './lib/yaml-parser.mjs';
import { extractKeys } from './lib/string-utils.mjs';
import { extractKeys, levenshtein } from './lib/string-utils.mjs';
const SCANNER = 'SET';
/** Known top-level settings.json keys (as of CC 2.1.181 / June 2026) */
/** Known top-level settings.json keys (as of CC 2.1.193 / June 2026) */
const KNOWN_KEYS = new Set([
'additionalDirectories',
'agent', 'allowAllClaudeAiMcps', 'allowedChannelPlugins', 'allowedHttpHookUrls',
@ -37,6 +37,9 @@ const KNOWN_KEYS = new Set([
'spinnerTipsOverride', 'spinnerVerbs', 'statusLine', 'strictKnownMarketplaces',
'useAutoModeDuringPlan', 'voiceEnabled', 'wheelScrollAccelerationEnabled',
'worktree', '$schema',
// CC 2.1.193 binary-verified (M-BUG-10): present as quoted string literals in the binary
'agentPushNotifEnabled', 'remoteControlAtStartup', 'skipAutoPermissionPrompt',
'skipDangerousModePermissionPrompt', 'skipWorkflowUsageWarning', 'tui',
]);
/** Deprecated keys with migration info */
@ -68,13 +71,26 @@ const TYPE_CHECKS = new Map([
['wheelScrollAccelerationEnabled', 'boolean'],
]);
/** Valid effortLevel values (CC 2.1.154 added 'xhigh' as the Opus-4.8 top tier) */
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
/** Valid effortLevel values (CC 2.1.154 added 'xhigh' as the Opus-4.8 top tier).
* Exported because the fix engine's nearest-match needs the SAME list: a second
* copy there had gone stale on `xhigh` and quietly corrected near-misses on the
* top tier down to `high` (C2). One table, no drift. */
export const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
/** v5 M6: warn when additionalDirectories grows beyond this each entry adds
* a project root to walks/discovery, inflating per-turn cost and confusing scope. */
const ADDITIONAL_DIRS_THRESHOLD = 2;
/** M-BUG-10: the CC settings schema is passthrough it forwards unrecognized
* keys unchanged rather than rejecting them, so an arbitrary unknown key is
* valid/forward-compatible, not an error. The only real risk is a TYPO of a
* real key (the intended setting silently does nothing), so an unknown key is
* flagged ONLY when it closely matches a known key: edit distance within
* TYPO_MAX_DISTANCE and both keys at least TYPO_MIN_LEN chars (short keys are
* too noisy for reliable edit-distance matching). */
const TYPO_MAX_DISTANCE = 2;
const TYPO_MIN_LEN = 4;
/** The only valid sub-keys of `autoMode`, each a prose-rule string array
* (the literal "$defaults" is a valid entry). Verified against
* code.claude.com/docs/en/auto-mode-config. */
@ -105,6 +121,7 @@ export async function scan(targetPath, discovery) {
if (parsed === null) {
findings.push(finding({
scanner: SCANNER,
code: 'invalid-json',
severity: SEVERITY.critical,
title: 'Invalid JSON in settings file',
description: `${file.relPath} contains invalid JSON and will be ignored by Claude Code.`,
@ -115,17 +132,33 @@ export async function scan(targetPath, discovery) {
continue;
}
// Check for unknown keys
// Check for unknown keys — typo gate (M-BUG-10). The CC settings schema is
// passthrough, so an unrecognized key is NOT an error; only a typo of a real
// key is (the intended setting silently does nothing). Flag a key only when
// it closely matches a known key; an unknown key far from every known key is
// treated as valid/forward-compatible and emitted nothing.
for (const key of Object.keys(parsed)) {
if (!KNOWN_KEYS.has(key)) {
if (KNOWN_KEYS.has(key)) continue;
let nearest = null;
let best = Infinity;
for (const known of KNOWN_KEYS) {
if (Math.min(key.length, known.length) < TYPO_MIN_LEN) continue;
const d = levenshtein(key, known);
if (d <= TYPO_MAX_DISTANCE && d < best) {
best = d;
nearest = known;
}
}
if (nearest) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Unknown settings key',
description: `${file.relPath}: "${key}" is not a recognized settings.json key. It will be silently ignored.`,
code: 'key-typo',
severity: SEVERITY.low,
title: 'Possible typo in settings key',
description: `${file.relPath}: "${key}" is not a recognized settings.json key, but it closely matches "${nearest}". Claude Code forwards unrecognized keys unchanged (it does not reject them), so if "${key}" is a typo of "${nearest}" the intended setting silently has no effect.`,
file: file.absPath,
evidence: key,
recommendation: 'Check spelling. See https://json.schemastore.org/claude-code-settings.json for valid keys.',
recommendation: `Did you mean "${nearest}"? Fix the spelling, or keep "${key}" if it is intentional (e.g. a newer settings key this audit does not recognize yet).`,
autoFixable: false,
}));
}
@ -136,6 +169,7 @@ export async function scan(targetPath, discovery) {
if (parsed[key] !== undefined) {
findings.push(finding({
scanner: SCANNER,
code: 'deprecated-key',
severity: SEVERITY.medium,
title: 'Deprecated settings key',
description: `${file.relPath}: "${key}" is deprecated. ${migration}`,
@ -152,6 +186,7 @@ export async function scan(targetPath, discovery) {
if (parsed[key] !== undefined && typeof parsed[key] !== expectedType) {
findings.push(finding({
scanner: SCANNER,
code: 'type-mismatch',
severity: SEVERITY.high,
title: 'Type mismatch in settings',
description: `${file.relPath}: "${key}" should be ${expectedType}, got ${typeof parsed[key]}.`,
@ -167,6 +202,7 @@ export async function scan(targetPath, discovery) {
if (parsed.effortLevel && !VALID_EFFORT_LEVELS.has(parsed.effortLevel)) {
findings.push(finding({
scanner: SCANNER,
code: 'invalid-effort-level',
severity: SEVERITY.medium,
title: 'Invalid effortLevel value',
description: `${file.relPath}: effortLevel "${parsed.effortLevel}" is not valid.`,
@ -181,6 +217,7 @@ export async function scan(targetPath, discovery) {
if (!parsed.$schema) {
findings.push(finding({
scanner: SCANNER,
code: 'missing-schema',
severity: SEVERITY.info,
title: 'Missing $schema reference',
description: `${file.relPath} lacks a $schema reference. Adding one enables autocomplete in VS Code/Cursor.`,
@ -197,6 +234,7 @@ export async function scan(targetPath, discovery) {
if (!perms.deny || (Array.isArray(perms.deny) && perms.deny.length === 0)) {
findings.push(finding({
scanner: SCANNER,
code: 'no-deny-rules',
severity: SEVERITY.medium,
title: 'No deny rules configured',
description: `${file.relPath}: No permission deny rules. Claude can access all files including .env and secrets.`,
@ -209,6 +247,7 @@ export async function scan(targetPath, discovery) {
if (!perms.allow || (Array.isArray(perms.allow) && perms.allow.length === 0)) {
findings.push(finding({
scanner: SCANNER,
code: 'no-allow-rules',
severity: SEVERITY.low,
title: 'No allow rules configured',
description: `${file.relPath}: No permission allow rules. This means frequent permission prompts for common operations.`,
@ -224,6 +263,7 @@ export async function scan(targetPath, discovery) {
parsed.additionalDirectories.length > ADDITIONAL_DIRS_THRESHOLD) {
findings.push(finding({
scanner: SCANNER,
code: 'many-additional-dirs',
severity: SEVERITY.low,
title: 'Many additionalDirectories entries',
description:
@ -250,6 +290,7 @@ export async function scan(targetPath, discovery) {
if (typeof am !== 'object' || am === null || Array.isArray(am)) {
findings.push(finding({
scanner: SCANNER,
code: 'automode-not-object',
severity: SEVERITY.medium,
title: 'autoMode must be an object',
description: `${file.relPath}: "autoMode" must be an object with environment/allow/soft_deny/hard_deny arrays, got ${Array.isArray(am) ? 'array' : typeof am}.`,
@ -264,6 +305,7 @@ export async function scan(targetPath, discovery) {
if (!AUTO_MODE_SUBKEYS.has(subKey)) {
findings.push(finding({
scanner: SCANNER,
code: 'automode-unknown-subkey',
severity: SEVERITY.medium,
title: `autoMode has an unknown sub-key: ${subKey}`,
description: `${file.relPath}: "autoMode.${subKey}" is not a recognized sub-key. Valid keys are environment, allow, soft_deny, hard_deny. It is silently ignored — a typo of a real key (e.g. "hard_denies") means those rules never apply.`,
@ -280,6 +322,7 @@ export async function scan(targetPath, discovery) {
if (!isStringArray) {
findings.push(finding({
scanner: SCANNER,
code: 'automode-subkey-not-string-array',
severity: SEVERITY.medium,
title: `autoMode.${subKey} must be an array of strings`,
description: `${file.relPath}: "autoMode.${subKey}" must be an array of prose-rule strings (the literal "$defaults" is allowed), got ${Array.isArray(val) ? 'an array with a non-string entry' : typeof val}.`,
@ -296,6 +339,7 @@ export async function scan(targetPath, discovery) {
if (file.scope === 'project') {
findings.push(finding({
scanner: SCANNER,
code: 'automode-in-shared-settings',
severity: SEVERITY.low,
title: 'autoMode in shared project settings is ignored by Claude Code',
description: `${file.relPath}: Claude Code does not read "autoMode" from shared project settings (.claude/settings.json), so a checked-in repo cannot inject its own rules. This autoMode block has no effect where it is.`,
@ -313,6 +357,7 @@ export async function scan(targetPath, discovery) {
if (Array.isArray(parsed.hooks)) {
findings.push(finding({
scanner: SCANNER,
code: 'hooks-as-array',
severity: SEVERITY.critical,
title: 'Hooks configured as array instead of object',
description: `${file.relPath}: "hooks" must be an object with event keys, not an array. All hooks will be ignored.`,

View file

@ -87,6 +87,7 @@ export async function scan(_targetPath, _discovery, opts = {}) {
findings.push(finding({
scanner: SCANNER,
code: 'description-over-cap',
severity: SEVERITY.medium,
title: 'Skill description exceeds the listing cap (Claude Code truncates it)',
description:
@ -116,6 +117,7 @@ export async function scan(_targetPath, _discovery, opts = {}) {
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
findings.push(finding({
scanner: SCANNER,
code: 'aggregate-listing-budget',
severity: SEVERITY.low,
title: 'Aggregate skill descriptions may exceed the listing budget',
description:
@ -138,6 +140,7 @@ export async function scan(_targetPath, _discovery, opts = {}) {
const winLabel = withCommas(window);
findings.push(finding({
scanner: SCANNER,
code: 'aggregate-listing-budget',
severity: advisory ? SEVERITY.info : SEVERITY.low,
title: 'Aggregate skill descriptions may exceed the listing budget',
description:
@ -173,6 +176,7 @@ export async function scan(_targetPath, _discovery, opts = {}) {
findings.push(finding({
scanner: SCANNER,
code: 'oversized-body',
severity: SEVERITY.low,
title: 'Skill body is large (loads on demand when the skill runs)',
description:

View file

@ -0,0 +1,147 @@
#!/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;
}

View file

@ -14,12 +14,22 @@
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { writeFile, readFile, stat } from 'node:fs/promises';
import { readFile, stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { discoverConfigFiles } from './lib/file-discovery.mjs';
import { resetCounter } from './lib/output.mjs';
import { scan } from './token-hotspots.mjs';
import * as tokenizerApi from './lib/tokenizer-api.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = {
boolean: [
'--json', '--raw', '--global', '--with-telemetry-recipe',
'--accurate-tokens', '--exclude-cache', '--no-exclude-cache',
],
value: ['--output-file'],
};
const __dirname = dirname(fileURLToPath(import.meta.url));
const TELEMETRY_RECIPE_PATH = resolve(__dirname, '..', 'knowledge', 'cache-telemetry-recipe.md');
@ -49,6 +59,7 @@ async function calibrateAgainstApi(hotspots, apiKey) {
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let targetPath = '.';
let outputFile = null;
let jsonMode = false;
@ -78,14 +89,15 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
process.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
process.exitCode = 3;
return;
}
resetCounter();
const discovery = await discoverConfigFiles(absPath, { includeGlobal, excludeCache });
const result = await scan(absPath, discovery);
@ -129,7 +141,7 @@ async function main() {
const json = JSON.stringify(payload, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');
await writeOutputFile(outputFile, json, 'utf-8');
}
if (jsonMode || rawMode || !outputFile) {
@ -141,6 +153,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -22,12 +22,12 @@
*/
import { resolve, dirname, isAbsolute } from 'node:path';
import { stat } from 'node:fs/promises';
import { stat, readFile } from 'node:fs/promises';
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { estimateTokens, readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
import { estimateTokens, effectiveMemoryBytes, readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
import {
assessMcpDeferralForRepo,
severityForForcedSchemas,
@ -261,6 +261,26 @@ function detectRedundantPermissions(settings) {
return issues;
}
/**
* Byte count to feed the token estimator for a discovered file. CLAUDE.md /
* memory files are sized from their *effective* (injected) content CC strips
* block-level HTML comments before injection so a raw byte read over-counts
* them. Every other source uses the raw on-disk size. (M-BUG-6)
*
* @param {{type:string, absPath?:string, size:number}} f
* @returns {Promise<number>}
*/
async function tokenBytesFor(f) {
if (f.type === 'claude-md' && f.absPath) {
try {
return effectiveMemoryBytes(await readFile(f.absPath, 'utf-8'));
} catch {
return f.size;
}
}
return f.size;
}
/**
* Build the ranked hotspots array.
*
@ -272,7 +292,7 @@ async function buildHotspots(discovery, targetPath, activeConfig) {
const ranked = [];
for (const f of discovery.files) {
const kind = tokenKind(f.type);
const tokens = estimateTokens(f.size, kind);
const tokens = estimateTokens(await tokenBytesFor(f), kind);
if (tokens <= 0) continue;
ranked.push({
absPath: f.absPath,
@ -380,6 +400,7 @@ export async function scan(targetPath, discovery) {
if (detectVolatileTop(content)) {
findings.push(finding({
scanner: SCANNER,
code: 'volatile-top',
severity: SEVERITY.high,
title: 'Cache-breaking volatile content at top of CLAUDE.md',
description:
@ -408,6 +429,7 @@ export async function scan(targetPath, discovery) {
if (issues.length === 0) continue;
findings.push(finding({
scanner: SCANNER,
code: 'redundant-permissions',
severity: SEVERITY.medium,
title: 'Redundant permission declarations',
description:
@ -432,6 +454,7 @@ export async function scan(targetPath, discovery) {
if (depth > MAX_IMPORT_DEPTH) {
findings.push(finding({
scanner: SCANNER,
code: 'deep-import-chain',
severity: SEVERITY.low,
title: 'Deep @import chain defeats prompt-cache reuse',
description:
@ -464,6 +487,7 @@ export async function scan(targetPath, discovery) {
const skillName = (fm && fm.name) || f.absPath.split('/').slice(-2, -1)[0] || f.absPath;
findings.push(finding({
scanner: SCANNER,
code: 'bloated-skill-description',
severity: SEVERITY.low,
title: 'Bloated skill description (loads on every turn)',
description:
@ -515,6 +539,7 @@ export async function scan(targetPath, discovery) {
'and user-scopes so per-project budget stays tight.';
findings.push(finding({
scanner: SCANNER,
code: 'mcp-schema-budget',
severity,
title: `High MCP tool-schema budget on server "${m.name}"`,
description,
@ -532,6 +557,7 @@ export async function scan(targetPath, discovery) {
const fileCount = activeConfig.claudeMd.files?.length ?? 0;
findings.push(finding({
scanner: SCANNER,
code: 'cascade-over-budget',
severity: SEVERITY.medium,
title: 'CLAUDE.md cascade exceeds 10k tokens per turn',
description:
@ -564,6 +590,7 @@ export async function scan(targetPath, discovery) {
const keys = stale.map(v => v.key);
findings.push(finding({
scanner: SCANNER,
code: 'stale-plugin-cache',
severity: SEVERITY.low,
title: 'Stale plugin-cache versions (disk cleanup, zero live-context impact)',
description:
@ -634,6 +661,7 @@ export async function scan(targetPath, discovery) {
'(gh / aws / gcloud) over MCP for common operations.';
findings.push(finding({
scanner: SCANNER,
code: 'mcp-schema-deferral',
severity,
title: 'MCP tool schemas forced into the always-loaded prefix',
file: null,
@ -651,7 +679,7 @@ export async function scan(targetPath, discovery) {
// ── Total estimated tokens (sum of every discovered source + activeConfig MCP) ──
let totalTokens = 0;
for (const f of discovery.files) {
totalTokens += estimateTokens(f.size, tokenKind(f.type));
totalTokens += estimateTokens(await tokenBytesFor(f), tokenKind(f.type));
}
if (activeConfig && Array.isArray(activeConfig.mcpServers)) {
for (const m of activeConfig.mcpServers) {

View file

@ -13,11 +13,17 @@
*/
import { resolve } from 'node:path';
import { writeFile, stat } from 'node:fs/promises';
import { stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { readActiveConfig } from './lib/active-config-reader.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = { boolean: ['--json', '--raw', '--verbose', '--suggest-disables'], value: ['--output-file'] };
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let targetPath = '.';
let outputFile = null;
let jsonMode = false;
@ -41,18 +47,20 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
process.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
process.exitCode = 3;
return;
}
const result = await readActiveConfig(absPath, { verbose, suggestDisables });
const json = JSON.stringify(result, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');
await writeOutputFile(outputFile, json, 'utf-8');
}
if (jsonMode || rawMode || !outputFile) {
@ -64,6 +72,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* write-scope CLI classify the write targets a command is about to touch,
* relative to the repo the session stands in (M-BUG-41).
*
* Exists so the gate has ONE implementation. Five command templates need the
* same answer before their approval surface; five prose paraphrases of the
* class table would be five policies drifting apart the shape that put the
* lever table in five copies (#61). The templates call this and render what
* comes back.
*
* Usage:
* node write-scope-cli.mjs --target <path> [--target <path> ...]
* [--repo <session-repo-root>]
* [--output-file <path>] [--json]
*
* Exit codes: 0 = classified, 3 = argument or tool error.
*
* A gated target is NOT an error exit. The exit-code contract reserves 3 for
* "the scanner could not do its job"; "this write leaves the repo" is a verdict
* about a write, and it rides in the payload a command cannot act on
* something that only ever reached stderr (F3's class).
*
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import { SCOPE_CLASSES, classifyWriteTarget, strongestGate } from './lib/write-scope.mjs';
/** Flag surface. Anything else is exit 3. */
const ARG_SPEC = { boolean: ['--json'], value: ['--target', '--repo', '--output-file'] };
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const targets = [];
let repo = process.cwd();
let outputFile = null;
let jsonMode = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--json') jsonMode = true;
else if (args[i] === '--target') targets.push(args[++i]);
else if (args[i] === '--repo') repo = args[++i];
else if (args[i] === '--output-file') outputFile = args[++i];
}
if (targets.length === 0) {
process.stderr.write('Error: at least one --target is required\n');
process.exitCode = 3;
return;
}
const classified = targets.map((t) => classifyWriteTarget(t, repo));
const gate = strongestGate(classified);
const payload = {
meta: {
repo: resolve(repo),
targetCount: classified.length,
// The class table travels with the answer so a template never has to
// restate what a class means.
classes: Object.fromEntries(
Object.entries(SCOPE_CLASSES).map(([name, spec]) => [name, { gate: spec.gate }]),
),
},
gate,
requiresApproval: gate === 'require-ok',
// Distinct disclosure lines, in class order, ready to render verbatim.
disclosures: [...new Set(classified.map((t) => t.disclosure).filter(Boolean))],
targets: classified,
};
const json = `${JSON.stringify(payload, null, 2)}\n`;
if (outputFile) {
await writeOutputFile(outputFile, json);
// Nothing on stdout when writing to a file: a command that also renders
// this would otherwise show the user the raw payload (ux-rules rule 1).
} else if (jsonMode) {
process.stdout.write(json);
} else {
for (const t of payload.targets) {
process.stdout.write(`${t.scopeClass}\t${t.gate}\t${t.target}\n`);
}
}
}
try {
await main();
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exitCode = 3;
}

View file

@ -0,0 +1,109 @@
/**
* R4 an agent file must not carry a contract its `tools:` cannot keep.
*
* `agents/verifier-agent.md` said both things at once: §Output Format
* ("Append to: implementation-log.md") and §Read-Only Guarantee ("never
* modifies any files"), while its frontmatter granted only Read/Glob/Grep.
* Which instruction wins is nondeterministic, and the failure mode is not
* "the write fails" it is the agent improvising a full-file Write on the
* SHARED implementation log, clobbering parallel implementer entries. That is
* exactly the defect `implement-log-append.test.mjs` exists to prevent,
* entering through the one file that test does not read.
*
* The guard is the blanket invariant over the whole agents/ catalogue, not a
* statement about verifier-agent: any agent whose tools grant no write
* capability must (a) instruct no file write, and (b) say positively that it
* returns its findings inline. Both sides are read from the file the tools
* list AND the body so removing a write tool from any agent whose body still
* instructs a write turns this red.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile, readdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const AGENTS_DIR = resolve(ROOT, 'agents');
/** Tools that can put bytes on disk. Bash counts: `>>` is a write. */
const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'Bash']);
/**
* Directive lines that tell the agent to put its output in a file.
* Anchored to line start so prose ABOUT writing ("do not write it to a file")
* is not caught the defect is an instruction, not a mention.
*/
const WRITE_DIRECTIVE_RE =
/^(?:\*\*)?(?:Append|Write|Save|Output|Persist)\b(?![^\n]*\bnot\b)[^\n]*?(?:\bto\b|`[^`\n]+\.(?:md|ya?ml|json)`)/mi;
/** The positive half: the file must say the findings come back inline. */
const RETURN_INLINE_RE =
/\breturn\b[^.]{0,120}?\b(?:as\s+your\s+final\s+message|inline)\b/i;
function frontmatterOf(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
return m ? m[1] : '';
}
function bodyOf(content) {
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
return m ? content.slice(m[0].length) : content;
}
function toolsOf(frontmatter) {
const m = frontmatter.match(/^tools:\s*(.+)$/m);
if (!m) return [];
return (m[1].match(/[A-Za-z_][A-Za-z0-9_]*/g) || []);
}
async function loadAgents() {
const names = (await readdir(AGENTS_DIR)).filter((n) => n.endsWith('.md')).sort();
return Promise.all(
names.map(async (name) => {
const content = await readFile(resolve(AGENTS_DIR, name), 'utf-8');
const frontmatter = frontmatterOf(content);
const tools = toolsOf(frontmatter);
return {
name,
body: bodyOf(content),
tools,
canWrite: tools.some((t) => WRITE_TOOLS.has(t)),
};
}),
);
}
test('R4 sweep is not vacuous: the agents catalogue is read and at least one agent has no write tool', async () => {
const agents = await loadAgents();
assert.ok(agents.length >= 7,
`expected the agents/ catalogue to be swept, got ${agents.length} files`);
assert.ok(agents.every((a) => a.tools.length > 0),
`every agent must declare tools:, missing in ${agents.filter((a) => !a.tools.length).map((a) => a.name).join(', ')}`);
const writeless = agents.filter((a) => !a.canWrite);
assert.ok(writeless.length >= 1,
'no write-tool-less agent found — the invariant below would be vacuously green');
});
test('R4: no agent instructs a file write its tools cannot perform', async () => {
const agents = await loadAgents();
for (const agent of agents.filter((a) => !a.canWrite)) {
const offending = agent.body.match(WRITE_DIRECTIVE_RE);
assert.ok(
offending === null,
`${agent.name} grants no write tool (tools: ${agent.tools.join(', ')}) but instructs a write: ${JSON.stringify(offending && offending[0])}`,
);
}
});
test('R4: a write-tool-less agent states positively that it returns findings inline', async () => {
const agents = await loadAgents();
for (const agent of agents.filter((a) => !a.canWrite)) {
assert.ok(
RETURN_INLINE_RE.test(agent.body),
`${agent.name} has no write tool, so it must say its findings are returned as its final message`,
);
}
});

View file

@ -0,0 +1,138 @@
/**
* R6 the two "Required Frontmatter" contracts are enforced by something.
*
* `.claude/rules/agent-development.md` and `.claude/rules/command-development.md`
* write down which frontmatter keys an agent/command MUST carry, and that agent
* colors must be unique within the plugin. Nothing enforced any of it: the only
* test that looked at agent frontmatter (`agent-prompt-shape`) asserted `name:`
* on a HAND-WRITTEN list of 3 of the 7 agents. Everything was compliant and
* unwatched the state in which a rule becomes fiction one file at a time.
*
* Nothing here is hand-maintained, because a hand-kept list of what to sweep is
* a premise, not a measurement (the #57 shape):
* - the required KEYS are parsed out of each rule's own ```yaml block;
* - the files swept are resolved from each rule's own `paths:` frontmatter;
* - the plugin name is read from `.claude-plugin/plugin.json`.
* Add a key to a rule and it is enforced on the next run, with no test edit.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile, readdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const RULES_DIR = resolve(ROOT, '.claude', 'rules');
function frontmatterOf(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
return m ? m[1] : '';
}
/** Top-level keys of a frontmatter block, in source order. */
function topLevelKeys(frontmatter) {
return frontmatter
.split('\n')
.map((line) => line.match(/^([A-Za-z][A-Za-z0-9_-]*):/))
.filter(Boolean)
.map((m) => m[1]);
}
function valueOf(frontmatter, key) {
const m = frontmatter.match(new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'));
return m ? m[1].trim() : null;
}
/**
* A rule file is the contract. Read the required keys out of the ```yaml block
* under "## Required Frontmatter", and the swept directory out of `paths:`.
*/
async function loadRule(fileName) {
const content = await readFile(resolve(RULES_DIR, fileName), 'utf-8');
const paths = valueOf(frontmatterOf(content), 'paths');
const fence = content.match(/##\s+Required Frontmatter[\s\S]*?```ya?ml\r?\n([\s\S]*?)```/);
const requiredKeys = fence ? topLevelKeys(fence[1]).filter((k) => k !== '---') : [];
const dir = paths ? paths.split('/')[0] : null;
return { fileName, paths, dir, requiredKeys };
}
async function loadTargets(rule) {
const dirAbs = resolve(ROOT, rule.dir);
const names = (await readdir(dirAbs)).filter((n) => n.endsWith('.md')).sort();
return Promise.all(
names.map(async (name) => {
const frontmatter = frontmatterOf(await readFile(resolve(dirAbs, name), 'utf-8'));
return { name, frontmatter, keys: topLevelKeys(frontmatter) };
}),
);
}
const AGENT_RULE = 'agent-development.md';
const COMMAND_RULE = 'command-development.md';
test('R6 derivation is not vacuous: both rules yield required keys and a non-empty file set', async () => {
for (const fileName of [AGENT_RULE, COMMAND_RULE]) {
const rule = await loadRule(fileName);
assert.ok(rule.requiredKeys.length > 0,
`${fileName}: no required keys parsed from its "Required Frontmatter" yaml block — every assertion below would be vacuously green`);
assert.ok(rule.dir, `${fileName}: no paths: frontmatter to resolve a file set from`);
const targets = await loadTargets(rule);
assert.ok(targets.length > 0,
`${fileName}: paths: ${rule.paths} resolved to 0 files — the sweep would prove nothing`);
}
});
test('R6: every agent and command carries the keys its rule requires, non-empty', async () => {
for (const fileName of [AGENT_RULE, COMMAND_RULE]) {
const rule = await loadRule(fileName);
const targets = await loadTargets(rule);
for (const target of targets) {
for (const key of rule.requiredKeys) {
assert.ok(target.keys.includes(key),
`${rule.dir}/${target.name} is missing required frontmatter key "${key}" (required by .claude/rules/${fileName}; ${targets.length} files swept)`);
const value = valueOf(target.frontmatter, key);
assert.ok(value !== null && value !== '',
`${rule.dir}/${target.name} has an empty "${key}" — a present-but-empty key satisfies no contract`);
}
}
}
});
test('R6: agent colors are unique within the plugin', async () => {
const rule = await loadRule(AGENT_RULE);
const targets = await loadTargets(rule);
const seen = new Map();
for (const target of targets) {
const color = valueOf(target.frontmatter, 'color');
if (seen.has(color)) {
assert.fail(`duplicate agent color "${color}": ${seen.get(color)} and ${target.name} (.claude/rules/${AGENT_RULE}: "Color must be unique within the plugin")`);
}
seen.set(color, target.name);
}
assert.equal(seen.size, targets.length, `expected ${targets.length} distinct colors, got ${seen.size}`);
});
test('R6: agent names are kebab-case with the -agent suffix', async () => {
const rule = await loadRule(AGENT_RULE);
for (const target of await loadTargets(rule)) {
const name = valueOf(target.frontmatter, 'name');
assert.match(name, /^[a-z0-9]+(?:-[a-z0-9]+)*-agent$/,
`agents/${target.name}: name "${name}" must be kebab-case with an -agent suffix`);
}
});
test('R6: command names are plugin:action, or the bare plugin name for the router', async () => {
const plugin = JSON.parse(await readFile(resolve(ROOT, '.claude-plugin', 'plugin.json'), 'utf-8')).name;
const rule = await loadRule(COMMAND_RULE);
const targets = await loadTargets(rule);
const routers = [];
for (const target of targets) {
const name = valueOf(target.frontmatter, 'name');
if (name === plugin) { routers.push(target.name); continue; }
assert.match(name, new RegExp(`^${plugin}:[a-z0-9]+(?:-[a-z0-9]+)*$`),
`commands/${target.name}: name "${name}" must be "${plugin}:action" (or the bare "${plugin}" router)`);
}
assert.equal(routers.length, 1, `expected exactly one bare-"${plugin}" router command, got ${routers.length}: ${routers.join(', ')}`);
});

View file

@ -41,13 +41,33 @@ async function readCommand(name) {
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
}
test('Action: every file contains a Bash invocation block', async () => {
// plan.md invokes no scanner — it spawns the planner agent. Its only bash
// block used to be a `RAW_FLAG=` assignment referenced from the agent prompt
// below it; a prompt is not a shell, so the agent received the literal string
// `$RAW_FLAG` (confirmed at runtime by the planner agent, session #49).
// Removing that block is the fix, so this assertion skips it.
const AGENT_DRIVEN = new Set(['plan.md']);
test('Action: every scanner-invoking file contains a Bash invocation block', async () => {
for (const name of ACTION_FILES) {
if (AGENT_DRIVEN.has(name)) continue;
const content = await readCommand(name);
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
}
});
test('Action: agent-driven files spawn an Agent instead of a scanner', async () => {
for (const name of AGENT_DRIVEN) {
const content = await readCommand(name);
assert.match(content, /Agent\(subagent_type:/, `${name} should spawn an Agent`);
assert.doesNotMatch(
content,
/RAW_FLAG=/,
`${name} must not assign a shell variable it then references from an agent prompt`,
);
}
});
test('Action: every file references the Read tool', async () => {
for (const name of ACTION_FILES) {
const content = await readCommand(name);

Some files were not shown because too many files have changed in this diff Show more