Compare commits

..

No commits in common. "main" and "v5.12.2" have entirely different histories.

194 changed files with 1148 additions and 16460 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": "6.0.0",
"version": "5.12.2",
"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, Agent
allowed-tools: Read, Write, Bash, Task
model: sonnet
---
```

3
.gitignore vendored
View file

@ -38,8 +38,5 @@ 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,586 +5,6 @@ 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,10 +1,13 @@
# Config-Audit Plugin
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).
Claude Code Configuration Intelligence — know if your configuration is correct, find what could improve it, fix it automatically.
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.
## What this plugin does
**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.
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
## Commands
@ -12,15 +15,15 @@ Per-command flags, patterns, and feature lists live in `README.md` and `/config-
| Command | Description |
|---------|-------------|
| `/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` | 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 fix` | Auto-fix deterministic issues with backup + verification |
| `/config-audit rollback` | Restore configuration from backup |
| `/config-audit plan` | Create action plan from findings |
| `/config-audit plan` | Create action plan from audit findings |
| `/config-audit implement` | Execute plan with backups + auto-verify |
| `/config-audit help` | Show all commands |
@ -30,9 +33,9 @@ Per-command flags, patterns, and feature lists live in `README.md` and `/config-
|---------|-------------|
| `/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 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 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 discover` | Run discovery phase only |
| `/config-audit analyze` | Run analysis phase only |
| `/config-audit interview` | Gather user preferences (opt-in) |
@ -48,227 +51,84 @@ Per-command flags, patterns, and feature lists live in `README.md` and `/config-
| 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 | Feature recommendations | opus | green | Read, Glob, Grep, Write |
| optimization-lens-agent | Mechanism-fit precision gate | opus | orange | Read, Glob, Grep, Write |
| 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 |
## Hooks
| Event | Script | Purpose |
|-------|--------|---------|
| 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 |
| 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 |
## Reference docs (read on demand)
- `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
- **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`
## Plain-Language Output (v5.1.0)
## Plain-Language Output (v5.1.0) — summary
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`.
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`.
## Suppressions
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`.
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`.
## Architecture
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).
### 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).
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`.
### 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)
```
**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.
### 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`
## Conventions
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)
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)
- `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'
```
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**.
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**.
## 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

131
GOVERNANCE.md Normal file
View file

@ -0,0 +1,131 @@
# 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,46 +1,27 @@
# config-audit
# Config-Audit Plugin for Claude Code
Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine
> Know if your configuration is correct. Find what could improve it. Fix it automatically.
Know if your configuration is correct. Find what could improve it. Fix it automatically.
> **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.
> **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.
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
*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)
![Version](https://img.shields.io/badge/version-5.12.2-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)
@ -58,13 +39,37 @@ Or enable directly in `~/.claude/settings.json`:
- [Testing](#testing)
- [Gotchas](#gotchas)
- [Data Storage & Safety Guarantees](#data-storage--safety-guarantees)
- [Non-goals](#non-goals)
- [config-audit vs. the built-in /doctor](#config-audit-vs-the-built-in-doctor)
- [Changelog](#changelog)
- [What This Plugin Does Not Cover](#what-this-plugin-does-not-cover)
- [Version History](#version-history)
- [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.
@ -149,7 +154,28 @@ Also **Grade A** — with only 3 opportunities remaining. This project has CLAUD
## Quick Start
Install first — see [Install](#install) above.
### 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
}
}
```
### First Scan
@ -172,7 +198,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 24 dimensions and groups recommendations by impact:
The feature opportunity scanner checks 25 dimensions and groups recommendations by impact:
| Impact Level | Focus | Examples |
|--------------|-------|---------|
@ -180,20 +206,9 @@ The feature opportunity scanner checks 24 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. 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.
Run `/config-audit feature-gap` to see what's relevant to your project.
---
@ -264,9 +279,6 @@ 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 |
@ -292,28 +304,6 @@ 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
@ -324,14 +314,14 @@ guard nothing.
| 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, 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 |
| `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 |
| `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 | 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 |
| `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) |
| `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 |
@ -341,27 +331,6 @@ guard nothing.
| `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
@ -450,7 +419,6 @@ 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]` |
@ -522,12 +490,7 @@ Skills activate automatically when your question matches their trigger patterns.
### Finding ID Format
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.
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`.
### Suppression
@ -546,8 +509,6 @@ 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
@ -596,7 +557,6 @@ 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
@ -609,8 +569,6 @@ 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 |
---
@ -637,65 +595,6 @@ 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
@ -704,7 +603,7 @@ the finding, and the reason precision-over-recall is the only defensible tuning.
node --test 'tests/**/*.test.mjs'
```
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`).
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`).
---
@ -747,78 +646,22 @@ 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".
---
## Non-goals
## What This Plugin Does Not Cover
- **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)
- **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)
- **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
---
## 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 History
| 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. |
@ -851,6 +694,8 @@ Full detail in [CHANGELOG.md](CHANGELOG.md). Highlights per release:
| **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

View file

@ -1,34 +0,0 @@
# 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,16 +51,11 @@ 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**: Compose the comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
8. **Generate report**: Write comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
## Output
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.
Write to: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
**Output MUST NOT exceed 300 lines.** Prioritize findings by severity. Use tables, not prose.
@ -188,4 +183,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; the report is returned as your final message)
- No file modifications (read-only except report output)

View file

@ -146,11 +146,6 @@ 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,48 +33,6 @@ 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
@ -87,16 +45,6 @@ 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
@ -156,17 +104,6 @@ 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,10 +171,18 @@ Total backup size: ~6.4 KB
**Rationale**:
Code style rules found in 3 projects are identical. Moving to global reduces duplication.
**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.
**Content**:
```markdown
# Code Style Rules
## Language Preferences
- TypeScript > JavaScript
- Explicit > implicit
- Lesbarhet > cleverness
## Commit Format
- Conventional Commits: `type(scope): description`
```
**Validation**:
- File exists after creation

View file

@ -140,15 +140,7 @@ Checking for secrets...
## Output Format
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.
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
```markdown
## Verification Report
@ -251,8 +243,8 @@ Optional: Generate before/after comparison:
This agent:
- Only uses Read, Glob, Grep tools
- Never modifies any files, including the shared implementation log
- Reports findings without taking action — every result is returned inline
- Never modifies any files
- Reports findings without taking action
- Safe to run multiple times
## Model policy

View file

@ -20,16 +20,9 @@ Generate comprehensive analysis report from discovery findings.
## Implementation
### Step 1: Resolve the session and verify its state
### Step 1: Verify session state
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."
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."
### Step 2: Tell the user what's happening
@ -44,16 +37,17 @@ This includes hierarchy mapping, conflict detection, and prioritized recommendat
Tell the user: **"Generating analysis (this takes about 30 seconds)..."**
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`.
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
```
```
Agent(subagent_type: "config-audit:analyzer-agent")
model: sonnet
prompt: |
Analyze all findings in: ~/.claude/config-audit/sessions/{session-id}/findings/
Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
Mode: $RAW_FLAG (empty = 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
@ -66,21 +60,12 @@ 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.
Return the complete report as your final message. Do not write it
to a file — the orchestrating command saves it to the session directory.
Output to: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
```
### Step 4: Save the report
### Step 4: Present 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:
After the agent completes, read the generated report and show a brief summary:
```markdown
### Analysis Complete
@ -99,6 +84,6 @@ Full report: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
- **`/config-audit fix`** — Auto-fix deterministic issues right away
```
### Step 6: Update state
### Step 5: Update state
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.
Update `state.yaml` with `current_phase: "analyze"`, `next_phase: "plan"`.

View file

@ -62,8 +62,7 @@ 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.
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.
Set a shared date stamp for any write: `TODAY=$(date +%F)`.
### Step 2: Always report current state first
@ -148,9 +147,6 @@ 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 $?
@ -174,23 +170,13 @@ at `~/.claude/config-audit/campaign-ledger.json`." Then suggest `add`.
them in one call (idempotent — already-tracked repos are skipped, not reset):
```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 add "<path1>" "<path2>" ... \
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`, `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.
report what was `added` vs `skipped`, then re-show the repo table.
### Step 5 (mode `set-status`): Transition a repo — propose, approve, write
@ -208,12 +194,9 @@ roll-up stays meaningful. Two honest sources, in order of preference:
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 set-status "<path>" "<status>" \
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 $?
```
@ -232,9 +215,6 @@ 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 $?
@ -255,9 +235,6 @@ 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 $?
@ -275,24 +252,9 @@ 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,13 +75,7 @@ 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**:
- **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}/`
- For each session to delete: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
- Track deleted count and freed space
7. **Output summary**:

View file

@ -83,41 +83,29 @@ This is a silent infrastructure step — do NOT show output to the user.
### Step 3: Run scanners and posture assessment
Tell the user: **"Running 16 configuration scanners..."**
Tell the user: **"Running 12 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 -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"
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 $?
```
Use `--full-machine` for `full` scope, `--global` for `home` scope. For `repo` and `current`, pass the resolved path directly.
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.
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.
### Step 4: Analyze results
Tell the user: **"Scanners complete. Preparing your results..."**
Read all three output files using the Read tool:
Read BOTH 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:
@ -158,7 +146,7 @@ Present results using this template. The humanizer has already replaced jargon-h
**Health: {overallGrade}** | {qualityAreaCount} areas scanned
{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.}
{Use the headline line from the humanized stderr scorecard — it carries grade-context prose already. Avoid hardcoding a separate per-grade prose ladder.}
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.)"}
@ -172,11 +160,9 @@ Scanned {files_scanned} files | {real_finding_count} findings ({severity_breakdo
| Settings | {grade} | {count} | {status} |
| Hooks | {grade} | {count} | {status} |
| Rules | {grade} | {count} | {status} |
| MCP | {grade} | {count} | {status} |
| MCP Servers | {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,19 +72,14 @@ 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
# 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 $?
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 $?
```
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 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.
Write `scope.yaml` and `state.yaml` to session directory. Update state with `current_phase: "discover"`, `next_phase: "analyze"`.
### 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>" --json $RAW_FLAG 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --save --name <baseline-name> $RAW_FLAG 2>/dev/null
```
`--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:
Read stdout for confirmation. Tell the user:
```markdown
### Baseline Saved
@ -50,16 +50,10 @@ 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>" --output-file /tmp/config-audit-drift.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --baseline <name> $RAW_FLAG 2>/dev/null
```
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.
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.
If baseline not found, tell the user:
@ -102,14 +96,9 @@ When iterating new/resolved findings, prefer `userActionLanguage` over raw `seve
If `$ARGUMENTS` contains `--list`:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list --output-file /tmp/config-audit-baselines.json 2>/dev/null; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list 2>/dev/null
```
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 >/dev/null 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 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,23 +128,15 @@ 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.
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:
1. **Create backup** of any files that will be modified:
```bash
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 $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <target-path> --json 2>/dev/null
```
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`.
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.
2. **Apply the template** from gap-closure-templates.md. Use the Write or Edit tool to create or modify the relevant configuration file.
@ -159,12 +151,9 @@ 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 >/dev/null 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --json --output-file /tmp/config-audit-verify-$$.json 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,13 +15,8 @@ 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
@ -39,11 +34,7 @@ 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
# 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 $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <path> --output-file /tmp/config-audit-fix-scan-$$.json [--global] $RAW_FLAG 2>/dev/null; echo $?
```
Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check your configuration."
@ -53,31 +44,13 @@ 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
# 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 $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --json 2>/dev/null
```
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.
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.
### 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
@ -89,9 +62,9 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
#### {userActionLanguage}
| # | ID | Issue | File | Scope |
|---|-----|-------|------|-------|
| 1 | {id} | {humanized title} | {file} | {scopeClass, or blank when "in-repo"} |
| # | ID | Issue | File |
|---|-----|-------|------|
| 1 | {id} | {humanized title} | {file} |
**Manual ({M} issues — require human judgment), grouped by impact:**
@ -104,10 +77,7 @@ 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. Render each distinct string in the scope
payload's `disclosures[]` verbatim first.
When `requiresApproval` is false:
If not `--dry-run`, ask for confirmation:
```
AskUserQuestion:
@ -118,59 +88,24 @@ 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
# 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 $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --apply --json 2>/dev/null
```
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.
Read the JSON output to get applied/failed counts and backup location.
### 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 >/dev/null 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <path> --json --output-file /tmp/config-audit-fix-posture-$$.json 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
@ -204,8 +139,7 @@ Run `/config-audit plan` to get a step-by-step guide for addressing these.
## Safety
- 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)
- Backup is **mandatory** — every fix creates a backup first
- Dry-run by default — user must confirm before changes
- Verify after fix — re-scans in the **same scope** the fix run used, so a `--global` run is verified against user scope too
- Verify after fix — re-scans to confirm findings resolved
- Rollback always available — `/config-audit rollback <backup-id>`
- A failed fix is reported, never swallowed — exit 2 plus a `failed[]` entry

View file

@ -22,47 +22,24 @@ 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
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 $?
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
```
Exit 0 = classified; 3 = argument error (show the stderr message). Read
`/tmp/config-audit-implement-scope.json`. Tell the user:
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:
```
## 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."
@ -72,47 +49,15 @@ AskUserQuestion:
- "Cancel"
```
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":
```
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"
```
### 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.
Create backup silently:
```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 $?
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
```
| 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.
Copy each file to be modified. Generate `manifest.yaml` with checksums.
Tell the user: **"Backup created. Implementing actions..."**
@ -126,15 +71,13 @@ Agent(subagent_type: "config-audit:implementer-agent")
prompt: |
Execute action: {action-id}
File: {file-path}, Type: {create|modify|delete}
Mode: {mode} ("humanized" = humanized progress prose; "--raw" = v5.0.0 verbatim)
Mode: $RAW_FLAG (empty = 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:
@ -157,19 +100,7 @@ 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
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
Report to: ~/.claude/config-audit/sessions/{session-id}/implementation-log.md
```
If verifier finds issues: one retry with implementer agent. If still failing: report and suggest rollback.
@ -181,51 +112,27 @@ 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/{backup-id}/`
**Rollback:** `/config-audit rollback {backup-id}`
**Backup location:** `~/.claude/config-audit/backups/{timestamp}/`
**Rollback:** `/config-audit rollback {timestamp}`
**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 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.
Update `state.yaml` with `current_phase: "implement"`, `next_phase: null`.
## Rollback
If the user requests rollback at any point:
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.
1. Read `manifest.yaml` from backup
2. Restore each file and verify checksums
3. Delete newly created files
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 an `Agent` subagent.
AskUserQuestion requires synchronous terminal interaction and does not work when delegated to an `Agent` subagent.
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.
## Prerequisites
@ -32,29 +32,10 @@ AskUserQuestion requires synchronous terminal interaction and does not work when
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
```
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.
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.
3. **Save interview results**: Write to `~/.claude/config-audit/sessions/{session-id}/interview.md`
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.
4. **Update state** (see state-management rule)
5. **Output summary**
## Interview Questions

View file

@ -44,21 +44,14 @@ 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_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 "$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 $?
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
node ${CLAUDE_PLUGIN_ROOT}/scanners/knowledge-refresh-cli.mjs \
--reference-date "$TODAY" $STALE_AFTER \
--output-file ~/.claude/config-audit/sessions/knowledge-refresh.json 2>/dev/null; echo $?
```
Exit code **0** = all fresh, **1** = some stale (advisory, normal), **3** = real error →
@ -107,27 +100,16 @@ 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 `${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.
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.
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
```
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.
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,9 +39,10 @@ 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 /tmp/config-audit-manifest.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file "$TMPFILE" $RAW_FLAG 2>/dev/null; echo $?
```
**Exit code handling:**
@ -51,14 +52,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs "<path>" --output-file /tmp/con
### Step 3: If `--json` was requested, cat the file and stop
```bash
cat /tmp/config-audit-manifest.json
cat "$TMPFILE"
```
Do NOT render the table in JSON mode.
### Step 4: Read JSON and render
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):
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):
```markdown
**Token-source manifest for `<repoPath>`** — ~{total} tokens total
@ -69,11 +70,10 @@ Use the Read tool on `/tmp/config-audit-manifest.json`. Extract `meta.repoPath`,
| Rank | Kind | Name | Source | Tokens | Load |
|------|------|------|--------|--------|------|
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} | {loadPattern} |
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} | {load} |
| ... | ... | ... | ... | ... | ... |
_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,9 +24,6 @@ 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.
@ -37,48 +34,7 @@ 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), `--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.
```
cascade in discovery).
Tell the user:
@ -96,19 +52,7 @@ 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
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 $?
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 $?
```
Exit code 0 is normal. Only exit code 3 is a real error → "The lens couldn't run.
@ -120,24 +64,8 @@ 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`.
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:
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`,
skip the agent and tell the user plainly:
```
✓ No mechanism-fit opportunities found.
@ -175,88 +103,7 @@ 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 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
### Step 6: Next steps
End with context-sensitive next steps, explaining WHY each is useful:
@ -270,26 +117,7 @@ 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.
- 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.
- No files are modified. To act on a finding, use `/config-audit plan`
`/config-audit implement` (backup + rollback) or edit by hand.

View file

@ -22,11 +22,7 @@ Generate a prioritized action plan based on analysis results.
### Step 1: Verify session state
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.
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."
### Step 2: Tell the user what's happening
@ -39,10 +35,10 @@ Actions are ordered by impact, with risk assessment and dependency tracking.
### Step 3: Parse flags and spawn planner agent
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.
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
```
Tell the user: **"Generating your action plan (this takes about 30 seconds)..."**
@ -53,7 +49,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: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
Mode: $RAW_FLAG (empty = 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")
@ -73,22 +69,7 @@ Agent(subagent_type: "config-audit:planner-agent")
### Step 4: Present the plan summary
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:
Read the generated plan and show a concise overview:
```markdown
### Action Plan Ready
@ -113,14 +94,7 @@ You can edit the plan file to remove, reorder, or modify actions before implemen
### Step 5: Update state
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.
Update `state.yaml` with `current_phase: "plan"`, `next_phase: "implement"`.
## Plan Modification

View file

@ -32,21 +32,15 @@ Auditing {N} plugin(s) for structure, frontmatter quality, and cross-plugin conf
### Step 2: Run scanner
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.
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.
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
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 $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <path> $RAW_FLAG 2>/dev/null
```
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.
Read stdout output (JSON) using the Read tool. Parse findings.
### Step 3: Present results
@ -55,7 +49,7 @@ The payload carries three things the report needs:
| Plugin | Grade | Commands | Agents | Status |
|--------|-------|----------|--------|--------|
| {plugins[].name} | {plugins[].grade} ({plugins[].score}) | {plugins[].commandCount} | {plugins[].agentCount} | {Good/Issues found} |
| {name} | {grade} ({score}) | {cmd_count} | {agent_count} | {Good/Issues found} |
| ... | ... | ... | ... | ... |
{If cross-plugin issues:}

View file

@ -42,35 +42,27 @@ 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 >/dev/null 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 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
Use the Read tool on `/tmp/config-audit-posture.json`. Extract:
Read the JSON output file using the Read tool. 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 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.
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.
### Step 4: Present the scorecard
```markdown
**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)
**Health: {overallGrade}** | {qualityAreaCount} areas scanned
{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.}
@ -101,19 +93,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>" --output-file /tmp/config-audit-posture-drift.json 2>/dev/null; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <target-path> 2>/dev/null
```
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.
Read stdout output and append a "Configuration Drift" section showing what changed since the last baseline.
**If `--plugin-health` flag is present:**
Run plugin health scanner silently:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs "<target-path>" --output-file /tmp/config-audit-posture-plh.json 2>/dev/null; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <target-path> 2>/dev/null
```
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.
Read stdout output and append a "Plugin Health" section.
**If both flags:** Use `scanners/lib/report-generator.mjs` to produce a unified markdown report.
@ -121,9 +113,5 @@ Use the Read tool on `/tmp/config-audit-posture-plh.json` and append a "Plugin H
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 >/dev/null 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --json --output-file ~/.claude/config-audit/sessions/<session-id>/posture.json 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,12 +10,6 @@ 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`)
@ -25,18 +19,14 @@ those checks, and the result cannot honestly be reported as verified.
### 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
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --list --output-file /tmp/config-audit-rollback-list.json 2>/dev/null; echo $?
ls -1 ~/.claude/config-audit/backups/
```
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
@ -50,26 +40,12 @@ its path differs from the one printed below.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
If `{count}` is 0, say there are no backups yet and stop — do not offer a restore.
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.
### Restore mode (with backup ID)
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:
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:
```
AskUserQuestion:
question: "Restore 3 files from backup 20260403_163045?"
@ -77,83 +53,38 @@ If `{count}` is 0, say there are no backups yet and stop — do not offer a rest
- "Yes, restore"
- "Cancel"
```
When `requiresApproval` is true, name the scope and put the safe option first:
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:
```
AskUserQuestion:
question: "This restores {K} of 3 files to locations outside this project. Restore all 3?"
options:
- "Cancel"
- "Yes — restore, including outside this project"
Restored 3 files from backup 20260403_163045
- .claude/settings.json (checksum verified)
- hooks/hooks.json (checksum verified)
- .claude/rules/typescript.md (checksum verified)
```
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 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.
If user says "delete" after listing, confirm and remove the backup directory.
## Implementation
`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.
Use the backup and rollback libraries directly:
```javascript
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
import { parseManifest } from '../scanners/lib/backup.mjs';
```
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.
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}
```

View file

@ -37,13 +37,8 @@ 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
@ -133,13 +128,11 @@ All config-audit sessions:
| 20250120_160000 | implement | 2025-01-20 16:00 |
```
## Resuming a session
## Resume Session
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.
If multiple sessions exist:
```
/config-audit resume {session-id}
```
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`.
Sets that session as active and continues from last phase.

View file

@ -40,25 +40,12 @@ 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
# 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 $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] [--no-exclude-cache] $RAW_FLAG 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.
@ -66,14 +53,14 @@ required because those two modes also print the payload to stdout even with
### Step 3: If `--json` was requested, cat the file and stop
```bash
cat /tmp/config-audit-tokens.json
cat "$TMPFILE"
```
Do NOT render tables in JSON mode.
### Step 4: Read JSON and render
Use the Read tool on `/tmp/config-audit-tokens.json`. Extract:
Use the Read tool on `$TMPFILE`. 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`)
@ -122,7 +109,7 @@ _Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±20
### Step 5: Cleanup and next steps
```bash
rm -f /tmp/config-audit-tokens.json
rm -f "$TMPFILE"
```
```markdown

View file

@ -33,14 +33,10 @@ 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
# 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 $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPFILE" [--verbose] [--suggest-disables] $RAW_FLAG 2>/dev/null; echo $?
```
**Exit code handling:**
@ -50,14 +46,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs "<path>" --output-file /tmp
### Step 3: If `--json` was requested, cat the file and stop
```bash
cat /tmp/config-audit-whats-active.json
cat "$TMPFILE"
```
Do NOT render tables in JSON mode.
### Step 4: Read JSON and render
Use the Read tool on `/tmp/config-audit-whats-active.json`. Extract:
Use the Read tool on `$TMPFILE`. Extract:
- `meta.repoPath`, `meta.durationMs`, `meta.gitRoot`, `meta.projectKey`
- `totals.estimatedTokens.grandTotal` (and subtotals)
@ -94,17 +90,7 @@ Render as markdown:
|-------|--------|--------|
| {name} | {source}{if pluginName: ` (${pluginName})`} | ~{estimatedTokens} |
### 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.
### MCP Servers ({mcpServers.length}, ~{mcpServers subtotal} tokens)
| Server | Source | Status | Command |
|--------|--------|--------|---------|
@ -163,7 +149,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 /tmp/config-audit-whats-active.json
rm -f "$TMPFILE"
```
```markdown

View file

@ -1,400 +0,0 @@
# 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

@ -1,244 +0,0 @@
# 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 | 24 feature checks across 4 tiers — shown as opportunities, not grades |
| `feature-gap-scanner.mjs` | GAP | 25 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,8 +44,6 @@ 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/`)
@ -221,46 +219,6 @@ 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
@ -296,39 +254,6 @@ 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
@ -439,43 +364,6 @@ 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
@ -578,56 +466,6 @@ 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.
@ -769,107 +607,3 @@ 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

@ -1,228 +0,0 @@
# 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

@ -1,136 +0,0 @@
# 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.

View file

@ -1,96 +0,0 @@
# 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,11 +6,7 @@ import { readdirSync, readFileSync, existsSync } from 'fs';
import { join, basename } from 'path';
import { homedir } from 'os';
// 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;
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
if (!existsSync(sessionsDir)) {
process.exit(0);

View file

@ -6,11 +6,7 @@ import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
import { join, basename, dirname } from 'path';
import { homedir } from 'os';
// 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;
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
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; 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.",
"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.",
"entries": [
{
"id": "BP-MECH-001",
@ -12,11 +12,7 @@
"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",
@ -28,11 +24,7 @@
"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",
@ -44,11 +36,7 @@
"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",
@ -60,11 +48,7 @@
"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",
@ -76,11 +60,7 @@
"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",
@ -89,11 +69,7 @@
"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",
@ -102,11 +78,7 @@
"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",
@ -115,11 +87,7 @@
"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",
@ -128,11 +96,7 @@
"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",
@ -141,11 +105,7 @@
"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",
@ -154,11 +114,7 @@
"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",
@ -169,11 +125,7 @@
"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",
@ -183,120 +135,7 @@
"severity": "low",
"category": "size-budget",
"lensCheck": "CA-SKL-002",
"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"
}
"source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" }
}
]
}

View file

@ -65,7 +65,6 @@ 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:
@ -92,7 +91,6 @@ 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,28 +30,7 @@ const SCANNER = 'CPS';
// hits per turn, not to chase every inline date in a long backlog file.
const CACHED_PREFIX_LINES = 150;
// 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.
// Volatile-pattern set (extends token-hotspots.mjs Pattern A).
const VOLATILE_PATTERNS = [
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
{ rx: /\{uuid\}/i, label: '{uuid} placeholder' },
@ -62,7 +41,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', varAware: true },
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution' },
];
/**
@ -89,35 +68,18 @@ 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++) {
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;
for (const { rx, label } of VOLATILE_PATTERNS) {
if (rx.test(lines[i])) {
out.push({
line: i + 1,
label,
snippet: line.length > 120 ? line.slice(0, 117) + '...' : line,
snippet: lines[i].length > 120 ? lines[i].slice(0, 117) + '...' : lines[i],
});
break;
}
}
}
return out;
}
@ -157,7 +119,6 @@ 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:
@ -200,7 +161,6 @@ 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,8 +23,7 @@
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { findArgError } from './lib/cli-args.mjs';
import { writeFile } from 'node:fs/promises';
import {
loadLedger,
validateLedger,
@ -33,32 +32,20 @@ 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) {
throw new CliUsageError(message);
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
}
/** 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') ledgerFile = args[++i];
else if (a === '--output-file') outputFile = args[++i];
if (a === '--ledger-file' && args[i + 1]) ledgerFile = args[++i];
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
}
const ledgerPath = resolve(ledgerFile || defaultLedgerPath());
@ -109,18 +96,17 @@ async function main() {
}
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exitCode = exitCode;
process.exit(exitCode);
}
const isDirectRun =
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -35,41 +35,18 @@
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) {
throw new CliUsageError(message);
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
}
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
@ -78,7 +55,7 @@ function defaultSessionsDir() {
}
function parseArgs(argv) {
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false, approveScope: false, sessionRoot: null };
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo' && argv[i + 1] !== undefined) flags.repo = argv[++i];
@ -87,8 +64,6 @@ 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}"`);
}
@ -97,16 +72,13 @@ function parseArgs(argv) {
async function emit(payload, outputFile, exitCode) {
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exitCode = exitCode;
process.exit(exitCode);
}
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const flags = parseArgs(args);
const flags = parseArgs(process.argv.slice(2));
if (!flags.repo) fail('--repo <path> is required');
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
@ -168,32 +140,6 @@ 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 });
@ -203,9 +149,7 @@ async function main() {
return emit(
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
exportable: true, problems: [], written, targetPath, document,
gate: scope.gate, requiresApproval: scope.requiresApproval,
disclosures: scope.disclosures },
exportable: true, problems: [], written, targetPath, document },
flags.outputFile,
0,
);
@ -215,8 +159,7 @@ const isDirectRun =
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -43,8 +43,7 @@
*/
import { resolve } from 'node:path';
import { stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { writeFile } from 'node:fs/promises';
import {
createLedger,
addRepo,
@ -58,63 +57,32 @@ 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) {
throw new CliUsageError(message);
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
}
/** 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') 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];
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}"`);
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 {
@ -126,9 +94,9 @@ async function loadOrFail(path) {
async function emit(payload, outputFile, exitCode) {
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exitCode = exitCode;
process.exit(exitCode);
}
async function main() {
@ -172,7 +140,6 @@ 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);
@ -180,17 +147,13 @@ 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 });
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);
(present ? skipped : added).push(resolved);
}
await saveLedger(ledgerPath, ledger);
return emit(
{
status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath,
added, addedUnverified, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
added, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
},
flags.outputFile,
0,
@ -247,13 +210,6 @@ 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 });
@ -290,8 +246,7 @@ const isDirectRun =
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -5,13 +5,12 @@
*/
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { finding, scannerResult, resetCounter } 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, resolve as resolvePath, sep } from 'node:path';
import { stat } from 'node:fs/promises';
import { dirname } from 'node:path';
const SCANNER = 'CML';
const MAX_RECOMMENDED_LINES = 200;
@ -31,134 +30,6 @@ 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' },
@ -191,7 +62,6 @@ 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.',
@ -221,7 +91,6 @@ 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.`,
@ -240,7 +109,6 @@ 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).`,
@ -252,7 +120,6 @@ 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.`,
@ -275,7 +142,6 @@ 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.`,
@ -290,7 +156,6 @@ 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.` +
@ -307,7 +172,6 @@ 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.`,
@ -333,7 +197,6 @@ 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(', ')}`,
@ -349,7 +212,6 @@ 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.`,
@ -366,7 +228,6 @@ 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.`,
@ -384,7 +245,6 @@ 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.`,
@ -406,7 +266,6 @@ 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.`,
@ -422,7 +281,6 @@ 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).`,
@ -430,40 +288,6 @@ 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,7 +74,6 @@ 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:
@ -98,7 +97,6 @@ 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,7 +5,6 @@
* 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';
@ -18,22 +17,6 @@ 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.
@ -80,10 +63,10 @@ export async function scan(targetPath, discovery) {
const start = Date.now();
const findings = [];
// 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));
// 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');
const totalFiles = settingsFiles.length + hooksFiles.length;
@ -129,7 +112,6 @@ 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}`,
@ -161,7 +143,6 @@ 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}).`,
@ -179,7 +160,6 @@ 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}).`,
@ -230,7 +210,6 @@ 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,7 +113,6 @@ 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:
@ -135,7 +134,6 @@ 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:
@ -162,7 +160,6 @@ 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:
@ -187,7 +184,6 @@ 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,22 +5,16 @@
* 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] [--output-file path]
* node drift-cli.mjs <path> [--baseline my-baseline] [--json]
* 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, humanizeFinding } from './lib/humanizer.mjs';
const BOOL_FLAGS = ['--save', '--list', '--json', '--raw', '--global'];
const VALUE_FLAGS = ['--name', '--baseline', '--output-file'];
import { humanizeFindings } from './lib/humanizer.mjs';
async function main() {
const args = process.argv.slice(2);
@ -31,50 +25,30 @@ 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++) {
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;
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];
}
}
// --- 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 {
@ -91,12 +65,7 @@ async function main() {
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
}
return;
}
if (!(await requireTargetDir(resolve(targetPath)))) {
process.exitCode = 3;
return;
process.exit(0);
}
// --- Save mode ---
@ -115,7 +84,7 @@ async function main() {
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
}
return;
process.exit(0);
}
// --- Drift mode (default) ---
@ -134,28 +103,7 @@ 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.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`
);
process.exit(1);
}
// Run current scan
@ -167,49 +115,25 @@ 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
process.exitCode = diff.summary.trend === 'degrading' ? 1 : 0;
if (diff.summary.trend === 'degrading') process.exit(1);
process.exit(0);
}
// Only run CLI if invoked directly
@ -217,6 +141,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.exitCode = 3;
process.exit(3);
});
}

View file

@ -1,14 +1,13 @@
/**
* GAP Scanner Feature Gap Scanner
* Compares actual configuration against complete Claude Code feature register.
* 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.
* 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.
* Finding IDs: CA-GAP-NNN
*/
import { resolve, join, sep } from 'node:path';
import { resolve } from 'node:path';
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
@ -47,68 +46,6 @@ 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,
@ -116,36 +53,6 @@ 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
@ -208,8 +115,7 @@ export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
return finding({
scanner: SCANNER,
severity: SEVERITY.low,
code: LEVERS.bundledSkills.code,
title: LEVERS.bundledSkills.title,
title: 'Bundled skills add to an over-budget skill listing',
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 ` +
@ -253,8 +159,7 @@ export function cliOverMcpLeverFinding({ assessment } = {}) {
return finding({
scanner: SCANNER,
severity: SEVERITY.low,
code: LEVERS.cliOverMcp.code,
title: LEVERS.cliOverMcp.title,
title: 'Prefer CLI over MCP for common operations',
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, …) ' +
@ -291,8 +196,7 @@ export function filterHookLeverFinding({ flaggedHooks } = {}) {
return finding({
scanner: SCANNER,
severity: SEVERITY.info,
code: LEVERS.filterHookOutput.code,
title: LEVERS.filterHookOutput.title,
title: 'Filter hook output before it enters context',
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, " +
@ -307,103 +211,8 @@ 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[]} */
export const GAP_CHECKS = [
const GAP_CHECKS = [
// --- Tier 1: Foundation ---
{
id: 't1_1', tier: 't1',
@ -609,11 +418,12 @@ export const GAP_CHECKS = [
return false;
},
},
// 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.
{
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'),
},
// --- Tier 4: Team/Enterprise ---
{
@ -669,30 +479,18 @@ export async function scan(targetPath, sharedDiscovery) {
? sharedDiscovery
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
// 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) ...
// Parse all settings files upfront
const parsedSettings = new Map();
for (const file of authoredFiles.filter(f => f.type === 'settings-json')) {
for (const file of discovery.files.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: authoredFiles,
files: discovery.files,
targetPath: resolve(targetPath),
parsedSettings,
fileContents: new Map(),
@ -703,7 +501,6 @@ 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}`,
@ -734,13 +531,6 @@ 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,19 +9,11 @@
*/
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 = '.';
@ -29,44 +21,18 @@ 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++) {
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;
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];
}
}
@ -75,11 +41,6 @@ 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`);
@ -142,29 +103,18 @@ 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) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
if (machineMode) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
return;
process.exit(0);
}
if (apply) {
// 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))];
// Create backup first
const filesToBackup = [...new Set(fixes.filter(f => f.type !== 'file-rename').map(f => f.file))];
const backup = createBackup(filesToBackup);
backupId = backup.backupId;
@ -173,43 +123,9 @@ async function main() {
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
}
const result = await applyFixes(fixes, {
dryRun: false,
backupDir: backup.backupPath,
repoRoot,
approveScope,
});
const result = await applyFixes(fixes, { dryRun: false, backupDir: backup.backupPath });
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`);
@ -226,10 +142,7 @@ async function main() {
process.stderr.write(`\n Verifying...\n`);
}
// 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 });
const verification = await verifyFixes(envelope, applied);
verified = verification.verified;
regressions = verification.regressions;
@ -238,11 +151,7 @@ async function main() {
if (regressions.length > 0) {
process.stderr.write(` Regressions: ${regressions.join(', ')}\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`);
process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${backupId}\n`);
}
}
} else {
@ -256,7 +165,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,
@ -283,20 +192,8 @@ async function main() {
recommendation: m.recommendation,
})),
backupId,
gate: scopeGate,
disclosures: scopeDisclosures,
};
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;
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
}
@ -305,6 +202,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.exitCode = 3;
process.exit(3);
});
}

View file

@ -8,9 +8,7 @@ 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.
@ -24,8 +22,8 @@ const FIX_TYPES = {
FILE_RENAME: 'file-rename',
};
/** Valid effortLevel values for nearest-match — the validator's list, not a copy. */
const VALID_EFFORT_LEVELS = [...SETTINGS_EFFORT_LEVELS];
/** Valid effortLevel values for nearest-match */
const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
/**
* Plan fixes from a scanner envelope.
@ -58,21 +56,9 @@ export function planFixes(envelope) {
}
}
// 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.
// Sort fixes by severity weight (critical first)
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 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);
});
fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));
return { fixes, skipped, manual };
}
@ -238,42 +224,6 @@ 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({
@ -306,14 +256,7 @@ export async function applyFixes(fixPlans, opts = {}) {
}
}
return {
applied,
failed,
gate: scope.gate,
requiresApproval: scope.requiresApproval,
disclosures: scope.disclosures,
refused: [],
};
return { applied, failed };
}
/**
@ -657,29 +600,20 @@ 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, opts = {}) {
export async function verifyFixes(originalEnvelope, appliedResults) {
const targetPath = originalEnvelope.meta.target;
const verified = [];
const regressions = [];
const newFindings = [];
// 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 });
// Re-scan the target
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false });
// 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 original finding IDs that were fixed
const fixedIds = new Set(
appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
);
// Build set of new finding titles for comparison
@ -693,13 +627,11 @@ export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
// Check that fixed findings are gone
for (const scanner of originalEnvelope.scanners) {
for (const f of scanner.findings) {
if (!fixedInstances.has(instanceKey(f.id, f.file))) continue;
if (!fixedIds.has(f.id)) 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 => instanceKey(r.findingId, r.file) === instanceKey(f.id, f.file),
);
const fixResult = appliedResults.find(r => r.findingId === f.id);
if (fixResult && fixResult.type === 'file-rename') {
// Check that the finding doesn't reappear at the new path
verified.push(f.id);

View file

@ -70,7 +70,6 @@ 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.`,
@ -121,7 +120,6 @@ 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.`,
@ -137,7 +135,6 @@ 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.`,
@ -152,7 +149,6 @@ 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.`,
@ -170,7 +166,6 @@ 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".`,
@ -185,7 +180,6 @@ 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.`,
@ -201,7 +195,6 @@ 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)'}".`,
@ -223,7 +216,6 @@ 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.`,
@ -240,7 +232,6 @@ 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:
@ -268,7 +259,6 @@ 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:
@ -297,7 +287,6 @@ 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.`,
@ -309,7 +298,6 @@ 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,7 +74,6 @@ 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.`,
@ -92,7 +91,6 @@ 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.`,
@ -113,7 +111,6 @@ 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.`,
@ -132,7 +129,6 @@ 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,32 +24,19 @@
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { writeFile } from 'node:fs/promises';
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) {
throw new CliUsageError(message);
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
}
/** 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
@ -58,12 +45,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') outputFile = args[++i];
else if (a === '--stale-after') {
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
else if (a === '--stale-after' && args[i + 1] !== undefined) {
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') {
} else if (a === '--reference-date' && args[i + 1]) {
referenceDate = args[++i];
if (!DATE_RE.test(referenceDate)) fail('--reference-date must be YYYY-MM-DD');
}
@ -100,18 +87,17 @@ async function main() {
};
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exitCode = assessment.counts.stale > 0 ? 1 : 0;
process.exit(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) => {
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}

View file

@ -53,76 +53,6 @@ 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)
// ─────────────────────────────────────────────────────────────────────────
@ -272,11 +202,7 @@ 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);
// 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');
const estimatedTokens = estimateTokens(totalBytes, 'markdown');
return { files, totalBytes, totalLines, estimatedTokens };
}
@ -291,7 +217,6 @@ async function tryAddClaudeMd(absPath, scope, parent, files, seen) {
path: absPath,
scope,
bytes: s.size,
effectiveBytes: effectiveMemoryBytes(content),
lines: lineCount(content),
parent,
};
@ -647,20 +572,14 @@ async function countPluginItems(pluginRoot) {
return counts;
}
async function listMarkdownFiles(dir, recursive = false) {
async function listMarkdownFiles(dir) {
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 });
@ -743,11 +662,6 @@ 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
@ -755,16 +669,8 @@ function hasText(v) {
*/
function configDirs(repoPath, pluginList, subdir, pluginSubdirs = [subdir]) {
const home = process.env.HOME || process.env.USERPROFILE || '';
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 });
const dirs = [{ dir: join(repoPath, '.claude', subdir), source: 'project', pluginName: null }];
if (home) dirs.push({ dir: join(home, '.claude', subdir), 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 });
@ -817,24 +723,15 @@ 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, model:string|null, effort:string|null, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, 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, true); // M-BUG-3: CC scans agents dirs recursively
const files = await listMarkdownFiles(dir);
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,
@ -842,11 +739,6 @@ 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,29 +10,15 @@ 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 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');
return BACKUP_ROOT;
}
/**
@ -70,35 +56,22 @@ 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)
* @param {string[]} [opts.created] - Paths this run will create (recorded, not copied)
* @returns {{ backupId: string, backupPath: string, manifest: object, skipped: string[] }}
* @returns {{ backupId: string, backupPath: string, manifest: object }}
*/
export function createBackup(files, opts = {}) {
const backupId = opts.backupId || generateBackupId();
const backupPath = join(getBackupDir(), backupId);
const backupPath = join(BACKUP_ROOT, backupId);
const filesDir = join(backupPath, 'files');
mkdirSync(filesDir, { recursive: true });
const manifestFiles = [];
const skipped = [];
for (const file of files) {
// 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; }
if (!existsSync(file)) continue;
const safeName = safeFileName(file);
copyFileSync(file, join(filesDir, safeName));
@ -119,7 +92,6 @@ export function createBackup(files, opts = {}) {
created_at: new Date().toISOString(),
backup_id: backupId,
files: manifestFiles,
created: [...(opts.created || [])],
};
// Write manifest as YAML-like format
@ -129,7 +101,7 @@ export function createBackup(files, opts = {}) {
// Cleanup old backups
cleanupOldBackups();
return { backupId, backupPath, manifest, skipped };
return { backupId, backupPath, manifest };
}
/**
@ -147,14 +119,6 @@ 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;
}
@ -164,7 +128,7 @@ function serializeManifest(manifest) {
* @returns {object}
*/
export function parseManifest(content) {
const result = { created_at: '', backup_id: '', files: [], created: [] };
const result = { created_at: '', backup_id: '', files: [] };
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
if (createdMatch) result.created_at = createdMatch[1];
@ -172,7 +136,7 @@ export function parseManifest(content) {
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
if (idMatch) result.backup_id = idMatch[1];
// Parse file entries — engine format (quoted `original_path:` …).
// Parse file entries
const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1);
for (const block of fileBlocks) {
const origMatch = block.match(/^\s*"([^"]+)"/);
@ -190,48 +154,6 @@ 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;
}
@ -239,10 +161,9 @@ export function parseManifest(content) {
* Remove old backups beyond MAX_BACKUPS.
*/
function cleanupOldBackups() {
const backupRoot = getBackupDir();
if (!existsSync(backupRoot)) return;
if (!existsSync(BACKUP_ROOT)) return;
const dirs = readdirSync(backupRoot, { withFileTypes: true })
const dirs = readdirSync(BACKUP_ROOT, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
.sort();
@ -250,7 +171,7 @@ function cleanupOldBackups() {
if (dirs.length > MAX_BACKUPS) {
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
for (const dir of toDelete) {
rmSync(join(backupRoot, dir), { recursive: true, force: true });
rmSync(join(BACKUP_ROOT, dir), { recursive: true, force: true });
}
}
}

View file

@ -1,85 +0,0 @@
/**
* 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,11 +11,6 @@ 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

@ -1,321 +0,0 @@
/**
* 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

@ -1,126 +0,0 @@
/**
* 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,11 +77,6 @@ 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.',
@ -101,10 +96,10 @@ export const TRANSLATIONS = {
// ─────────────────────────────────────────────────────────────
SET: {
static: {
'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).',
'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.',
},
'Deprecated settings key': {
title: 'A settings key is no longer supported',
@ -440,12 +435,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 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.',
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.',
recommendation: 'Break out long sections into separate files and link them with `@import`.',
},
'No path-scoped rules': {
title: 'You haven\'t set up path-scoped rules yet',
title: 'Your rules all load on every conversation',
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.',
},
@ -495,7 +490,7 @@ export const TRANSLATIONS = {
recommendation: 'Add fields like `model`, `tools`, or `description` to your skill files where useful.',
},
'No subagent isolation': {
title: 'You haven\'t set up subagent isolation yet',
title: 'Your subagents share Claude\'s main work folder',
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.',
},
@ -504,6 +499,11 @@ 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,30 +529,6 @@ 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: {
@ -841,11 +817,6 @@ 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,7 +38,6 @@ 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,14 +16,6 @@
/** 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;
@ -54,40 +46,15 @@ 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, evidenceStaleAfterDays?: number }} opts
* @param {{ referenceDate: string|Date, staleAfterDays?: number }} opts
* @returns {{
* referenceDate: string,
* staleAfterDays: number,
* evidenceStaleAfterDays: number,
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined, reasons:string[]}>,
* stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined}>,
* fresh: Array<{id:string, verified:string|undefined, ageDays:number}>,
* counts: { total:number, stale:number, fresh:number }
* }}
@ -96,10 +63,6 @@ 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 = [];
@ -108,30 +71,14 @@ 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.
reasons.push('no-verified-date');
} else {
ageDays = Math.floor((ref.ms - vms) / DAY_MS);
if (ageDays > staleAfterDays) reasons.push('verified-age');
// 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;
}
// 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 });
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 });
} else {
fresh.push({ id: e.id, verified, ageDays });
}
@ -140,7 +87,6 @@ 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,13 +5,18 @@
*/
import { riskScore, riskBand, verdict } from './severity.mjs';
import { findingId } from './finding-codes.mjs';
let findingCounter = 0;
/** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */
export function resetCounter() {
findingCounter = 0;
}
/**
* Create a finding object. The ID names the CHECK see `finding-codes.mjs`.
* Create a finding object with auto-incremented ID.
* @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
@ -25,8 +30,10 @@ import { findingId } from './finding-codes.mjs';
* @returns {object}
*/
export function finding(opts) {
findingCounter++;
const id = `CA-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
const result = {
id: findingId(opts.scanner, opts.code),
id,
scanner: opts.scanner,
severity: opts.severity,
title: opts.title,

View file

@ -1,109 +0,0 @@
/**
* 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

@ -1,49 +0,0 @@
/**
* 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: 7, t4: 5 };
const TOTAL_DIMENSIONS = 24;
const TIER_COUNTS = { t1: 5, t2: 7, t3: 8, t4: 5 };
const TOTAL_DIMENSIONS = 25;
const MAX_WEIGHTED = Object.entries(TIER_COUNTS).reduce(
(sum, [tier, count]) => sum + count * TIER_WEIGHTS[tier],
0,
); // 5*3 + 7*2 + 7*1 + 5*1 = 41
); // 5*3 + 7*2 + 8*1 + 5*1 = 42
/**
* 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=24]
* @param {number} [totalDimensions=25]
* @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 24 gap checks */
/** Title→ID mapping for all 25 gap checks */
const TITLE_TO_ID = {
'No CLAUDE.md file': 't1_1',
'No permissions configured': 't1_2',
@ -123,6 +123,7 @@ 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',
@ -403,4 +404,4 @@ export function generateHealthScorecard(areaScores, opportunityCount, options =
return lines.join('\n');
}
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, TOTAL_DIMENSIONS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };

View file

@ -36,23 +36,6 @@ 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,41 +43,6 @@ 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

@ -1,350 +0,0 @@
/**
* 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

@ -1,242 +0,0 @@
/**
* 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,7 +8,6 @@
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.
@ -70,39 +69,6 @@ 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

@ -1,39 +0,0 @@
/**
* 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

@ -1,242 +0,0 @@
/**
* 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,13 +40,8 @@
*/
import { resolve } from 'node:path';
import { stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { writeFile, stat } from 'node:fs/promises';
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.
@ -119,10 +114,6 @@ 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));
}
@ -249,7 +240,6 @@ 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;
@ -269,13 +259,11 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exitCode = 3;
return;
process.exit(3);
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exitCode = 3;
return;
process.exit(3);
}
const start = Date.now();
@ -297,7 +285,7 @@ async function main() {
const json = JSON.stringify(output, null, 2);
if (outputFile) {
await writeOutputFile(outputFile, json, 'utf-8');
await writeFile(outputFile, json, 'utf-8');
}
if (jsonMode || rawMode || !outputFile) {
@ -309,6 +297,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.exitCode = 3;
process.exit(3);
});
}

View file

@ -56,7 +56,6 @@ 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.`,
@ -76,7 +75,6 @@ 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}".`,
@ -90,7 +88,6 @@ 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.`,
@ -113,7 +110,6 @@ 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.`,
@ -131,7 +127,6 @@ 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,7 +122,6 @@ 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,45 +20,18 @@
*
* 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, sep } from 'node:path';
import { readFile, stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { resolve } from 'node:path';
import { writeFile, readFile, stat } from 'node:fs/promises';
import { discoverConfigFiles } from './lib/file-discovery.mjs';
import { resetCounter } from './lib/output.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
import { subtractionCandidates, SUBTRACT_DETECTORS } from './lib/subtraction-prefilter.mjs';
import { 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) {
@ -68,18 +41,13 @@ 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];
}
@ -88,13 +56,11 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exitCode = 3;
return;
process.exit(3);
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exitCode = 3;
return;
process.exit(3);
}
// Load the register once; tolerate its absence (deterministic half still runs).
@ -105,13 +71,8 @@ async function main() {
register = null;
}
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)),
};
resetCounter();
const discovery = await discoverConfigFiles(absPath, { includeGlobal });
// ── Deterministic half: the OPT scanner (CA-OPT-001) ──
const opt = await optScan(absPath, discovery);
@ -119,28 +80,6 @@ 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 {
@ -151,42 +90,11 @@ 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({
// 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,
file: file.relPath || file.absPath,
line: bodyStartLine - 1 + cand.line,
lensCheck: cand.lensCheck,
mechanism: cand.mechanism,
@ -234,41 +142,9 @@ 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 writeOutputFile(outputFile, json, 'utf-8');
await writeFile(outputFile, json, 'utf-8');
}
if (!outputFile) {
process.stdout.write(json + '\n');
@ -279,6 +155,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.exitCode = 3;
process.exit(3);
});
}

View file

@ -100,7 +100,6 @@ 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:
@ -128,7 +127,6 @@ 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:
@ -157,7 +155,6 @@ 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,10 +9,8 @@
*/
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 } from './lib/output.mjs';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
@ -222,7 +220,6 @@ 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}`,
@ -239,7 +236,6 @@ 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}"`,
@ -261,7 +257,6 @@ 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:
@ -301,7 +296,6 @@ 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}`,
@ -317,7 +311,6 @@ 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}`,
@ -343,7 +336,6 @@ 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`,
@ -355,7 +347,6 @@ 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`,
@ -379,7 +370,6 @@ 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`,
@ -393,7 +383,6 @@ 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`,
@ -420,7 +409,6 @@ 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`,
@ -434,7 +422,6 @@ 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`,
@ -450,7 +437,6 @@ 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'}.`,
@ -473,7 +459,6 @@ 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`,
@ -483,7 +468,6 @@ 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`,
@ -494,7 +478,6 @@ 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}"`,
@ -507,18 +490,11 @@ async function scanSinglePlugin(pluginDir) {
const pluginMetaDir = join(pluginDir, '.claude-plugin');
try {
const entries = await readdir(pluginMetaDir);
// `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']);
const known = new Set(['plugin.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}"`,
@ -532,62 +508,27 @@ 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 {
result: scannerResult(SCANNER, 'ok', [
return 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),
plugins: [],
crossPluginFindings: [],
};
], 0, Date.now() - start);
}
const allFindings = [];
@ -599,12 +540,6 @@ export async function scanDetailed(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
@ -637,7 +572,6 @@ export async function scanDetailed(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:
@ -673,7 +607,6 @@ export async function scanDetailed(targetPath) {
if (dirs.length < 2) continue;
allFindings.push(finding({
scanner: SCANNER,
code: 'namespace-collision',
severity: SEVERITY.medium,
title: `Plugin namespace collision: "${declaredName}"`,
description:
@ -699,19 +632,7 @@ export async function scanDetailed(targetPath) {
}));
}
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),
};
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
}
/**
@ -728,7 +649,9 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
lines.push('');
for (const p of pluginResults) {
const { score, grade } = pluginGrade(p.findings.length);
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 padding = '.'.repeat(Math.max(1, 25 - p.name.length));
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
}
@ -752,55 +675,27 @@ 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++) {
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.`);
if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--raw') {
rawMode = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
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, plugins, crossPluginFindings } = await scanDetailed(targetPath);
const result = await scan(targetPath);
if (jsonMode || rawMode) {
// --json and --raw both write the v5.0.0-shape result (byte-identical).
@ -813,24 +708,6 @@ 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`);
}
}
}
@ -838,6 +715,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.exitCode = 3;
process.exit(3);
});
}

View file

@ -8,11 +8,8 @@
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { writeFile } from 'node:fs/promises';
import { runAllScanners } from './scan-orchestrator.mjs';
import { humanizeEnvelope } from './lib/humanizer.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import {
calculateUtilization,
determineMaturityLevel,
@ -23,12 +20,6 @@ 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
@ -66,7 +57,6 @@ 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;
@ -95,11 +85,6 @@ 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, {
@ -129,15 +114,8 @@ async function main() {
}
if (outputFile) {
// 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');
const json = JSON.stringify(result, null, 2);
await writeFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
}
@ -147,10 +125,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
// 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;
process.exit(1);
});
}

View file

@ -1,208 +0,0 @@
#!/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,47 +6,25 @@
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
import { join } from 'node:path';
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;
}
import { getBackupDir, parseManifest, checksum } from './lib/backup.mjs';
/**
* 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 {
continue;
return { backups: [] };
}
for (const entry of entries) {
if (!entry.isDirectory() || seen.has(entry.name)) continue;
if (!entry.isDirectory()) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
@ -55,25 +33,21 @@ 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));
@ -91,51 +65,22 @@ export async function listBackups() {
*/
export async function restoreBackup(backupId, opts = {}) {
const verify = opts.verify !== false;
const resolved = await resolveBackupPath(backupId);
if (!resolved) throw new Error(`Backup not found: ${backupId}`);
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const manifestPath = join(backupPath, 'manifest.yaml');
const backupPath = resolved.path;
const manifestContent = await readFile(join(backupPath, 'manifest.yaml'), 'utf-8');
// Read manifest
let manifestContent;
try {
manifestContent = await readFile(manifestPath, 'utf-8');
} catch {
throw new Error(`Backup not found: ${backupId}`);
}
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);
@ -194,19 +139,7 @@ export async function restoreBackup(backupId, opts = {}) {
}
}
// 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: [],
};
return { restored, failed };
}
/**
@ -215,11 +148,17 @@ export async function restoreBackup(backupId, opts = {}) {
* @returns {Promise<{ deleted: boolean, error?: string }>}
*/
export async function deleteBackup(backupId) {
const resolved = await resolveBackupPath(backupId);
if (!resolved) return { deleted: false, error: `Backup not found: ${backupId}` };
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
try {
await rm(resolved.path, { recursive: true, force: true });
await stat(backupPath);
} catch {
return { deleted: false, error: `Backup not found: ${backupId}` };
}
try {
await rm(backupPath, { 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, sep } from 'node:path';
import { join, resolve, relative } from 'node:path';
const SCANNER = 'RUL';
@ -30,16 +30,8 @@ export async function scan(targetPath, discovery) {
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
}
// 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);
}
// Collect all real files in the project for glob matching
const projectFiles = await collectProjectFiles(targetPath);
for (const file of ruleFiles) {
const content = await readTextFile(file.absPath);
@ -55,7 +47,6 @@ 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.`,
@ -70,7 +61,6 @@ 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.`,
@ -84,24 +74,14 @@ 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 (relative to the rule's root)
const matchCount = countGlobMatches(pattern, projectFiles, projectRoot);
// Check if pattern matches any real files
const matchCount = countGlobMatches(pattern, projectFiles, targetPath);
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.`,
@ -114,13 +94,11 @@ 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).`,
@ -134,7 +112,6 @@ 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.`,
@ -152,7 +129,6 @@ 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.`,
@ -167,7 +143,6 @@ 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/.`,
@ -220,18 +195,6 @@ 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);
@ -258,9 +221,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(/\{\{GLOBSTAR\}\}/g, '.*')
.replace(/\?/g, '[^/]');
// Handle leading patterns
if (!regex.startsWith('.*') && !regex.startsWith('/')) {

View file

@ -9,12 +9,10 @@
import { resolve, sep } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { evaluateWriteTargets } from './lib/write-scope.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { resetCounter } from './lib/output.mjs';
import { envelope } from './lib/output.mjs';
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
import { loadSuppressions, applySuppressions, formatSuppressionSummary, unknownSuppressions } from './lib/suppression.mjs';
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
import { humanizeEnvelope } from './lib/humanizer.mjs';
import { resolveContextWindow } from './lib/context-window.mjs';
import { resolveActiveModel } from './lib/active-model.mjs';
@ -36,17 +34,6 @@ 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'];
@ -135,6 +122,7 @@ 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 });
@ -194,14 +182,8 @@ 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);
@ -227,23 +209,15 @@ 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;
@ -252,8 +226,6 @@ 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]) {
@ -285,11 +257,6 @@ 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`);
@ -311,7 +278,7 @@ async function main() {
const json = JSON.stringify(output, null, 2);
if (outputFile) {
await writeOutputFile(outputFile, json, 'utf-8');
await writeFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
} else {
process.stdout.write(json + '\n');
@ -319,27 +286,11 @@ 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;
@ -348,12 +299,10 @@ async function main() {
process.stderr.write(`Risk: ${agg.risk_score}/100 (${agg.risk_band})\n`);
process.stderr.write(`Verdict: ${agg.verdict}\n`);
// 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;
// Exit code
if (agg.verdict === 'FAIL') process.exit(2);
if (agg.verdict === 'WARNING') process.exit(1);
process.exit(0);
}
// Only run CLI if invoked directly
@ -361,6 +310,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.exitCode = 3;
process.exit(3);
});
}

View file

@ -20,10 +20,6 @@ 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);
@ -334,7 +330,6 @@ 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');
@ -355,6 +350,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.exitCode = 3;
process.exit(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, levenshtein } from './lib/string-utils.mjs';
import { extractKeys } from './lib/string-utils.mjs';
const SCANNER = 'SET';
/** Known top-level settings.json keys (as of CC 2.1.193 / June 2026) */
/** Known top-level settings.json keys (as of CC 2.1.181 / June 2026) */
const KNOWN_KEYS = new Set([
'additionalDirectories',
'agent', 'allowAllClaudeAiMcps', 'allowedChannelPlugins', 'allowedHttpHookUrls',
@ -37,9 +37,6 @@ 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 */
@ -71,26 +68,13 @@ const TYPE_CHECKS = new Map([
['wheelScrollAccelerationEnabled', 'boolean'],
]);
/** 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']);
/** 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']);
/** 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. */
@ -121,7 +105,6 @@ 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.`,
@ -132,33 +115,17 @@ export async function scan(targetPath, discovery) {
continue;
}
// 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.
// Check for unknown keys
for (const key of Object.keys(parsed)) {
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) {
if (!KNOWN_KEYS.has(key)) {
findings.push(finding({
scanner: SCANNER,
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.`,
severity: SEVERITY.medium,
title: 'Unknown settings key',
description: `${file.relPath}: "${key}" is not a recognized settings.json key. It will be silently ignored.`,
file: file.absPath,
evidence: key,
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).`,
recommendation: 'Check spelling. See https://json.schemastore.org/claude-code-settings.json for valid keys.',
autoFixable: false,
}));
}
@ -169,7 +136,6 @@ 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}`,
@ -186,7 +152,6 @@ 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]}.`,
@ -202,7 +167,6 @@ 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.`,
@ -217,7 +181,6 @@ 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.`,
@ -234,7 +197,6 @@ 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.`,
@ -247,7 +209,6 @@ 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.`,
@ -263,7 +224,6 @@ 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:
@ -290,7 +250,6 @@ 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}.`,
@ -305,7 +264,6 @@ 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.`,
@ -322,7 +280,6 @@ 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}.`,
@ -339,7 +296,6 @@ 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.`,
@ -357,7 +313,6 @@ 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,7 +87,6 @@ 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:
@ -117,7 +116,6 @@ 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:
@ -140,7 +138,6 @@ 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:
@ -176,7 +173,6 @@ 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

@ -1,147 +0,0 @@
#!/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,22 +14,12 @@
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFile, stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { writeFile, readFile, stat } from 'node:fs/promises';
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');
@ -59,7 +49,6 @@ 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;
@ -89,15 +78,14 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exitCode = 3;
return;
process.exit(3);
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exitCode = 3;
return;
process.exit(3);
}
resetCounter();
const discovery = await discoverConfigFiles(absPath, { includeGlobal, excludeCache });
const result = await scan(absPath, discovery);
@ -141,7 +129,7 @@ async function main() {
const json = JSON.stringify(payload, null, 2);
if (outputFile) {
await writeOutputFile(outputFile, json, 'utf-8');
await writeFile(outputFile, json, 'utf-8');
}
if (jsonMode || rawMode || !outputFile) {
@ -153,6 +141,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.exitCode = 3;
process.exit(3);
});
}

View file

@ -22,12 +22,12 @@
*/
import { resolve, dirname, isAbsolute } from 'node:path';
import { stat, readFile } from 'node:fs/promises';
import { stat } 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, effectiveMemoryBytes, readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
import { estimateTokens, readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
import {
assessMcpDeferralForRepo,
severityForForcedSchemas,
@ -261,26 +261,6 @@ 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.
*
@ -292,7 +272,7 @@ async function buildHotspots(discovery, targetPath, activeConfig) {
const ranked = [];
for (const f of discovery.files) {
const kind = tokenKind(f.type);
const tokens = estimateTokens(await tokenBytesFor(f), kind);
const tokens = estimateTokens(f.size, kind);
if (tokens <= 0) continue;
ranked.push({
absPath: f.absPath,
@ -400,7 +380,6 @@ 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:
@ -429,7 +408,6 @@ 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:
@ -454,7 +432,6 @@ 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:
@ -487,7 +464,6 @@ 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:
@ -539,7 +515,6 @@ 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,
@ -557,7 +532,6 @@ 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:
@ -590,7 +564,6 @@ 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:
@ -661,7 +634,6 @@ 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,
@ -679,7 +651,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(await tokenBytesFor(f), tokenKind(f.type));
totalTokens += estimateTokens(f.size, tokenKind(f.type));
}
if (activeConfig && Array.isArray(activeConfig.mcpServers)) {
for (const m of activeConfig.mcpServers) {

View file

@ -13,17 +13,11 @@
*/
import { resolve } from 'node:path';
import { stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import { writeFile, stat } from 'node:fs/promises';
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;
@ -47,20 +41,18 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exitCode = 3;
return;
process.exit(3);
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exitCode = 3;
return;
process.exit(3);
}
const result = await readActiveConfig(absPath, { verbose, suggestDisables });
const json = JSON.stringify(result, null, 2);
if (outputFile) {
await writeOutputFile(outputFile, json, 'utf-8');
await writeFile(outputFile, json, 'utf-8');
}
if (jsonMode || rawMode || !outputFile) {
@ -72,6 +64,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.exitCode = 3;
process.exit(3);
});
}

View file

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

@ -1,109 +0,0 @@
/**
* 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

@ -1,138 +0,0 @@
/**
* 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,33 +41,13 @@ async function readCommand(name) {
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
}
// 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 () => {
test('Action: every 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