Compare commits

..

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

164 changed files with 894 additions and 11294 deletions

View file

@ -1,7 +1,7 @@
{
"name": "config-audit",
"description": "Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine",
"version": "5.13.0",
"version": "5.12.5",
"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,442 +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]
### 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

100
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 |
| `/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,67 +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` (`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`.
### 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.
**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.
**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

217
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.
*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.*
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
![Version](https://img.shields.io/badge/version-5.13.0-blue)
![Version](https://img.shields.io/badge/version-5.12.5-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-1344-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,8 +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 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 |
@ -308,7 +321,7 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
| `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 |
@ -477,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
@ -501,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
@ -551,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
@ -564,7 +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) |
---
@ -591,41 +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.
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
@ -634,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`).
---
@ -677,74 +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 |
|---------|------|-----------|
| **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. |
@ -780,6 +697,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

@ -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,39 +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?* Three
things make it different, and all three 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.
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
@ -78,10 +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.
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
@ -141,15 +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}
```
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

@ -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,45 +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
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" --apply $GLOBAL_FLAG --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.
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
@ -190,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,53 +49,16 @@ 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 backup silently, and **print the backup ID** — Step 6 has to tell the user
how to roll back, and a timestamp that only ever existed inside a command
substitution cannot be quoted later. Shell state does not survive to the next
block, so capture the printed value and substitute it literally from here on:
Create backup silently:
```bash
BACKUP_ID=$(date +%Y%m%d_%H%M%S)
mkdir -p ~/.claude/config-audit/backups/"$BACKUP_ID"/files/ 2>/dev/null
echo "$BACKUP_ID"
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
```
Use the printed ID wherever `{backup-id}` appears below. Never invent or re-derive
it with a second `date` call — a run that straddles a second boundary would hand
the user a rollback ID that does not exist.
Copy each file to be modified. Generate `manifest.yaml` with checksums.
The manifest is what `/config-audit rollback` reads, so it MUST carry both lists:
```yaml
files: # pre-existing files this run will MODIFY
- backup: files/root/CLAUDE.md
original: /abs/path/CLAUDE.md
sha256: <sha256 of the pre-change content>
created: # files this run will CREATE (no backup can exist)
- /abs/path/.claude/rules/post-quality.md
```
Record every `create`-type action under `created:`. Rollback cannot restore a
file that never existed, but it must be able to tell the user which files it is
leaving behind — a half-restored target is only dangerous when it is silent.
Tell the user: **"Backup created. Implementing actions..."**
### Step 4: Execute actions
@ -131,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:
@ -162,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.
@ -186,49 +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. Read `manifest.yaml` from backup
2. Restore each file and verify checksums
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.
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,7 +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)
Each finding cites its register rule + source URL. A clean CLAUDE.md returns "no
opportunities" — that is a good result, not a failure.
@ -35,30 +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) 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.
```
**`--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).
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:
@ -76,9 +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
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 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.
@ -90,13 +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.
**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.
@ -134,78 +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. 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:
@ -219,20 +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.
- **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

@ -45,21 +45,7 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
### Restore mode (with backup ID)
1. Read the list of changes from `~/.claude/config-audit/backups/{backup-id}/manifest.yaml` using the Read tool
2. Classify the `original:` paths 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:
2. Show files that will be restored — ask for confirmation:
```
AskUserQuestion:
question: "Restore 3 files from backup 20260403_163045?"
@ -67,15 +53,6 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
- "Yes, restore"
- "Cancel"
```
When `requiresApproval` is true, name the scope and put the safe option first:
```
AskUserQuestion:
question: "This restores {K} of 3 files to locations outside this project. Restore all 3?"
options:
- "Cancel"
- "Yes — restore, including outside this project"
```
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
@ -83,22 +60,10 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
4. Show result:
```
Restored 3 files from backup 20260403_163045
- /abs/path/.claude/settings.json (checksum verified)
- /abs/path/hooks/hooks.json (checksum verified)
- .claude/settings.json (checksum verified)
- hooks/hooks.json (checksum verified)
- .claude/rules/typescript.md (checksum verified)
```
5. **Report what rollback cannot undo.** A backup only holds files that already
existed, so files the implement step CREATED survive the restore. If the
manifest has a `created:` section (or `restoreBackup()` returns a non-empty
`createdNotRemoved`), 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
@ -109,14 +74,9 @@ If user says "delete" after listing, confirm and remove the backup directory.
Use the backup and rollback libraries directly:
```javascript
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
import { parseManifest, getBackupDir } from '../scanners/lib/backup.mjs';
import { parseManifest } from '../scanners/lib/backup.mjs';
```
Both read `~/.claude/config-audit/backups` and fall back to the pre-v2.2.0
`~/.config-audit/backups`, so a backup made before the move still resolves;
`listBackups()` flags those with `legacy: true`. Prefer this API over ad-hoc
`cp` — it verifies the checksum before and after each write.
Or via Bash:
```bash
# List backups

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

@ -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,39 +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.

View file

@ -1,218 +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 — new lens axis (own chunk):** `BP-JUDG-001` + `CA-OPT-002` for instructions that are
local and specific but **over-specify and cage judgment** (article rule 1). NOT an extension
of `--subtract` — the floor (`floor-exclusion.mjs`) rightly protects these blocks from
deletion; this axis says *keep the content, loosen the phrasing*. Same precision gate as
`optimization-lens-agent` (cite rule + source, stay silent when unsure). Mixing the axes is
the ÅS#5 defect class.
- **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:** fixture with a precise-but-caging instruction → CA-OPT-002 with rule+source citation;
floor-protected block WITHOUT caging phrasing → silent (axis separation proven).
- **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

@ -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,86 +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.'"
}
]
"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

@ -157,7 +157,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 +199,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,7 +35,6 @@
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,
@ -45,15 +44,9 @@ import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.m
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);
}
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
@ -79,9 +72,9 @@ 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() {
@ -166,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,7 +5,7 @@
*/
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';
@ -62,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.',
@ -92,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.`,
@ -111,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).`,
@ -123,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.`,
@ -146,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.`,
@ -161,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.` +
@ -178,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.`,
@ -204,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(', ')}`,
@ -220,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.`,
@ -237,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.`,
@ -255,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.`,
@ -277,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.`,
@ -293,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).`,

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

@ -129,7 +129,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 +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 ${a.scope} (${a.file}) but denied in ${b.scope} (${b.file}).`,
@ -179,7 +177,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 +227,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,23 +5,17 @@
* 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 } from './lib/humanizer.mjs';
const BOOL_FLAGS = ['--save', '--list', '--json', '--raw', '--global'];
const VALUE_FLAGS = ['--name', '--baseline', '--output-file'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
@ -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,41 +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.
const humanizedDiff = {
...diff,
_baselineAnchor: { matches: anchorMatches, baselineTarget, currentTarget },
newFindings: humanizeFindings(diff.newFindings || []),
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
movedFindings: humanizeFindings(diff.movedFindings || []),
};
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
@ -209,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'];
const VALUE_FLAGS = ['--output-file'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
@ -29,36 +21,18 @@ async function main() {
let jsonMode = false;
let rawMode = false;
let includeGlobal = false;
let outputFile = null;
// 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;
// --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.`);
}
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];
}
}
@ -67,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`);
@ -136,21 +105,16 @@ async function main() {
let backupId = null;
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;
@ -178,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;
@ -190,11 +151,7 @@ async function main() {
if (regressions.length > 0) {
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
}
// There is no rollback-cli.mjs — the restore path is the command, which
// drives rollback-engine.mjs. Pointing at a nonexistent script in the
// one message a user reaches for after a bad fix is the worst place for
// a dead reference.
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${backupId}\n`);
}
}
} else {
@ -208,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,
@ -236,17 +193,7 @@ async function main() {
})),
backupId,
};
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');
}
}
@ -255,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

@ -9,7 +9,6 @@ import { dirname } from 'node:path';
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { createBackup } from './lib/backup.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { VALID_EFFORT_LEVELS as SETTINGS_EFFORT_LEVELS } from './settings-validator.mjs';
/**
* Fix type constants.
@ -23,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.
@ -57,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 };
}
@ -613,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
@ -649,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

@ -817,7 +817,7 @@ 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 = [];
@ -842,11 +842,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;
}
/**
@ -77,7 +63,7 @@ export function checksum(content) {
*/
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 });
@ -142,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];
@ -150,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*"([^"]+)"/);
@ -168,45 +154,6 @@ export function parseManifest(content) {
}
}
// Parse file entries — implement-flow format. `commands/implement.md` has the
// agent hand-build the backup dir, so real manifests on disk use unquoted
// `- backup:` / `original:` / `sha256:`. Reading only the engine format made
// restoreBackup a success-shaped no-op on every backup implement produced.
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;
}
@ -214,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();
@ -225,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

@ -1,320 +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,
},
// ── 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

@ -435,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.',
},
@ -490,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.',
},
@ -499,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.',
@ -524,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: {
@ -836,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,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

@ -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.
*/
const LB = '(?<![\\wæøåÆØÅ])';
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,206 +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 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

@ -24,28 +24,14 @@
* 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 { 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'], value: ['--output-file'] };
// 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) {
@ -55,15 +41,12 @@ 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;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--global') includeGlobal = true;
else if (args[i] === '--subtract') subtract = true;
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
else if (!args[i].startsWith('-')) targetPath = args[i];
}
@ -73,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).
@ -90,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);
@ -104,11 +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;
for (const file of claudeMdFiles) {
let content;
try {
@ -119,37 +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)) {
subtractCands.push({
file: file.absPath,
line: bodyStartLine - 1 + cand.startLine,
endLine: bodyStartLine - 1 + cand.endLine,
lineCount: cand.lineCount,
lensCheck: cand.lensCheck,
mechanism: cand.mechanism,
signalText: cand.text,
register: {
id: subtractEntry.id,
claim: subtractEntry.claim,
recommendation: subtractEntry.recommendation || null,
severity: subtractEntry.severity || 'low',
source: subtractEntry.source,
},
});
}
}
for (const cand of prefilterClaudeMd(body)) {
const entry = register ? confirmedEntry(register, cand.registerId) : null;
if (!entry) continue; // never surface an unverifiable recommendation
candidates.push({
// Absolute path: unique + readable. relPath collides across scopes
// (a repo-root `CLAUDE.md` and the user-global `~/.claude/CLAUDE.md`
// both relPath to `CLAUDE.md`), which would send the agent's Read() to
// the wrong file. (M-BUG-11)
file: file.absPath,
file: file.relPath || file.absPath,
line: bodyStartLine - 1 + cand.line,
lensCheck: cand.lensCheck,
mechanism: cand.mechanism,
@ -197,32 +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 })),
};
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');
@ -233,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', [
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: [],
};
return scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
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);
}
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.`);
}
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] === '--json') {
jsonMode = true;
} else if (args[i] === '--raw') {
rawMode = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
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

@ -6,72 +6,47 @@
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getBackupDir, getLegacyBackupDir, parseManifest, checksum } from './lib/backup.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;
let entries;
try {
entries = await readdir(backupRoot, { withFileTypes: true });
} catch {
return { backups: [] };
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
try {
entries = await readdir(backupRoot, { withFileTypes: true });
const manifestContent = await readFile(manifestPath, 'utf-8');
const manifest = parseManifest(manifestContent);
backups.push({
id: entry.name,
createdAt: manifest.created_at,
files: manifest.files.map(f => ({
originalPath: f.originalPath,
backupPath: f.backupPath,
checksum: f.checksum,
sizeBytes: f.sizeBytes,
})),
});
} catch {
// Skip backups without valid manifest
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || seen.has(entry.name)) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
try {
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
@ -90,22 +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 = [];
// 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);
@ -164,10 +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 };
return { restored, failed };
}
/**
@ -176,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

@ -55,7 +55,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 +69,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.`,
@ -101,7 +99,6 @@ export async function scan(targetPath, discovery) {
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.`,
@ -120,7 +117,6 @@ export async function scan(targetPath, discovery) {
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 +130,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 +147,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 +161,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/.`,
@ -258,9 +251,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,11 +9,10 @@
import { resolve, sep } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.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';
@ -35,16 +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',
],
value: ['--output-file', '--context-window', '--baseline'],
};
// Directory names that identify test fixture / example directories
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
@ -133,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 });
@ -192,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);
@ -225,19 +209,12 @@ 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;
@ -280,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`);
@ -306,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');
@ -327,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
@ -340,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

@ -71,11 +71,8 @@ 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. */
@ -121,7 +118,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.`,
@ -152,7 +148,6 @@ export async function scan(targetPath, discovery) {
if (nearest) {
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.`,
@ -169,7 +164,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 +180,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 +195,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 +209,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 +225,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 +237,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 +252,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 +278,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 +292,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 +308,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 +324,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 +341,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

@ -400,7 +400,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 +428,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 +452,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 +484,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 +535,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 +552,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 +584,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 +654,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,

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

@ -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);

View file

@ -1,62 +0,0 @@
/**
* M-BUG-18 analysis-report.md persistence contract.
*
* The Claude Code subagent harness instructs spawned agents NOT to write
* report/summary/findings/analysis .md files the parent reads the agent's
* final text message, not files it creates. The analyzer-agent therefore
* cannot be the one that persists analysis-report.md (verified live: the
* agent skipped Write and returned the report inline).
*
* New contract (orchestrator-writes pattern):
* - analyzer-agent returns the complete report as its final message
* - the analyze command saves that returned report verbatim to
* ~/.claude/config-audit/sessions/{session-id}/analysis-report.md,
* which downstream phases (plan, interview, status) read.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
const AGENTS_DIR = resolve(__dirname, '..', '..', 'agents');
test('analyze.md: agent prompt does not tell the agent to write the report file', async () => {
const content = await readFile(resolve(COMMANDS_DIR, 'analyze.md'), 'utf-8');
assert.doesNotMatch(
content,
/Output to:.*analysis-report\.md/,
'the spawn prompt must not instruct the subagent to write analysis-report.md — the harness blocks agent-written report files'
);
});
test('analyze.md: command saves the returned report to analysis-report.md', async () => {
const content = await readFile(resolve(COMMANDS_DIR, 'analyze.md'), 'utf-8');
assert.match(
content,
/return[s]? the complete report as (its|your) final message/i,
'analyze.md must state that the agent returns the report inline'
);
assert.match(
content,
/Write tool[\s\S]{0,200}analysis-report\.md|analysis-report\.md[\s\S]{0,200}Write tool/,
'analyze.md must instruct the command to persist the returned report to analysis-report.md with the Write tool'
);
});
test('analyzer-agent.md: output contract is return-inline, not self-write', async () => {
const content = await readFile(resolve(AGENTS_DIR, 'analyzer-agent.md'), 'utf-8');
assert.match(
content,
/return the complete report as your final message/i,
'analyzer-agent must be told its final message IS the report'
);
assert.doesNotMatch(
content,
/^Write to: .*analysis-report\.md/m,
'analyzer-agent must not carry the old self-write output contract'
);
});

View file

@ -1,91 +0,0 @@
/**
* Session #51 command-template flag-value portability.
*
* Dogfooding `knowledge-refresh` surfaced a defect that only exists at the
* seam between the command template and the shell that runs it:
*
* STALE_AFTER="--stale-after 30"
* node -cli.mjs --reference-date "$TODAY" $STALE_AFTER --output-file
*
* The unquoted `$STALE_AFTER` is meant to split into TWO argv entries. Under
* **bash** it does. Under **zsh** the macOS default since Catalina, and the
* shell the Bash tool actually runs on this machine unquoted parameter
* expansions are NOT word-split, so the CLI receives ONE argv entry with the
* literal text `--stale-after 30`, matches no known flag, and (because the CLI
* silently ignored unknown flags see cli-unknown-flag-rejection.test.mjs)
* falls back to the 90-day default while reporting success. Measured:
*
* $ STALE_AFTER="--stale-after 30"; set -- $STALE_AFTER; echo $#
* 1 # zsh (bash prints 2)
* payload staleAfterDays: 90, exit 0, "✓ All 14 entries fresh"
*
* The user-facing knob was silently dead. Note the asymmetry that makes this
* survivable elsewhere: an EMPTY unquoted expansion yields ZERO argv entries in
* both shells, so the `FLAG=""` idiom used by ~25 other sites is portable. Only
* a variable that can hold a flag AND its value is affected.
*
* The invariant asserted here is therefore about VALUE-carrying flags, not
* about quoting in general: a command template must never depend on the shell
* splitting one variable into a flag plus its argument. Pass the value through
* its own quoted variable instead.
*/
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 COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
async function commandFiles() {
const entries = await readdir(COMMANDS_DIR);
return entries.filter((e) => e.endsWith('.md')).sort();
}
/**
* Assignments whose right-hand side contains a flag followed by a value
* i.e. the value only reaches argv if the shell word-splits. Matches both
* `X="--flag value"` and `X="--flag $(cmd)"`.
*/
const MULTIWORD_FLAG_ASSIGN = /^\s*([A-Z_][A-Z0-9_]*)=(["'])(--[a-z0-9-]+)[ \t]+\S.*\2\s*$/;
test('no command template builds a flag AND its value into one shell variable', async () => {
const offenders = [];
for (const file of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8');
content.split('\n').forEach((line, i) => {
const m = line.match(MULTIWORD_FLAG_ASSIGN);
if (m) offenders.push(`${file}:${i + 1} ${m[1]}=${m[2]}${m[3]}${m[2]}`);
});
}
assert.deepEqual(
offenders,
[],
'A variable holding "--flag value" only reaches argv correctly if the shell\n' +
'word-splits an unquoted expansion. zsh does not. Pass the value in its own\n' +
'quoted variable instead:\n' +
' N=$(… extract …); [ -n "$N" ] && node cli.mjs --flag "$N"\n' +
'Offending assignments:\n ' + offenders.join('\n '),
);
});
test('knowledge-refresh.md passes --stale-after with a quoted value', async () => {
const content = await readFile(resolve(COMMANDS_DIR, 'knowledge-refresh.md'), 'utf-8');
assert.ok(
!/\$STALE_AFTER\b(?!")/.test(content.replace(/"\$STALE_AFTER"/g, '')),
'knowledge-refresh.md still expands a flag-carrying variable unquoted; under zsh the\n' +
'threshold silently reverts to the 90-day default while the command reports success.',
);
assert.match(
content,
/--stale-after "\$[A-Z_]+"/,
'knowledge-refresh.md must pass the extracted threshold as its own quoted argument\n' +
'(`--stale-after "$STALE_AFTER_DAYS"`), so no word-splitting is required.',
);
});

View file

@ -1,222 +0,0 @@
/**
* Session #50 command-template output-discipline tests.
*
* Dogfooding the four read commands (`posture`, `tokens`, `manifest`,
* `whats-active`) surfaced three defect classes that all live in the same
* seam: what a command template PROMISES versus what the scanner behind it
* actually does.
*
* 1. M-BUG-43 a scanner invoked with `--raw`/`--json` writes the payload
* to stdout *even when `--output-file` is set*
* (`token-hotspots-cli.mjs:137`, `manifest.mjs:293`,
* `whats-active.mjs:60`, `posture.mjs:101`, `drift-cli.mjs`,
* `plugin-health-scanner.mjs`). The command templates redirect only
* stderr, so the payload lands in the transcript. Measured on this repo:
* posture 255 182 B, whats-active 35 922 B, drift 28 316 B,
* manifest 23 825 B, tokens 8 768 B. `.claude/rules/ux-rules.md` rule 1
* says NEVER show raw JSON and a plugin that exists to cut token cost
* must not dump a quarter-megabyte to report one grade.
*
* 2. Flags documented in a command's prose that never reach any shell
* (`tokens.md`: `--json`, `--with-telemetry-recipe`). The scanner
* supports them; the template silently swallows them. Same "green is
* worse than an error" shape as the arg-sink class.
*
* 3. Render contracts that name a field the scanner never emits
* (`manifest.md` asked for `{load}`; the payload carries `loadPattern`),
* so the column the command's own prose calls the whole point renders
* blank for every row.
*
* Test 3 runs the real scanners against a fixture rather than asserting
* against a hardcoded key list a hardcoded list drifts, a live payload
* cannot.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile, readdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const COMMANDS_DIR = join(ROOT, 'commands');
const FIXTURE = join(ROOT, 'tests', 'fixtures', 'marketplace-medium');
async function commandFiles() {
const entries = await readdir(COMMANDS_DIR);
return entries.filter((e) => e.endsWith('.md')).sort();
}
/** Strip `#` comments so the tests never match their own explanatory prose. */
function stripComment(line) {
const h = line.indexOf('#');
return h === -1 ? line : line.slice(0, h);
}
/** Yield [lineNumber, line] for lines inside ```bash fences only. */
function bashLines(content) {
const out = [];
let open = null;
content.split('\n').forEach((line, i) => {
const m = line.match(/^\s*```(\w*)/);
if (m) {
open = open === null ? m[1] : null;
return;
}
if (open === 'bash') out.push([i + 1, line]);
});
return out;
}
/** Yield lines inside ```markdown fences — the render contracts. */
function markdownFenceLines(content) {
const out = [];
let open = null;
content.split('\n').forEach((line) => {
const m = line.match(/^\s*```(\w*)/);
if (m) {
open = open === null ? m[1] : null;
return;
}
if (open === 'markdown') out.push(line);
});
return out;
}
test('Output discipline: a scanner asked for --output-file never also prints to stdout', async () => {
// The rule is blanket and needs no per-scanner allowlist: if the command
// asked for the payload in a FILE, the same invocation must not let it reach
// the transcript. Invocations with no --output-file are out of scope — there
// the payload has nowhere else to go (e.g. `drift --save`, 112 B).
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(join(COMMANDS_DIR, name), 'utf-8');
for (const [lineNo, raw] of bashLines(content)) {
const line = stripComment(raw);
if (!/node \$\{CLAUDE_PLUGIN_ROOT\}\/scanners\//.test(line)) continue;
if (!line.includes('--output-file')) continue;
const rawish = /--json\b|--raw\b|\$RAW_FLAG/.test(line);
if (!rawish) continue;
// Mask `2>` so a stderr redirect is never mistaken for a stdout one.
const masked = line.replace(/2>/g, '2@');
if (/(^|\s)>\s*\S+/.test(masked)) continue;
violations.push(
`${name}:${lineNo} runs a scanner in raw/json mode with --output-file but never redirects stdout — the payload lands in the transcript`,
);
}
}
assert.deepEqual(violations, [], `Unredirected scanner payloads:\n${violations.join('\n')}`);
});
test('Flag threading: every flag documented in prose reaches a shell', async () => {
// A flag the user is told to pass must arrive somewhere. Two legitimate
// destinations exist: a bash fence (threaded to the scanner) or a documented
// control-flow branch handled by the model. The allowlist below carries only
// the second kind, each verified by reading the code:
// posture --drift / --plugin-health : step 5 runs *different* scanners
// fix --dry-run : dry-run is fix-cli's DEFAULT and the
// flag is an explicit no-op alias
// (`scanners/fix-cli.mjs:18-21`)
// knowledge-refresh --no-candidates : skips a web-poll step; never a CLI flag
// manifest/whats-active --json : served by `cat` of the payload, whose
// content is byte-identical to --raw
// (both scanners document --raw as a
// no-op; verified by diffing payloads)
const PROSE_HANDLED = new Set([
'posture.md:--drift',
'posture.md:--plugin-health',
'fix.md:--dry-run',
'knowledge-refresh.md:--no-candidates',
'manifest.md:--json',
'whats-active.md:--json',
]);
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(join(COMMANDS_DIR, name), 'utf-8');
const bash = bashLines(content).map(([, l]) => l).join('\n');
const documented = new Set();
for (const line of content.split('\n')) {
const m = line.match(/^\s*[-*]\s+`(--[a-z][a-z0-9-]*)`/);
if (m) documented.add(m[1]);
}
for (const flag of documented) {
if (bash.includes(flag)) continue;
if (PROSE_HANDLED.has(`${name}:${flag}`)) continue;
violations.push(`${name} documents \`${flag}\` but no bash fence ever passes it`);
}
}
assert.deepEqual(violations, [], `Flags dropped between docs and shell:\n${violations.join('\n')}`);
});
test('Render contract: every {field} in a render fence exists in the real payload', async () => {
// Derived values the model computes rather than reads. Kept deliberately
// short — every entry here is a field the render fence does NOT get from the
// scanner, so a long list would hollow out the test.
const DERIVED = new Set(['rank']);
const CASES = [
{ command: 'manifest.md', scanner: 'manifest.mjs', args: [] },
{ command: 'tokens.md', scanner: 'token-hotspots-cli.mjs', args: [] },
{
command: 'whats-active.md',
scanner: 'whats-active.mjs',
args: ['--verbose', '--suggest-disables'],
},
];
const dir = await mkdtemp(join(tmpdir(), 'ca-render-'));
try {
const violations = [];
for (const { command, scanner, args } of CASES) {
const outFile = join(dir, `${scanner}.json`);
await execFileAsync('node', [
join(ROOT, 'scanners', scanner), FIXTURE, '--output-file', outFile, ...args,
]);
const payload = JSON.parse(await readFile(outFile, 'utf-8'));
const keys = new Set();
const walk = (node) => {
if (Array.isArray(node)) node.slice(0, 5).forEach(walk);
else if (node && typeof node === 'object') {
for (const k of Object.keys(node)) {
keys.add(k);
walk(node[k]);
}
}
};
walk(payload);
const content = await readFile(join(COMMANDS_DIR, command), 'utf-8');
const refs = new Set();
for (const line of markdownFenceLines(content)) {
const re = /\{([a-zA-Z][a-zA-Z0-9_.]*)\}/g;
let m;
while ((m = re.exec(line)) !== null) refs.add(m[1]);
}
for (const ref of refs) {
if (DERIVED.has(ref)) continue;
if (ref.endsWith('.length')) {
// `{foo.length}` is a count of a collection — assert the collection
// itself exists, since that is the part the scanner owns.
const base = ref.slice(0, -'.length'.length).split('.').pop();
if (!keys.has(base)) {
violations.push(`${command}: {${ref}} — no \`${base}\` collection in the payload`);
}
continue;
}
const leaf = ref.split('.').pop();
if (!keys.has(leaf) && !keys.has(ref)) {
violations.push(`${command}: {${ref}} is never emitted by ${scanner}`);
}
}
}
assert.deepEqual(violations, [], `Render fields the scanner never emits:\n${violations.join('\n')}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

View file

@ -1,102 +0,0 @@
/**
* Session #56 placeholders inside runnable bash fences must not be shell-active.
*
* Dogfooding the ROUTER (`commands/config-audit.md`) surfaced a defect one layer
* below the CLIs: the fence on the router's step 3 carries the placeholder
* `<target-path>` **bare** unquoted, preceded by a space. `<` and `>` are
* redirection operators. Measured under the shell the Bash tool actually runs
* (zsh), in a scratch directory:
*
* $ node /scan-orchestrator.mjs <target-path> --output-file scan.json \
* >/dev/null 2>/dev/null; node /posture.mjs <target-path> \
* --output-file posture.json 2>/dev/null; echo $?
* zsh:1: no such file or directory: target-path
* zsh:1: no such file or directory: target-path
* 1
* neither file written, neither CLI ever started
*
* Three properties make this worse than a plain typo:
*
* 1. **The CLI never runs.** Redirection is resolved by the shell before the
* command is executed, so the CLI's own argument validation the layer that
* `cli-unknown-flag-rejection.test.mjs` hardened never sees it.
* 2. **The failure is quiet where it counts.** The echoed status is `1`, and
* `1` is inside the band the router's own step 3 classifies as
* "continue normally" (0/1/2 = PASS/WARNING/FAIL; only 3 is a real error).
* A total non-execution is indistinguishable from a healthy WARNING run.
* 3. **The file already knew about the neighbouring hazard.** Two lines above
* the offending call sits a comment warning that a *square-bracket*
* placeholder "does not start with a dash, so both CLIs' arg loops would
* take it as the TARGET PATH instead of a flag" awareness of the
* placeholder class, while carrying a strictly worse member of it.
*
* The invariant is not "substitute your placeholders" (a template cannot enforce
* that). It is that an UNSUBSTITUTED placeholder must fail **loudly, in the
* CLI**, not silently in the shell. Quoting achieves exactly that: `"<path>"`
* reaches argv as a literal, the CLI reports an unreadable target, and the exit
* code means what the router thinks it means.
*
* Measured breadth at the time of writing: 13 of 21 command files, 33 sites.
*/
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 COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
async function commandFiles() {
const entries = await readdir(COMMANDS_DIR);
return entries.filter((e) => e.endsWith('.md')).sort();
}
/**
* Yield the lines that live inside ```bash fences, with their 1-based file line
* numbers. Only bash-tagged fences count: an untagged or json fence is prose.
*/
function bashFenceLines(content) {
const out = [];
let inFence = false;
content.split('\n').forEach((line, i) => {
if (/^\s*```/.test(line)) {
inFence = /^\s*```bash\s*$/.test(line);
return;
}
if (inFence) out.push({ line, n: i + 1 });
});
return out;
}
/**
* A placeholder that the shell would read as a redirection: `<` at the start of
* a word (start of line, or after whitespace / `;` / `|` / `&`), a lowercase
* placeholder name, then `>`. A quoted placeholder (`"<path>"`) is excluded by
* construction the `<` is preceded by a quote, not a word boundary.
*/
const BARE_PLACEHOLDER = /(?:^|[\s;|&(])(<[a-z][a-z0-9._-]*>)/;
test('no runnable bash fence carries a bare (shell-active) angle-bracket placeholder', async () => {
const offenders = [];
for (const file of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8');
for (const { line, n } of bashFenceLines(content)) {
const m = line.match(BARE_PLACEHOLDER);
if (m) offenders.push(`${file}:${n} ${m[1]} in: ${line.trim().slice(0, 90)}`);
}
}
assert.deepEqual(
offenders,
[],
'A bare `<name>` inside a bash fence is a REDIRECTION, not an argument. Left\n' +
'unsubstituted it fails in the shell before the CLI starts — no output file,\n' +
'no CLI diagnostics, and an exit code (1) that the router reads as a normal\n' +
'WARNING run. Quote the placeholder (`"<name>"`) so an unsubstituted template\n' +
'fails loudly in the CLI instead.\n' +
'Offending sites:\n ' + offenders.join('\n '),
);
});

View file

@ -1,258 +0,0 @@
/**
* Session #49 command-template shell-state shape tests.
*
* Dogfooding the `plan` + `implement` chunk surfaced one root defect with
* several arms: **command templates assume shell state survives between
* fenced blocks.** It does not. Every ```bash fence is executed as its own
* Bash tool call, in its own process:
*
* - A variable assigned in block N is empty in block N+1.
* - `$$` (the PID) differs between calls, so a `/tmp/foo-$$.json` path
* created in one block can never be reconstructed in a later one.
* - The Read tool expands neither shell variables nor `$$` nor globs; it
* takes one literal path.
*
* Measured arms at the time of writing (all fixed by the accompanying commit):
* - `$RAW_FLAG` referenced inside a (non-bash) agent-prompt fence in
* analyze.md, plan.md, implement.md the agent received the literal
* string `$RAW_FLAG`, confirmed at runtime by the planner-agent itself.
* - `$TMPFILE` referenced across blocks in manifest.md, tokens.md,
* whats-active.md, plugin-health.md.
* - `$GLOBAL_FLAG` across blocks in fix.md.
* - `$TODAY` across blocks in campaign.md (6 sites).
* - `$$` temp paths referenced outside their creating fence in fix.md.
* - plan.md asked the Read tool to expand
* `~/.claude/config-audit/sessions/*_/state.yaml`.
*
* The hardened pattern already present in drift.md is the target shape: a
* fixed literal temp path, repeated literally in every block that needs it.
*/
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 COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
/** Shell variables supplied by the environment, not by a prior block. */
const AMBIENT = new Set([
'CLAUDE_PLUGIN_ROOT', 'ARGUMENTS', 'HOME', 'PATH', 'PWD', 'USER', 'TMPDIR',
]);
async function commandFiles() {
const entries = await readdir(COMMANDS_DIR);
return entries.filter((e) => e.endsWith('.md')).sort();
}
/**
* Parse fenced blocks. Returns { lines, blockIndexOf(lineIdx) } where
* blockIndexOf returns -1 for prose outside any fence.
*/
function parseFences(content) {
const lines = content.split('\n');
const blocks = [];
let open = null;
lines.forEach((line, i) => {
const m = line.match(/^\s*```(\w*)/);
if (!m) return;
if (open === null) open = { lang: m[1], start: i };
else {
blocks.push({ lang: open.lang, start: open.start, end: i });
open = null;
}
});
const blockIndexOf = (i) => blocks.findIndex((b) => i > b.start && i < b.end);
return { lines, blocks, blockIndexOf };
}
/** Strip `#` comments from a bash line so the test never matches its own prose. */
function stripComment(line) {
const h = line.indexOf('#');
return h === -1 ? line : line.slice(0, h);
}
test('Shell state: no variable is referenced outside the block that assigned it', async () => {
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
const { lines, blocks, blockIndexOf } = parseFences(content);
const assignedIn = new Map();
lines.forEach((raw, i) => {
const bi = blockIndexOf(i);
if (bi < 0) return;
// An assignment starts a command, and a command starts at the beginning of
// the line OR after a separator. Capturing only the line-initial form would
// miss the idiomatic status capture `node …; STATUS=$?` — the one form that
// MUST trail a command — and report the variable as never assigned.
const re = /(?:^|[;&|]|&&|\|\|)\s*([A-Z_][A-Z0-9_]*)=/g;
let m;
while ((m = re.exec(stripComment(raw))) !== null) {
if (!assignedIn.has(m[1])) assignedIn.set(m[1], new Set());
assignedIn.get(m[1]).add(bi);
}
});
lines.forEach((raw, i) => {
const line = stripComment(raw);
const bi = blockIndexOf(i);
const re = /\$\{?([A-Z_][A-Z0-9_]*)\}?/g;
let m;
while ((m = re.exec(line)) !== null) {
const v = m[1];
if (AMBIENT.has(v)) continue;
// Skip the assignment site itself (`FOO=$FOO...` right-hand side is fine).
const eq = line.indexOf('=');
if (/^\s*[A-Z_][A-Z0-9_]*=/.test(line) && line.indexOf(m[0]) < eq) continue;
const where = assignedIn.get(v);
if (!where) {
violations.push(`${name}:${i + 1} $${v} is never assigned in any block`);
} else if (bi < 0) {
violations.push(
`${name}:${i + 1} $${v} referenced in prose/agent-prompt — no shell expands it there`,
);
} else if (!where.has(bi)) {
const lang = blocks[bi].lang || 'none';
violations.push(
`${name}:${i + 1} $${v} referenced in block ${bi} (lang=${lang}) but assigned only in block(s) ${[...where].join(', ')} — separate Bash calls, separate processes`,
);
}
}
});
}
assert.deepEqual(violations, [], `Cross-block shell-variable references:\n${violations.join('\n')}`);
});
test('Shell state: no $$ temp path is referenced outside the block that created it', async () => {
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
const { lines, blockIndexOf } = parseFences(content);
const firstSeen = new Map();
const scan = (raw, i, cb) => {
const re = /(\/tmp\/[A-Za-z0-9._-]*\$\$[A-Za-z0-9._-]*)/g;
let m;
while ((m = re.exec(stripComment(raw))) !== null) cb(m[1], i);
};
lines.forEach((raw, i) => scan(raw, i, (p) => {
if (!firstSeen.has(p)) firstSeen.set(p, { blk: blockIndexOf(i), line: i + 1 });
}));
lines.forEach((raw, i) => scan(raw, i, (p) => {
const origin = firstSeen.get(p);
if (origin.line === i + 1) return;
const bi = blockIndexOf(i);
if (bi < 0) {
violations.push(`${name}:${i + 1} ${p} referenced in prose — the Read tool cannot expand $$`);
} else if (bi !== origin.blk) {
violations.push(
`${name}:${i + 1} ${p} referenced in block ${bi} but created in block ${origin.blk}$$ is a different PID there`,
);
}
}));
}
assert.deepEqual(violations, [], `Unresolvable $$ temp paths:\n${violations.join('\n')}`);
});
test('Shell state: no $$ appears in any temp path at all', async () => {
// Session #50 closed a blind spot in the test above: it only flags a `$$`
// path that is *referenced twice*, because it compares each occurrence to
// the block that created it. A path written once and then read via prose
// ("Read the JSON output file using the Read tool") has no second
// occurrence — so posture.md sat green through #49 while being unreadable
// by construction: the PID is never printed, so no later step can name the
// file. Measured live: written by PID 21614, read attempted from PID 23772.
//
// The invariant is therefore blanket, not relational: a command template
// must not put `$$` in a temp path at all. The hardened pattern from
// drift.md — one fixed literal path, repeated literally — is the only
// shape that survives the fence boundary.
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
content.split('\n').forEach((raw, i) => {
const re = /(\/tmp\/[A-Za-z0-9._-]*\$\$[A-Za-z0-9._-]*)/g;
let m;
while ((m = re.exec(stripComment(raw))) !== null) {
violations.push(
`${name}:${i + 1} ${m[1]}$$ differs per Bash call; no later step can name this file`,
);
}
});
}
assert.deepEqual(violations, [], `Unresolvable $$ temp paths:\n${violations.join('\n')}`);
});
test('Read tool: never asked to expand a glob', async () => {
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
content.split('\n').forEach((raw, i) => {
// Only flag when the glob is the *object* of a Read instruction, i.e.
// "Read tool on `<glob>`" / "Read `<glob>`". Prose that merely explains
// that Read cannot expand a glob is the fix, not the defect — matching
// any co-occurrence on the line would flag this repo's own warning text.
const m = raw.match(/\bRead(?:\s+the)?(?:\s+tool)?\s+(?:tool\s+)?on\s+`([~/][^`]*)`|\bRead\s+`([~/][^`]*)`/);
if (!m) return;
const path = m[1] ?? m[2];
if (!path.includes('*')) return;
violations.push(`${name}:${i + 1} Read tool pointed at a glob \`${path}\` — use Glob`);
});
}
assert.deepEqual(violations, [], `Read-tool glob misuse:\n${violations.join('\n')}`);
});
test('state.yaml: phase commands name all four fields the rule requires', async () => {
// .claude/rules/state-management.md mandates current_phase, completed_phases,
// next_phase, updated_at after EVERY phase. A command that writes the file
// while naming only two fields silently drops the other two.
const REQUIRED = ['current_phase', 'completed_phases', 'next_phase', 'updated_at'];
const violations = [];
for (const name of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
if (!/Update\s+`?state\.yaml`?|Update state/i.test(content)) continue;
const missing = REQUIRED.filter((f) => !content.includes(f));
if (missing.length) {
violations.push(`${name} updates state.yaml but never names: ${missing.join(', ')}`);
}
}
assert.deepEqual(violations, [], `Incomplete state.yaml contracts:\n${violations.join('\n')}`);
});
/**
* Session #51 the instruction that CAUSED the $TODAY defect must not outlive its fix.
*
* #49 fixed campaign.md by re-deriving `TODAY=$(date +%F)` inside each of the six write
* blocks. But step 1 still carried the original prose "Set a shared date stamp for any
* write: `TODAY=$(date +%F)`" which is not in a fence, cannot set anything, and directly
* contradicts the six comments added below it. A template that argues with itself is not a
* contract, and the next edit is the one that believes the wrong half.
*
* The guard above asserts fences; this one asserts that no PROSE line instructs the reader
* to establish shell state for later blocks.
*/
test('no command template instructs shell state to be set outside a fence', async () => {
const offenders = [];
for (const file of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8');
const { lines, blockIndexOf } = parseFences(content);
lines.forEach((line, i) => {
if (blockIndexOf(i) !== -1) return; // inside a fence: that is where state belongs
if (/`[A-Z_][A-Z0-9_]*=\$\(/.test(line) || /^\s*[A-Z_][A-Z0-9_]*=\$\(/.test(line)) {
offenders.push(`${file}:${i + 1} ${line.trim()}`);
}
});
}
assert.deepEqual(
offenders,
[],
'Prose that tells the reader to set a shell variable implies it survives to a later\n' +
'block. It does not — every fence is its own process. Delete the instruction; the\n' +
'blocks that need the value derive it themselves:\n ' + offenders.join('\n '),
);
});

View file

@ -68,33 +68,13 @@ async function readCommand(name) {
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
}
// Agent-driven commands invoke no scanner, so they have no bash block to
// assert. analyze.md's 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` (session #49). Removing that
// block is the fix; requiring one here would re-assert the defect.
const AGENT_DRIVEN = new Set(['analyze.md']);
test('Group B: every scanner-invoking file contains a Bash invocation block', async () => {
test('Group B: every file contains a Bash invocation block', async () => {
for (const name of GROUP_B_FILES) {
if (AGENT_DRIVEN.has(name)) continue;
const content = await readCommand(name);
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
}
});
test('Group B: 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('Group B: every file references the Read tool', async () => {
for (const name of GROUP_B_FILES) {
const content = await readCommand(name);
@ -152,29 +132,3 @@ test('status.md: preserves current_phase machine field and adds humanized phase
`status.md must include at least 3 humanized phase labels; found ${present.length}: ${present.join(', ')}`,
);
});
// ---------------------------------------------------------------------------
// Økt #46 — ux-rules rule 2 for the plugin-health scanner.
//
// plugin-health.md passed the humanized-field assertion above while the data it
// names was unreachable: the scanner had no --output-file, and its default-mode
// report went to stderr, which the command discards with `2>/dev/null`. A .md
// contract test that only greps for prose cannot catch that — these assert the
// plumbing that makes the prose true.
// ---------------------------------------------------------------------------
test('plugin-health.md invokes the scanner with --output-file (ux-rules rule 2)', async () => {
const content = await readCommand('plugin-health.md');
const call = content.split('\n').find(l => l.includes('plugin-health-scanner.mjs'));
assert.ok(call, 'plugin-health.md must invoke plugin-health-scanner.mjs');
assert.match(call, /--output-file/, 'scanner call must write to a file, not stdout/stderr');
});
test('posture.md invokes the plugin-health and drift scanners with --output-file', async () => {
const content = await readCommand('posture.md');
for (const scanner of ['plugin-health-scanner.mjs', 'drift-cli.mjs']) {
const call = content.split('\n').find(l => l.includes(`scanners/${scanner}`));
assert.ok(call, `posture.md must invoke ${scanner}`);
assert.match(call, /--output-file/, `${scanner} call in posture.md discards its output`);
}
});

View file

@ -1,41 +0,0 @@
/**
* M-BUG-20 shared implementation-log clobbering under parallel agents.
*
* implement.md step 4 spawns implementer agents in parallel batches, and every
* agent appends its result to the SAME implementation-log.md. Dogfooding
* (2026-07-17, throwaway linkedin-posts copy) showed agents satisfying
* "Append result to:" with a full-file Write: each agent read the log, added
* its entry, and wrote the whole file back the last writer silently
* clobbered 4 of 6 entries.
*
* Contract: both the command template and the agent prompt must pin the append
* mechanism Bash `>>`, never the Write/Edit tool on the shared log.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile } 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 APPEND_MECHANISM_REGEX = />>/;
const FORBID_WRITE_TOOL_REGEX = /never[^.\n]*\bwrite\b[^.\n]*tool|\bwrite\b[^.\n]*tool[^.\n]*never/i;
test('implement.md: agent-spawn template pins Bash >> append on the shared log', async () => {
const content = await readFile(resolve(ROOT, 'commands', 'implement.md'), 'utf-8');
assert.match(content, APPEND_MECHANISM_REGEX,
'implement.md must instruct appending to implementation-log.md with Bash >>');
assert.match(content, FORBID_WRITE_TOOL_REGEX,
'implement.md must forbid the Write tool on the shared implementation log');
});
test('implementer-agent.md: output section pins Bash >> append and forbids Write tool on the log', async () => {
const content = await readFile(resolve(ROOT, 'agents', 'implementer-agent.md'), 'utf-8');
assert.match(content, APPEND_MECHANISM_REGEX,
'implementer-agent.md must instruct appending to the log with Bash >>');
assert.match(content, FORBID_WRITE_TOOL_REGEX,
'implementer-agent.md must forbid the Write tool on the shared implementation log');
});

View file

@ -1,151 +0,0 @@
/**
* DEL B chunk `interview` (session #47) command-contract shape tests.
*
* Every assertion here encodes a defect that was MEASURED against the shipped
* command templates before the fix, per the fasit in
* `docs/interview-fasit.local.md`:
*
* - interview.md / analyze.md referenced `{session-id}` with no rule for
* resolving WHICH session every other session-aware command has one.
* - interview.md could push a finished session backwards to `interview`
* with no guard (state-management.md mandates the write, nothing bounded it).
* - status.md advertised `/config-audit resume {session-id}`; no resume
* command exists. ux-rules: "Never reference commands that don't exist."
* - status.md documented `/config-audit status all` but its flag-parse step
* knew only `--raw`.
* - cleanup.md ran `rm -rf .../sessions/{session-id}/` with no guard against
* an empty/malformed id an empty expansion deletes every session.
* - discover.md carried literal `[--full-machine] [--global]` inside an
* executable bash block; `[` is not `-`, so scan-orchestrator's arg loop
* turns it into the SCAN TARGET (M-BUG-21 class).
* - feature-gap.md called `fix-cli.mjs --json` under the heading
* "Create backup". fix-cli is dry-run by default: no backup was created,
* `backupId` came back null, and the command then edited config believing
* it could roll back (M-BUG-31 class).
* - fix-cli.mjs told the user to recover with `node scanners/rollback-cli.mjs`
* a file that does not exist.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const COMMANDS_DIR = resolve(ROOT, 'commands');
const read = (name) => readFileSync(resolve(COMMANDS_DIR, name), 'utf-8');
/** Bash fenced blocks only — prose may legitimately use bracket notation. */
function bashBlocks(text) {
return [...text.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
}
test('interview.md and analyze.md resolve WHICH session before using {session-id}', () => {
for (const file of ['interview.md', 'analyze.md']) {
const text = read(file);
assert.match(
text,
/sessions\/\*\/state\.yaml/,
`${file} references {session-id} but never says how to find the session`
);
assert.match(
text,
/most recent|most recently modified/i,
`${file} must state which session wins when several exist`
);
}
});
test('interview.md guards against pushing a finished session backwards', () => {
const text = read('interview.md');
assert.match(
text,
/completed_phases/,
'interview.md must reason about completed_phases before rewriting current_phase'
);
assert.match(
text,
/do not (re-?add|add)|already contains|leave .*completed_phases/i,
'interview.md must forbid re-adding a phase already in completed_phases'
);
});
test('no command references a /config-audit subcommand that does not exist', () => {
// Scope words are arguments to the bare router command, not subcommands.
const SCOPE_WORDS = new Set(['current', 'repo', 'home', 'full']);
const dead = [];
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
const text = readFileSync(resolve(COMMANDS_DIR, file), 'utf-8');
for (const m of text.matchAll(/\/config-audit ([a-z][a-z-]+)/g)) {
const word = m[1];
if (SCOPE_WORDS.has(word)) continue;
if (!existsSync(resolve(COMMANDS_DIR, `${word}.md`))) dead.push(`${file}: /config-audit ${word}`);
}
}
assert.deepEqual(dead, [], `dead command references: ${dead.join(', ')}`);
});
test('status.md documents how the `all` argument is parsed', () => {
const text = read('status.md');
assert.match(
text,
/ALL_FLAG|contains? `all`|\ball\b[^\n]*\$ARGUMENTS|\$ARGUMENTS[^\n]*\ball\b/i,
'status.md advertises `/config-audit status all` but never parses `all`'
);
});
test('cleanup.md guards its rm -rf against an empty or malformed session id', () => {
const text = read('cleanup.md');
// The destructive line lives in prose, not a bash block, so scan the whole
// file. An earlier version of this test matched the example session ids in
// the display table and went green without any guard existing.
assert.ok(/rm -rf/.test(text), 'cleanup.md is expected to document a deletion step');
assert.match(
text,
/\^\[0-9\]\{8\}_\[0-9\]\{6\}\$|\^\\d\{8\}_\\d\{6\}\$/,
'cleanup.md must state the exact session-id pattern it validates against before deleting'
);
assert.match(
text,
/(refuse|abort|skip)[^\n]*(delet|remov)|never[^\n]*empty[^\n]*(id|path)/i,
'cleanup.md must say what happens when the id fails validation'
);
});
test('no executable bash block passes a bracketed placeholder flag', () => {
// `[--full-machine]` does not start with `-`, so the scanner arg loops take
// it as the scan target and silently scan a path that does not exist.
const offenders = [];
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
for (const block of bashBlocks(readFileSync(resolve(COMMANDS_DIR, file), 'utf-8'))) {
if (/\[--[a-z-]+\]/.test(block)) offenders.push(file);
}
}
assert.deepEqual([...new Set(offenders)], [], `bracketed flags inside bash blocks: ${offenders.join(', ')}`);
});
test('feature-gap.md does not present a dry-run fix-cli call as a backup', () => {
const text = read('feature-gap.md');
for (const block of bashBlocks(text)) {
if (!/fix-cli\.mjs/.test(block)) continue;
assert.match(
block,
/--apply/,
'feature-gap.md calls fix-cli without --apply; dry-run creates no backup'
);
}
});
test('fix-cli.mjs recovery hint points at a command that exists', () => {
const text = readFileSync(resolve(ROOT, 'scanners', 'fix-cli.mjs'), 'utf-8');
const hints = [...text.matchAll(/scanners\/([a-z-]+\.mjs)/g)].map((m) => m[1]);
for (const hint of hints) {
assert.ok(
existsSync(resolve(ROOT, 'scanners', hint)),
`fix-cli.mjs tells the user to run scanners/${hint}, which does not exist`
);
}
});

View file

@ -1,91 +0,0 @@
/**
* Session #51 knowledge-refresh must write the register it actually read.
*
* The command reads the register through `knowledge-refresh-cli.mjs`, whose `REGISTER_PATH`
* is anchored to the CLI module itself i.e. `${CLAUDE_PLUGIN_ROOT}/knowledge/
* best-practices.json`. For any installed plugin that is the marketplace cache; measured
* live during this chunk:
*
* registerPath: /.claude/plugins/cache/ktg-plugin-marketplace/config-audit/5.13.0/
* knowledge/best-practices.json
*
* Step 6 then said: Edit `knowledge/best-practices.json` an UNANCHORED relative path,
* which resolves against whatever repo the user happens to be sitting in. Three consequences,
* none of them visible at the time:
*
* 1. For a normal user, that path does not exist in their repo at all, so the approved
* change either fails or drops a stray file into their project.
* 2. In the plugin's own checkout it resolves to the working tree, so the command reads
* one file and writes a different one the refresh appears to do nothing, because the
* CLI keeps reporting the cached copy's dates.
* 3. Step 6.3's validation gate runs the plugin's own test file, which loads the register
* via the same anchored `REGISTER_PATH`. So the gate validates the copy that was NOT
* edited and passes no matter what was written a guard that cannot see the file it
* guards.
*
* The two register copies were byte-identical on the day this was found, which is exactly
* why the defect was invisible to a casual dogfood run.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FILE = resolve(__dirname, '..', '..', 'commands', 'knowledge-refresh.md');
/**
* Scoped to lines that tell the model to MODIFY the register. Descriptive prose ("this
* command keeps knowledge/best-practices.json current") and the closing `git commit`
* suggestion name the file without acting on it, and a git path is correctly repo-relative.
* The defect was specifically an unanchored path in an imperative write step.
*/
const WRITE_VERB = /\b(edit|write|append|overwrite|save)\b/i;
function unanchoredWriteTargets(content) {
const out = [];
content.split('\n').forEach((line, i) => {
if (!WRITE_VERB.test(line)) return;
for (const m of line.matchAll(/(\S*)knowledge\/best-practices\.json/g)) {
if (!m[1].endsWith('${CLAUDE_PLUGIN_ROOT}/')) out.push(`${i + 1}: ${line.trim()}`);
}
});
return out;
}
test('the guard still catches the original defect', () => {
const original = '1. Edit `knowledge/best-practices.json` — bump `source.verified`, update the `claim`/';
assert.equal(
unanchoredWriteTargets(original).length,
1,
'A narrowed guard must still fail on the text it was written for.',
);
});
test('every register path in knowledge-refresh.md is anchored to the plugin root', async () => {
const content = await readFile(FILE, 'utf-8');
const unanchored = unanchoredWriteTargets(content);
assert.deepEqual(
unanchored,
[],
'A bare `knowledge/best-practices.json` resolves against the user\'s current repo, not\n' +
'against the register the CLI actually read. Anchor every reference to\n' +
'${CLAUDE_PLUGIN_ROOT}/ so the file that is read, written, and validated is one file:\n ' +
unanchored.join('\n '),
);
});
test('the write step says where the register really lives', async () => {
const content = await readFile(FILE, 'utf-8');
assert.match(
content,
/marketplace|plugin cache|replaced on upgrade|plugin's own checkout/i,
'Step 6 must state that the register is part of the installed plugin, so an approved\n' +
'edit to a marketplace-installed copy is discarded by the next plugin upgrade and the\n' +
'durable change belongs in the plugin\'s own checkout. Silently editing a cache\n' +
'directory is the kind of write that looks successful and evaporates.',
);
});

View file

@ -1,161 +0,0 @@
/**
* Session #56 shape invariants for the ROUTER (`commands/config-audit.md`).
*
* `/config-audit` with no arguments is the plugin's front door: it auto-detects
* scope, runs `scan-orchestrator` + `posture`, and renders the result. Dogfooding
* it surfaced four defects that a scanner test could not see, because each lives
* at the seam between the command template and something it does not own the
* shell, the scanner registry, the area registry, or the stderr stream.
*
* 1. **The orchestrator's exit code is discarded.** Step 3 runs both CLIs on one
* line and appends a single `echo $?`, which reports only the LAST command.
* Measured:
*
* $ node -e "process.exitCode=3" >/dev/null 2>/dev/null; \
* node -e "process.exitCode=0" 2>/dev/null; echo $?
* 0
*
* An orchestrator hard-error (exit 3) is therefore invisible to the very gate
* step 3 defines for it ("3 → tell user … and stop").
*
* 2. **The narrated scanner count is stale.** Step 3 tells the user "Running 12
* configuration scanners"; the orchestrator registers and runs 16. Asserted
* against the registry rather than a literal, so the next scanner addition
* cannot re-stale it silently.
*
* 3. **The Area Breakdown table drops areas.** Posture emits 10 areas (9 quality
* areas plus Feature Coverage, which the template excludes by design). The
* table hardcodes 7 rows, so `Token Efficiency` and `Plugin Hygiene` both
* real, both graded, one of them a B on this very repo never reach the
* user. Asserted against the area registry in `lib/scoring.mjs`.
*
* 4. **The template consumes a stream the same file mandates be thrown away.**
* Step 6 instructs: "Use the headline line from the humanized stderr
* scorecard Avoid hardcoding a separate per-grade prose ladder." That
* headline (`Health: A (93/100) — Healthy setup, only minor polish needed`)
* exists ONLY on posture's stderr measured absent from the JSON payload
* and step 3's fence sends posture's stderr to /dev/null, as UX rule 2
* requires. So the router is told to render something it cannot obtain and
* forbidden from deriving a replacement; the slot can only be improvised.
* `commands/posture.md` already shows the fix in-repo: redirect stderr to a
* FILE (`2>/tmp/…-stderr.txt`), which satisfies "the user never sees it"
* while keeping the text readable.
*
* This is the mirror image of [[stderr-only-warnings-invisible-to-commands]]:
* there a WARNING was lost to /dev/null, here a REQUIRED INPUT is.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile } 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 ROUTER = resolve(ROOT, 'commands', 'config-audit.md');
const router = await readFile(ROUTER, 'utf-8');
/** Scanner ids the orchestrator actually registers, read from its registry. */
async function registeredScanners() {
const src = await readFile(resolve(ROOT, 'scanners', 'scan-orchestrator.mjs'), 'utf-8');
const block = src.slice(src.indexOf('const SCANNERS = ['));
const arr = block.slice(0, block.indexOf('\n];'));
return [...arr.matchAll(/\{\s*name:\s*'([A-Z]+)'/g)].map((m) => m[1]);
}
/** Distinct posture area names, read from the scanner→area registry. */
async function registeredAreas() {
const src = await readFile(resolve(ROOT, 'scanners', 'lib', 'scoring.mjs'), 'utf-8');
const block = src.slice(src.indexOf('const SCANNER_AREA_MAP = {'));
const arr = block.slice(0, block.indexOf('\n};'));
return [...new Set([...arr.matchAll(/:\s*'([^']+)'/g)].map((m) => m[1]))];
}
test('the router surfaces the exit code of BOTH scanner invocations', async () => {
const fence = router.slice(router.indexOf('### Step 3'), router.indexOf('### Step 4'));
const chained = fence
.split('\n')
.filter((l) => (l.match(/\bnode \$\{CLAUDE_PLUGIN_ROOT\}/g) || []).length > 1);
assert.deepEqual(
chained,
[],
'Step 3 runs scan-orchestrator and posture on ONE line with a single trailing\n' +
'`echo $?`, which reports only the LAST command. An orchestrator exit 3 echoes\n' +
'as posture\'s 0 and the "3 → stop" gate never fires. Capture each status into\n' +
'its own variable and echo both.\n' +
'Offending line(s):\n ' + chained.map((l) => l.trim().slice(0, 120)).join('\n '),
);
assert.match(
fence,
/echo "\$[A-Z_]+ \$[A-Z_]+"/,
'Step 3 must echo both captured exit codes (e.g. `echo "$ORCH_STATUS $POSTURE_STATUS"`)\n' +
'so the gate can act on either scanner failing.',
);
});
test('the scanner count the router narrates matches the orchestrator registry', async () => {
const scanners = await registeredScanners();
const narrated = [...router.matchAll(/Running (\d+) configuration scanners/g)].map((m) =>
Number(m[1]),
);
assert.ok(narrated.length > 0, 'The router should still narrate how many scanners are running.');
assert.deepEqual(
narrated,
narrated.map(() => scanners.length),
`The router tells the user it is running ${narrated.join('/')} scanners; the ` +
`orchestrator registers ${scanners.length} (${scanners.join(', ')}). A user-facing ` +
'count that no test binds to the registry goes stale on the next scanner added.',
);
});
test('the Area Breakdown table has a row for every quality area posture emits', async () => {
const areas = (await registeredAreas()).filter((a) => a !== 'Feature Coverage');
const table = router.slice(router.indexOf('### Area Breakdown'), router.indexOf('{For the status column'));
const missing = areas.filter((a) => !table.includes(`| ${a} |`));
assert.deepEqual(
missing,
[],
'Posture grades these areas but the router\'s table has no row for them, so they\n' +
'are silently dropped from the user\'s results. (Feature Coverage is excluded by\n' +
'design — it is reported as opportunities, not as a quality grade.)\n' +
`Areas emitted: ${areas.join(', ')}\n` +
`Missing rows: ${missing.join(', ')}`,
);
});
test('the router does not consume a stderr scorecard it discards', async () => {
const fence = router.slice(router.indexOf('### Step 3'), router.indexOf('### Step 4'));
const dependsOnStderr = /stderr scorecard/i.test(router);
const postureDiscardsStderr = /posture\.mjs[^\n]*2>\/dev\/null/.test(fence);
assert.ok(
!(dependsOnStderr && postureDiscardsStderr),
'Step 6 renders "the headline line from the humanized stderr scorecard" and forbids\n' +
'deriving a grade-prose ladder instead — but step 3 sends posture\'s stderr to\n' +
'/dev/null, and the prose is absent from the JSON payload (measured). The slot can\n' +
'only be improvised. Capture posture stderr to a FILE and read the headline from\n' +
'it, as commands/posture.md already does.',
);
});
test('the --raw detection is not substring-based', async () => {
const substringMatch = /grep -q -- "--raw"/.test(router);
assert.ok(
!substringMatch,
'Measured: `echo "$ARGUMENTS" | grep -q -- "--raw"` turns raw mode ON for `--rawdog`\n' +
'and for any path containing `--raw`. Anchor the match to whole arguments.',
);
assert.match(
router,
/grep -qE -- '\(\^\| \)--raw\( \|\$\)'/,
'The --raw check must match a whole argument, not a substring.',
);
});

View file

@ -1,187 +0,0 @@
/**
* SUB-WRITE caller arm `optimize --subtract --apply` (#63).
*
* #45/#46/#47 all taught the same lesson: fixing a CLI does not fix the command
* that reads its payload. A gate the engine enforces is worth nothing to the
* user if the template never renders the disclosure, and a refusal the payload
* reports is invisible if the template only ever prints successes.
*
* This arm is deliberately NOT folded into `write-scope-gate-shape.test.mjs`.
* Those five commands classify their targets with `write-scope-cli.mjs` and
* then honour the answer in prose; this one hands its targets to a CLI that
* refuses the write itself. Requiring it to ALSO call `write-scope-cli.mjs`
* would classify the same paths twice, which is the copy the class table exists
* to prevent.
*/
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 COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
const optimizeMd = async () => await readFile(resolve(COMMANDS_DIR, 'optimize.md'), 'utf-8');
/**
* Completeness derived from the catalog rather than from a literal: it is the
* NEXT command to drive the removal CLI that is at risk, not this one (#57/#62
* a hand-maintained sweep list is a premise, not a measurement).
*/
async function commandsDrivingTheRemovalCli() {
const out = [];
for (const name of await readdir(COMMANDS_DIR)) {
if (!name.endsWith('.md')) continue;
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
if (content.includes('subtraction-write-cli.mjs')) out.push({ name, content });
}
return out;
}
test('the removal CLI has at least one caller — otherwise this whole arm is vacuous', async () => {
const callers = await commandsDrivingTheRemovalCli();
assert.ok(
callers.length >= 1,
'No command drives subtraction-write-cli.mjs. Every assertion below would pass over an\n' +
'empty list, which is how a caller-arm guard goes green on a feature nobody can reach.',
);
});
test('every caller anchors the CLI and keeps its payload off the screen', async () => {
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
assert.match(
content,
/\$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/subtraction-write-cli\.mjs/,
`${name} must anchor the CLI at \${CLAUDE_PLUGIN_ROOT} — a relative path resolves against\n` +
"the user's working directory, and this one deletes configuration.",
);
assert.match(
content,
/subtraction-write-cli\.mjs[^\n]*--output-file[^\n]*2>\/dev\/null/,
`${name} must invoke it as \`--output-file <path> 2>/dev/null\` (ux-rules rule 2).`,
);
}
});
test('every caller dry-runs before it writes', async () => {
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
assert.match(
content,
/--dry-run/,
`${name} writes without proving the spans still match first. A stale approval is the\n` +
'expected case here — the file may have been edited since the scan.',
);
}
});
test('no caller classifies the write against the path it scanned', async () => {
// Measured (#63): `--repo "<target-path>"` under `--global` hands the CLI
// `~/.claude` as the session root, so `~/.claude/CLAUDE.md` classifies
// `in-repo` and the gate drops to `silent` — 29 removals applied with no
// approval asked. `--repo` is what a target is classified AGAINST; it is the
// session's own root. Every other gated template already passes `$PWD`.
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
for (const line of content.split('\n')) {
// Invocations only. Prose that merely names the CLI (the Notes section
// explaining why removal is not a `fix` action) carries no argv, and
// matching it made this guard red against its own fixed template.
if (!/^node\s.*subtraction-write-cli\.mjs/.test(line.trim())) continue;
const repoArg = line.match(/--repo\s+("[^"]*"|\S+)/);
assert.ok(repoArg, `${name} must pass --repo explicitly on every removal-CLI invocation.`);
assert.equal(
repoArg[1],
'"$PWD"',
`${name} passes ${repoArg[1]} as --repo. Anything but the session root can classify a\n` +
'machine-wide target as in-repo and silently downgrade the strongest gate on this axis.',
);
}
}
});
test('every caller surfaces the scope gate in the user\'s words', async () => {
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
assert.match(
content,
/requiresApproval/,
`${name} must branch on \`requiresApproval\`. The engine refuses the write, but a template\n` +
'that never asks leaves the user staring at a run that did nothing.',
);
assert.match(
content,
/disclosures/,
`${name} must render the payload's \`disclosures[]\` verbatim. Wording paraphrased per\n` +
'command is a policy copy that drifts.',
);
// Whitespace-tolerant: markdown wraps, and a bare space would let line
// length decide green/red (#62, [[guard-can-be-green-on-its-own-defect]]).
assert.match(
content,
/every\s+project/,
`${name} must say, in words, that a machine-wide removal costs and saves in every project.\n` +
'The class name alone is vocabulary the user has not been taught.',
);
}
});
test('every caller reports refusals with their reason, not just successes', async () => {
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
assert.match(
content,
/refused/,
`${name} must report the payload's \`refused\` entries. A removal silently dropped reads\n` +
'as a removal that happened.',
);
for (const reason of ['block-mismatch', 'floor']) {
assert.ok(
content.includes(reason),
`${name} must explain \`${reason}\` — the two reasons a user can actually act on. One\n` +
'means re-run the scan, the other means the block is load-bearing and never goes.',
);
}
}
});
test('every caller tells the user how to undo the removal', async () => {
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
assert.match(
content,
/backupId/,
`${name} must surface the backup id from the payload.`,
);
assert.match(
content,
/config-audit\s+rollback/,
`${name} must name the command that restores the file. A backup nobody is told about is\n` +
'not a safety net.',
);
}
});
test('the subtraction copy does not oversell the saving', async () => {
// Measured in the #40 fasit: ~1 400 deletable tokens, ~850 after tier-2
// earn-backs, against a ~4 300-token file — a fifth, not most of it. Copy
// that implies more is a defect of this feature, not a rounding difference.
const content = await optimizeMd();
// No trailing `\b` after the percent alternative: `%` is a non-word
// character, so `\b` there demands a word character NEXT — and "80% of the
// file" has a space. Measured green against exactly that mutation before the
// anchor was dropped; the same ASCII-only `\b` trap as `/\bunngå\b/`.
assert.doesNotMatch(
content,
/most\s+of\s+(?:the|your)\s+(?:file|config)|majority\s+of\s+(?:the|your)\s+file|\b(?:[5-9]\d|100)\s*%/i,
'optimize.md implies the subtraction axis removes most of a CLAUDE.md. The measured figure\n' +
'is around a fifth, and the honest number is the whole point of a deletion feature.',
);
});
test('optimize.md still says what runs without --apply', async () => {
const content = await optimizeMd();
assert.match(
content,
/Without\s+`--apply`,\s+no\s+files\s+are\s+modified/,
'The default must stay stated: `--subtract` alone proposes. A reader who skims the flag\n' +
'list needs to know which half of the axis writes.',
);
});

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