Compare commits
135 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5714261d1 | |||
| 44b222859e | |||
| b35ff449e8 | |||
| c3af74dfbc | |||
| adcf44fe4e | |||
| 7df8e0d65b | |||
| 05b4e9d797 | |||
| dc800560b7 | |||
| 30c78aeda0 | |||
| 749b710de7 | |||
| e60b80978b | |||
| 6bb100f2e0 | |||
| dbb6a6a3cf | |||
| 33bfd5ff5b | |||
| 000e47f9d2 | |||
| 1543830c52 | |||
| 49bae2657f | |||
| 9ae4be26d2 | |||
| e861e63a7b | |||
| 542f983178 | |||
| 7a794b47eb | |||
| 4027cdcf54 | |||
| 182a37c1af | |||
| 3d6ddb273c | |||
| c76dc537ce | |||
| 0b763f25c1 | |||
| ca199cc5f6 | |||
| b3f6866644 | |||
| 1b49bcc766 | |||
| d68b2152e3 | |||
| c8d4dc9421 | |||
| d66035ed86 | |||
| caea8aca23 | |||
| acd1cf1248 | |||
| 09f817977c | |||
| de8a7b5d51 | |||
| b85919f2ec | |||
| 001090261e | |||
| 05f1e954d0 | |||
| 1182f85767 | |||
| c60f849d2e | |||
| 8f149891c9 | |||
| b8cbbc0f5b | |||
| e9921d3c9d | |||
| c0625c568f | |||
| 31073c2178 | |||
| 36c55fb167 | |||
| 3252b514ed | |||
| 3086e8bb11 | |||
| b4d819b72d | |||
| 0cd87e0597 | |||
| 4b7b2d9c48 | |||
| 69a4654dd7 | |||
| 97867dbf37 | |||
| 239e88cecb | |||
| 2975b0563f | |||
| eb0b3fd29d | |||
| f4bf3ae2cb | |||
| b58393099a | |||
| 1d63492617 | |||
| 96e32df87b | |||
| 1bdaefc268 | |||
| e8afb148d3 | |||
| 3cf5c714a2 | |||
| 7e94910566 | |||
| dd9db60fc9 | |||
| bfd577aeee | |||
| 346dfac6fa | |||
| 18af5a24e9 | |||
| 4ad1875b31 | |||
| 7f097d524f | |||
| a1e786ba4f | |||
| be1056aac0 | |||
| 0f9e319c85 | |||
| 45efed3dbf | |||
| 6bb08cc84d | |||
| cf75249b5e | |||
| ad1eceb76a | |||
| 2082b7d112 | |||
| 27988801be | |||
| c2e3a56a20 | |||
| fa1ddd963a | |||
| d2c45a3bb8 | |||
| 8f7e196046 | |||
| 42e48514e4 | |||
| ba9f82f952 | |||
| a371832688 | |||
| a17823a9af | |||
| d664b70520 | |||
| 82f881afc4 | |||
| 872b8ac281 | |||
| cefa751990 | |||
| 2a3cb537f9 | |||
| fcfb2979ef | |||
| 759daa7201 | |||
| 9c8acec71f | |||
| 817fb8933f | |||
| 319e5541c9 | |||
| 49833aded8 | |||
| ee0c762151 | |||
| 4be7a16788 | |||
| f93830ce74 | |||
| f9862b8a6d | |||
| 0f9c091a14 | |||
| ba66f1fc17 | |||
| 7b3b487d26 | |||
| c1409ae9b9 | |||
| 313375184c | |||
| e7833b65fc | |||
| 55f83a3c99 | |||
| 685b770cb9 | |||
| 0a5a347ea1 | |||
| 51ca45500c | |||
| 7548a627ba | |||
| e3b044a476 | |||
| 43d8873339 | |||
| 778b517e6f | |||
| bb647ce35f | |||
| 62d910ed6d | |||
| d03c3831bd | |||
| dac1db48c5 | |||
| 9eba0f6169 | |||
| f3aadb5183 | |||
| f75ed5655c | |||
| 73a7f117d6 | |||
| 51fe6a1197 | |||
| 2f9d391b95 | |||
| 5ac6c87053 | |||
| 55633028e5 | |||
| e71e60f14e | |||
| b6a62d7699 | |||
| 5a270704fd | |||
| 292352eff8 | |||
| a5cfc331fd | |||
| 47efc5979e |
234 changed files with 27367 additions and 1316 deletions
|
|
@ -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.4.0",
|
||||
"version": "6.0.0",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ All command files MUST include:
|
|||
---
|
||||
name: plugin:command
|
||||
description: Short description of what this command does
|
||||
allowed-tools: Read, Write, Bash, Task
|
||||
allowed-tools: Read, Write, Bash, Agent
|
||||
model: sonnet
|
||||
---
|
||||
```
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -24,7 +24,12 @@ S*-PROMPT.md
|
|||
# v5 namespace research (local-only spike output)
|
||||
docs/v5-namespace-research.md
|
||||
|
||||
# --- session/local state (gitignored per ~/.claude polyrepo-konvensjon) ---
|
||||
# --- session/local state (gitignored) ---
|
||||
# STATE.md is LOCAL-ONLY: this repo's only remote is the PUBLIC open/ mirror, and
|
||||
# the global continuity rule says STATE.md must never reach a public mirror (it
|
||||
# would leak internal state-of-play). Mirrors the linkedin-studio precedent.
|
||||
# (Previously tracked under the old "always tracked" rule that assumed a private
|
||||
# remote — corrected 2026-06-21. See ~/.claude/CLAUDE.md.)
|
||||
STATE.md
|
||||
REMEMBER.md
|
||||
ROADMAP.md
|
||||
|
|
@ -33,5 +38,8 @@ NEXT-SESSION-PROMPT*.local.md
|
|||
*.local.md
|
||||
*.local.json
|
||||
*.local.sh
|
||||
# Local-only dogfood harnesses: they index the operator's private config by line
|
||||
# number (see docs/subtraction-fasit.local.md) and must never reach the public mirror.
|
||||
*.local.mjs
|
||||
.DS_Store
|
||||
.claude/
|
||||
|
|
|
|||
981
CHANGELOG.md
981
CHANGELOG.md
|
|
@ -5,6 +5,987 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [6.0.0] - 2026-08-18
|
||||
|
||||
### Summary
|
||||
"Prose is not a contract" — a MAJOR release whose theme is a *class* of defect rather than a feature
|
||||
area. Across three sweeps (Q1, Q2, Q_AUDIT) the same shape kept surfacing: a command template stated
|
||||
an invariant in prose, code on the other side depended on it, and nothing checked that the two still
|
||||
agreed. The write-scope **gate** was policy paraphrased in five templates while exactly one writer
|
||||
imported it. The **argv** a template built was never checked against the CLI receiving it —
|
||||
`--stale-after 30` arrived as a single argument, matched no flag, and the command reported success
|
||||
about a threshold the user had just overridden. And the **data contracts** — backup manifests,
|
||||
`state.yaml`, `scope.yaml` — are hand-built by the model and parsed by engines that know one frozen
|
||||
example. The first two are now enforced in code and tests; the third is measured and ranked in
|
||||
`docs/q-audit-prose-invariants.md`, with the recovery path (`rollback`) at the top as the surface
|
||||
that runs precisely when the user is already in trouble.
|
||||
|
||||
**Breaking — a finding ID's `{NNN}` names the check, not its emission position (`M-BUG-28`).**
|
||||
IDs are therefore not unique per finding: one check failing in three files emits three findings
|
||||
sharing an ID, and `(id, file, line)` is the instance key. Any consumer keying on `id` alone must
|
||||
move to the triple. `scanners/lib/finding-codes.mjs` is now the single authority — an undeclared or
|
||||
missing code **throws**, with no counter fallback, because a fallback lets a half-converted scanner
|
||||
ship IDs that look valid. Retired numbers are never reissued. Frozen `v5.0.0` baselines mask IDs
|
||||
rather than re-deriving them.
|
||||
|
||||
**37** commits since 5.13.0. **1752** tests, 0 failing. GAP dimensions **25 → 24** (one `/doctor`
|
||||
duplicate retired). No component-count change: scanners **16**, agents **7**, commands **21**,
|
||||
hooks **4**, knowledge entries **8**.
|
||||
|
||||
### Fixed
|
||||
- **`M-BUG-45` — `/config-audit knowledge-refresh --stale-after N` was silently dead under zsh.** The
|
||||
command built `STALE_AFTER="--stale-after 30"` and expanded it unquoted, relying on the shell to
|
||||
split it into two argv entries. bash does; **zsh — the macOS default since Catalina — does not**.
|
||||
The CLI received one argv entry reading `--stale-after 30`, matched no flag, and fell back to the
|
||||
90-day default while reporting success: "✓ All 14 register entries were re-verified within the last
|
||||
90 days" — a true-sounding sentence about a threshold the user had just overridden. Measured:
|
||||
`set -- $STALE_AFTER; echo $#` prints 1 under zsh, 2 under bash. The threshold is now passed as its
|
||||
own quoted argument, and a guard rejects any command template that packs a flag and its value into
|
||||
one variable.
|
||||
- **`M-BUG-46` — four CLIs accepted unknown flags in silence.** No `else` branch at all in the parse
|
||||
loop, so an unrecognised flag vanished without a trace: a typo'd `--ledger-file` made
|
||||
`campaign-cli` report confidently on the *default* ledger instead of the one the caller named, and
|
||||
a mistyped `--stale-after` reverted to 90 days. This is what made `M-BUG-45` silent rather than
|
||||
loud. `campaign-cli` and `knowledge-refresh-cli` now fail with exit 3 and name the offending flag;
|
||||
`optimize-lens-cli` and `token-hotspots-cli` share the defect and are closed together with their
|
||||
positional-swallow arm in the v5.14 argument-handling work (tracked in the guard's `KNOWN_OPEN`).
|
||||
- **`M-BUG-47` — the machine-wide token bill counted repos it could not read.** `refresh-tokens`
|
||||
routed a repo to `skipped[]` only when `readActiveConfig` *threw*, but that function resolves any
|
||||
path and its sub-readers all tolerate ENOENT, so a repo that does not exist yields an empty config
|
||||
instead of an error. Measured: a phantom path landed in `swept[]` with a 0-token delta,
|
||||
`skipped[]` was empty, and the roll-up claimed `reposWithTokens: 3` for a machine with two real
|
||||
repos — so the command's own honesty clause ("name those repos plainly so the user knows the bill
|
||||
omits them") could never fire. Readability is now checked before the sweep.
|
||||
- **`M-BUG-48` — `campaign add` vouched for paths that do not exist.** `add /finnes/ikke` returned
|
||||
`added: [...]` and exit 0, and the phantom row then sat in the backlog permanently. Paths are still
|
||||
tracked (an unmounted volume is a legitimate reason for a repo to be absent today) but are now
|
||||
reported separately as `addedUnverified`, and `campaign.md` names them instead of glossing over them.
|
||||
- **`M-BUG-49` — `posture` reported a crash as a passing grade.** Its top-level catch set
|
||||
`process.exitCode = 1`, while every command in this plugin is told that "codes 0, 1, 2 are normal
|
||||
(PASS/WARNING/FAIL). Only 3 is a real error". A fatal error was therefore indistinguishable from a
|
||||
WARNING, and the command went on to Read a payload file that was never written. Measured as the
|
||||
single outlier: 1 of 14 scanners. Now exits 3.
|
||||
- **`M-BUG-50` — `knowledge-refresh` read one register and wrote another, and the gate saw neither.**
|
||||
Step 6 said `Edit knowledge/best-practices.json` — an unanchored relative path — while the CLI reads
|
||||
`${CLAUDE_PLUGIN_ROOT}/knowledge/best-practices.json`, which for a marketplace install is the plugin
|
||||
cache. So a normal user has no such file in their repo at all; in the plugin's own checkout the
|
||||
command read the cache and wrote the working tree; and step 6.3's validation gate ran the cached
|
||||
test against the cached register — **validating the copy that was not edited, and passing no matter
|
||||
what was written.** Every write step is now anchored, and the command states where the register
|
||||
actually lives (a marketplace copy is discarded on the next plugin upgrade).
|
||||
- **No scanner created its `--output-file` parent directory.** `saveLedger` always did; the payload
|
||||
write never did — an accidental asymmetry across all 13 writers. `commands/campaign.md` writes its
|
||||
report under `~/.claude/config-audit/sessions/`, so on a fresh machine — precisely the first run
|
||||
that `campaign-cli` otherwise handles gracefully with `initialized: false` — the write threw ENOENT
|
||||
and the command's exit-code table reported it as a possibly-corrupt ledger, steering the user away
|
||||
from the one action that would have helped. All payload writes now go through
|
||||
`scanners/lib/write-output.mjs`.
|
||||
- **`M-BUG-40`, fifth arm — `posture` wrote four temp files it could never read back.** #49 closed the
|
||||
`$$`/cross-block class in four commands, but `posture.md` survived it, and so did the guard written
|
||||
to prevent exactly this. The guard compared each `$$` path against the block that created it, so a
|
||||
path written **once** and then read via prose ("Read the JSON output file using the Read tool") had
|
||||
no second occurrence to flag. Measured live: the scanner wrote `/tmp/config-audit-posture-21614.json`
|
||||
from PID 21614 while the next Bash call ran as PID 23772, and the read step had no path to hand the
|
||||
Read tool at all. The invariant is now blanket — **no `$$` in any temp path in any command file** —
|
||||
which also caught `fix.md` and `feature-gap.md`. All five sites now use fixed literal paths, repeated
|
||||
literally in every step that needs them.
|
||||
- **`M-BUG-43` — commands leaked whole JSON payloads into the transcript.** Every scanner except
|
||||
`scan-orchestrator` writes its payload to stdout when `--raw`/`--json` is set, **even when
|
||||
`--output-file` was given** — and the command templates redirected only stderr. Measured on a real
|
||||
repo: `posture` 255 182 B, `whats-active` 35 922 B, `drift` 28 316 B, `manifest` 23 825 B, `tokens`
|
||||
8 768 B. `fix` and `feature-gap` were the worst case: both ran posture with `--json`, never read the
|
||||
file they wrote, and in practice recovered a single letter grade from a quarter-megabyte dump — in
|
||||
the plugin that exists to cut token cost. 13 invocations across 10 command files now redirect stdout,
|
||||
and the two commands that needed the data read it from their output file instead.
|
||||
- **`tokens` swallowed two documented flags.** `--json` and `--with-telemetry-recipe` were listed as
|
||||
recognized flags but never threaded into the CLI call, so `--json` returned the *humanized* payload
|
||||
where the docs promised byte-stable v5.0.0 output (measured: 4/4 findings carried humanizer fields,
|
||||
and the title read "Your file starts with content that changes between turns" instead of
|
||||
"Cache-breaking volatile content at top of CLAUDE.md"), while `--with-telemetry-recipe` silently
|
||||
produced no `telemetry_recipe_path` — the very flag the command's own closing tip recommends.
|
||||
- **`M-BUG-42` — `manifest` asked for a field the scanner never emits.** The render contract used
|
||||
`{load}`; the payload carries `loadPattern`. The Load column — which the command's own prose calls
|
||||
the whole point of the view — would render blank for all 96 rows. `posture`'s headline had the same
|
||||
shape (`{qualityAreaCount}`, never emitted) and now takes its count from the humanized scorecard
|
||||
rather than `areas.length`, which counts a Feature Coverage row the table below deliberately excludes.
|
||||
|
||||
### Removed
|
||||
- **GAP dimension `No autoMode classifier` (D1) — retired as a `/doctor` duplicate.** CC 2.1.226's
|
||||
`/doctor` Check 8 covers auto mode with usage-weighted judgement, and the binding positioning
|
||||
(README «config-audit vs. the built-in /doctor») forbids carrying a feature whose whole value is
|
||||
duplicating a `/doctor` check. What is retired is only the *"adopt this feature"* nudge; the
|
||||
deterministic side stays untouched — SET still validates `autoMode` structure and still flags it
|
||||
as dead config in shared project settings. GAP dimensions: **25 → 24**.
|
||||
|
||||
The title lived in **four** tables, not the two the removal was scoped against: the dimension
|
||||
list, the scoring `TITLE_TO_ID` map, the humanizer's static translations, and — the one that
|
||||
moves a user-visible number — the scoring denominators (`TIER_COUNTS` t3 8→7,
|
||||
`TOTAL_DIMENSIONS` 25→24, `MAX_WEIGHTED` 42→41). `findGapId` falls back to `'unknown'` silently,
|
||||
so a partial removal would have degraded without failing. A blanket sync invariant now asserts
|
||||
all four against `GAP_CHECKS` rather than checking occurrences pairwise, and each arm was
|
||||
verified red against its own defect.
|
||||
|
||||
Utilization shifts accordingly (fixture: 43 → 44). `risk_score`, `risk_band`, `verdict`,
|
||||
`overallGrade`, `maturity` and `segment` are byte-identical across the change — the dimension was
|
||||
severity `info` (zero risk weight) and GAP is excluded from the overall grade.
|
||||
|
||||
**Frozen `tests/snapshots/v5.0.0/` stays untouched.** The removal-twin normalizer
|
||||
(`tests/helpers/strip-retired-gap.mjs`, mirroring `strip-added-scanner.mjs`) strips the retired
|
||||
dimension from whichever side still carries it and re-derives GAP IDs — retiring a dimension from
|
||||
mid-list shifts every later ID by one. The derived utilization figures are dropped from
|
||||
comparison rather than recomputed, since recomputing them in a test helper would assert the new
|
||||
arithmetic against itself; they are covered exactly in `tests/lib/scoring.test.mjs`. Re-seeding
|
||||
the baselines was rejected: it would silently bake in any other drift accumulated across every
|
||||
scanner those four files cover.
|
||||
|
||||
### Added
|
||||
- Four command-template shape tests (1449 → 1453), each verified to fail before the fix: a blanket
|
||||
`$$` ban, stdout-redirect discipline for any scanner invoked with `--output-file` in raw/json mode,
|
||||
flag threading from prose to shell, and a render-contract test that checks every `{field}` against a
|
||||
**live payload generated from a fixture** rather than a hardcoded key list, which would drift.
|
||||
|
||||
- **`M-BUG-40` — command templates assumed shell state survives between fenced blocks.** It does not:
|
||||
every ```` ```bash ```` fence is executed as its own Bash call, in its own process. A variable
|
||||
assigned in one block is empty in the next, and `$$` (the PID) differs between calls, so a
|
||||
`/tmp/foo-$$.json` path created in one block can never be reconstructed in a later one. The defect
|
||||
was surfaced by dogfooding `plan` + `implement`, and **confirmed at runtime by the planner agent
|
||||
itself**, which reported that `Mode: $RAW_FLAG` "arrived literally unsubstituted" — `--raw` was
|
||||
documented in both files while being functionally dead. A machine sweep found the same root in
|
||||
**20 places across 9 files**, far past the two predicted: `$RAW_FLAG` referenced from non-shell
|
||||
agent prompts (`analyze`, `plan`, `implement`); `$TMPFILE` referenced across blocks in `tokens`,
|
||||
`manifest`, `whats-active` and `plugin-health`, so each command could not read the file it had just
|
||||
written; `$GLOBAL_FLAG` in `fix`; `$TODAY` in `campaign`, which was **never assigned in any block**
|
||||
and passed `--reference-date ""` to a write CLI; and three `$$` temp paths handed to the Read tool
|
||||
in `fix`, which expands neither `$$` nor variables. All now follow the hardened `drift.md` pattern:
|
||||
a fixed literal path, or a re-derivation inside each block that needs it.
|
||||
- **`implement` handed out a rollback ID it never captured.** The backup directory was created with
|
||||
`mkdir -p .../$(date +%Y%m%d_%H%M%S)/`, so the timestamp existed only inside a command
|
||||
substitution, while step 6 promised `/config-audit rollback {timestamp}` — the one message a user
|
||||
reads after a bad run. The step now prints `BACKUP_ID` and substitutes it literally.
|
||||
- **`plan` reported "No analysis results found" for valid sessions.** Step 1 pointed the Read tool at
|
||||
`~/.claude/config-audit/sessions/*/state.yaml`; Read takes one literal path and does not expand
|
||||
`*`, so the lookup failed and the command reported the session as missing. It now uses Glob, and
|
||||
additionally verifies `analysis-report.md` exists before spawning the planner agent — a session can
|
||||
carry a valid `state.yaml` and still be missing its report.
|
||||
- **Phase commands wrote `state.yaml` with two of the four required fields.** `.claude/rules/state-management.md`
|
||||
mandates `current_phase`, `completed_phases`, `next_phase` and `updated_at`; `analyze`, `discover`,
|
||||
`implement`, `interview` and `plan` named only a subset. Because the planner agent writes all four,
|
||||
a follow-up full-file Write naming two **deletes** the other two — the fields that make an
|
||||
interrupted run resumable.
|
||||
- **`implement` documented a rollback semantics that does not exist.** Its "## Rollback" section
|
||||
promised to "delete newly created files", while `rollback.md` deliberately leaves them in place and
|
||||
lists them under "Left in place" (deletion is unimplemented; `M-BUG-26` remains open). The doc now
|
||||
mirrors actual behaviour rather than describing a half-restore as clean.
|
||||
- **`implement` claimed a score delta with no source**, since nothing captured the pre-change grade
|
||||
before the edits ran, and its implied posture call omitted both `--output-file` and `2>/dev/null`
|
||||
required by the output rules. It now reports a delta only when a pre-change grade was actually
|
||||
measured.
|
||||
- **`verifier-agent` was instructed to write a report it has no tool to write** (`tools: Read, Glob,
|
||||
Grep`, and "Read-only validation" by design). It now returns findings as its final message and the
|
||||
command appends them with Bash `>>`, preserving both the read-only design and the shared-log
|
||||
append discipline.
|
||||
- **Dead tool name in always-loaded context:** `.claude/rules/command-development.md` taught
|
||||
`allowed-tools: ... Task` while every command uses `Agent`, and `interview.md` carried two more
|
||||
`Task` references. `planner-agent.md` also contradicted itself — line 110 forbids inline file
|
||||
content while its own output template demonstrated exactly that, pushing plans past the 200-line
|
||||
budget the same file sets.
|
||||
|
||||
### Fixed (previously released work)
|
||||
- **`M-BUG-39` — every scanner CLI could truncate its own output when piped.** `process.exit()`
|
||||
terminates immediately, but Node writes stdout **asynchronously** when stdout is a pipe, so whatever
|
||||
is still buffered is discarded. `scan-orchestrator.mjs` measured **246 854 bytes to a file vs
|
||||
65 536 to a pipe** (and 131 072 on another run — the cut point is a nondeterministic flush race),
|
||||
handing any machine consumer truncated, unparseable JSON that reads like a corrupt file rather than
|
||||
a cut-off. Reported by `org-ops`, whose census pipes our envelope. The whole class is closed, not
|
||||
just the CLI where it was observable: `campaign-cli`, `campaign-export-cli`, `campaign-write-cli`,
|
||||
`knowledge-refresh-cli`, `drift-cli` and `fix-cli` all exited the same way on their success paths
|
||||
and were green only because their payloads happen to fit the pipe buffer today. All 38 sites across
|
||||
14 files now set `process.exitCode` and return, letting Node exit once stdout drains — the pattern
|
||||
`self-audit.mjs` and llm-security's orchestrator already used. `fail()` throws instead of exiting so
|
||||
it keeps its never-returns contract; exit codes and `Error:`/`Fatal:` stderr text are unchanged.
|
||||
Guarded by `tests/scanners/cli-pipe-integrity.test.mjs`: one behavioural test that pipes a >128 KB
|
||||
envelope, one class sweep over `scanners/*.mjs`.
|
||||
- **`M-BUG-36` — `/config-audit drift --list` showed nothing.** `drift-cli.mjs` accepted
|
||||
`--output-file` but list mode ignored it, and the listing itself goes to **stderr**, which
|
||||
`commands/drift.md` discards with `2>/dev/null` (ux-rules rule 2). The command received **0 bytes**
|
||||
and could render no baselines at all. List mode now honours `--output-file`; `--raw`/`--json` stdout
|
||||
is unchanged and byte-stable. Fourth instance of the stderr-only class after `M-BUG-33`.
|
||||
- **`M-BUG-37` — `/config-audit feature-gap` promised a backup it never made.** Step 6's
|
||||
"Create backup" ran `fix-cli.mjs <path> --json`, but fix-cli is **dry-run by default**: no backup was
|
||||
written and `backupId` came back `null`, after which the command edited the user's configuration
|
||||
believing it could be restored. Passing `--apply` would have been worse — it executes unrelated
|
||||
auto-fixes the user never selected. The step now copies the files itself and states plainly that
|
||||
plain copies are restored by copying them back, not by `/config-audit rollback` (`M-BUG-31` class).
|
||||
- **`M-BUG-38` — `fix-cli.mjs` sent users to a script that does not exist.** After applying fixes it
|
||||
printed `Rollback: node scanners/rollback-cli.mjs <id>`; there is no `rollback-cli.mjs` — only
|
||||
`rollback-engine.mjs`, driven by `/config-audit rollback`. A dead reference in the one message a
|
||||
user reaches for after a bad fix. Now points at the command.
|
||||
- **`M-BUG-21` (fourth arm) — command templates fed bracketed placeholder flags to the arg loops.**
|
||||
Five templates (`config-audit.md`, `discover.md`, `fix.md`, `tokens.md`, `whats-active.md`) carried
|
||||
literal `[--global]` / `[--full-machine]` / `[--verbose]` inside executable bash blocks. A bracketed
|
||||
placeholder does not start with `-`, so every scanner's `else if (!args[i].startsWith('-'))` branch
|
||||
takes it as the **scan target** — silently scanning a path that does not exist. Replaced with empty
|
||||
shell variables that expand to nothing when the flag does not apply.
|
||||
- **`/config-audit interview` and `analyze` never said which session they act on.** Both referenced
|
||||
`{session-id}` with no resolution rule, while every other session-aware command globs
|
||||
`sessions/*/state.yaml` and takes the most recent. Two runs could write to two different sessions.
|
||||
Both now resolve the session explicitly and exit when none exists.
|
||||
- **`/config-audit interview` could rewind a finished session.** The mandated state write had no bound,
|
||||
so running the optional interview against a session that had already reached `implement` reset
|
||||
`current_phase` and re-added phases. It now appends `interview` only if absent and leaves the
|
||||
furthest phase reached intact.
|
||||
- **`/config-audit cleanup` interpolated an unvalidated id into `rm -rf`.** An empty or malformed
|
||||
`{session-id}` expands the path to `sessions//`, deleting **every** session. The id must now match
|
||||
`^[0-9]{8}_[0-9]{6}$` or come verbatim from the directory listing; anything else is refused and
|
||||
reported.
|
||||
- **`/config-audit status` advertised a command that does not exist.** It documented
|
||||
`/config-audit resume {session-id}`; there is no `resume` command. Replaced with how session
|
||||
selection actually works. A test now fails on any `/config-audit <word>` reference in `commands/`
|
||||
without a matching file.
|
||||
- **`/config-audit status all` was documented but never parsed.** The flag-parse step knew only
|
||||
`--raw`. It now parses `all` and routes to the all-sessions table.
|
||||
|
||||
### Fixed (previously)
|
||||
- **`M-BUG-21` (third arm) — `plugin-health-scanner.mjs` swallowed unknown flags, and the wrong
|
||||
target looked *green*.** The same `else if (!args[i].startsWith('-')) targetPath = args[i]` loop:
|
||||
`--output-file /tmp/x.json` was dropped and `/tmp/x.json` became the scan target. Where `drift`
|
||||
produced phantom drift, this produced a **reassuring** answer — a non-existent path discovers no
|
||||
plugins, so the scanner reported `No plugins found` (info) and exit `0`. Unknown options and a
|
||||
value-less `--output-file` now exit `3`.
|
||||
- **`M-BUG-33` — `/config-audit plugin-health` read zero bytes.** The scanner had no `--output-file`
|
||||
(ux-rules rule 2) and its default-mode report goes to **stderr**, which `commands/plugin-health.md`
|
||||
discards with `2>/dev/null` before telling the agent to "read stdout output (JSON)". The command's
|
||||
default path could not produce the report it documents. `--output-file` now writes a humanized
|
||||
payload; `--raw`/`--json` stdout is unchanged and byte-stable.
|
||||
- **`M-BUG-34` — the report's per-plugin table and Cross-Plugin section were unbuildable.** Per-plugin
|
||||
data (`commandCount`, `agentCount`) and the grade formula never left `scan()` — the only grade code,
|
||||
`formatPluginHealthReport`, had no caller — and cross-plugin findings were flattened into `findings`
|
||||
behind a `category: 'plugin-hygiene'` they share with per-plugin findings. The command mandated both,
|
||||
so it had to fabricate them. The payload now carries `plugins[]` (name, declaredName, counts, score,
|
||||
grade via the shared `pluginGrade`) and `cross_plugin_findings[]` (also marked `crossPlugin: true`),
|
||||
via a new `scanDetailed()`; `scan()`'s frozen v5.0.0 envelope is untouched.
|
||||
- **`M-BUG-35` — `.claude-plugin/marketplace.json` was reported as an unknown file.** It is the
|
||||
documented, required location for a marketplace catalog, and a marketplace entry with
|
||||
`"source": "./"` makes the repo root its own plugin — such a repo legitimately carries both files.
|
||||
Genuinely unexpected files in `.claude-plugin/` are still flagged.
|
||||
- **`commands/posture.md` discarded both optional scanners' output.** Its `--drift` and
|
||||
`--plugin-health` sections ran `drift-cli.mjs` / `plugin-health-scanner.mjs` in default mode under
|
||||
`2>/dev/null` and read stdout, which is empty in that mode. Both calls now use `--output-file`.
|
||||
- **`M-BUG-21` — `drift-cli.mjs` had no `--output-file`, and its argument loop turned the missing
|
||||
flag into a wrong scan target.** The loop ended in `else if (!arg.startsWith('-')) targetPath = arg`
|
||||
with no unknown-flag branch, so an unrecognised flag was dropped silently and its *value* fell
|
||||
through to the scan target: `drift-cli.mjs . --output-file /tmp/x.json` scanned `/tmp/x.json`, a
|
||||
path that does not exist, and reported the resulting near-empty scan as drift — permanently, and
|
||||
without a warning. The same silence was destructive for `--save --name` with the value omitted:
|
||||
`--name` was ignored, the name stayed `default`, and an existing baseline was **overwritten**.
|
||||
Unknown options and value-less `--name`/`--baseline`/`--output-file` now exit `3`.
|
||||
- **`M-BUG-21` (second arm) — `/config-audit drift` captured nothing at all.** `commands/drift.md`
|
||||
ran the CLI under `2>/dev/null` and told the agent to "read stdout", but the default-mode report,
|
||||
the `--save` confirmation, and the `--list` output all go to **stderr**. All three modes returned
|
||||
empty. `--output-file` now writes the diff (humanized in default mode, raw under `--json`/`--raw`,
|
||||
matching `posture.mjs`), and the command reads that file; `--save` passes `--json` for its
|
||||
confirmation.
|
||||
- **`M-BUG-27` — `drift` compared against baselines anchored to a different directory and called it
|
||||
"improving".** `diff-engine` never checked the baseline's stored `target_path` against the current
|
||||
scan target. Diffing a repo against a baseline saved elsewhere marked every baseline finding
|
||||
"resolved" and every current finding "new" — a 100% phantom diff surfacing as a *reassuring* trend,
|
||||
on the **default** baseline. The CLI now warns on stderr in every mode and carries
|
||||
`_baselineAnchor {matches, baselineTarget, currentTarget}` in the default-mode payload, so a caller
|
||||
running under `2>/dev/null` can still see it. `--json`/`--raw` stdout stays v5.0.0-shaped and the
|
||||
frozen `drift.json` snapshot is untouched.
|
||||
|
||||
- **`M-BUG-21` (third arm) — `fix-cli.mjs` had the same unvalidated argument loop, where it moves the
|
||||
*write* target.** An unrecognised flag was dropped and its value became the scan target, so
|
||||
`fix-cli.mjs <repo> --output-file /tmp/x.json` silently audited `/tmp/x.json`; with `--apply` the
|
||||
same slip relocates what gets written. Unknown options and value-less `--output-file` now exit `3`.
|
||||
`--dry-run` — documented in `commands/fix.md`'s `argument-hint` but never implemented — is now
|
||||
accepted instead of silently dropped, and `--output-file` writes the fix payload to disk so
|
||||
`commands/fix.md` can read a file rather than parse stdout it runs under `2>/dev/null`.
|
||||
- **`M-BUG-31` — `fix` promised a mandatory backup it did not always take.** `fix-cli.mjs` excluded
|
||||
`file-rename` from the backup set, so a rule file whose only defect was its extension was renamed
|
||||
with **no** backup entry — while the command told the user "every fix creates a backup first" and
|
||||
handed back a `backupId` that could not restore it. The source file is now backed up like any other.
|
||||
- **`M-BUG-32` — verification re-scanned a different scope than the fix run.** `verifyFixes` hardcoded
|
||||
`includeGlobal: false`. After a `--global` run every user-scope finding fell out of the re-scan and
|
||||
was therefore counted as *verified*: a clean "fixed" report for files nothing had touched
|
||||
(reproduced against an untouched `~/.claude/CLAUDE.md`). It now inherits the run's scope, and
|
||||
`commands/fix.md` passes `--global` to every step instead of only the display scan.
|
||||
- **`M-BUG-29` — two fixes on one file were applied in an order that guaranteed failure.** A rule file
|
||||
with both `globs:` and a non-`.md` extension had the rename applied first; the frontmatter fix then
|
||||
failed with `ENOENT`. Renames now sort after every other fix.
|
||||
- **`M-BUG-30` — critical fixes sorted last.** `severityOrder[s] || 4` maps `critical` (weight `0`) to
|
||||
`4`, the opposite of the documented "critical first" contract. The old test used the same falsy
|
||||
fallback, so it agreed with the bug. Now `?? 4`.
|
||||
- **A failed fix no longer exits `0`.** `fix-cli.mjs` returns `2` when any planned fix failed, matching
|
||||
the `0/1/2 = PASS/WARNING/FAIL`, `3 = error` convention the other scanners follow.
|
||||
|
||||
**1420** tests (+10). No count change (scanners **16**, agents **7**, commands **21**, hooks **4**).
|
||||
|
||||
## [5.13.0] - 2026-07-31
|
||||
|
||||
### Summary
|
||||
"Pipeline hardening" — the batch release of everything found by dogfooding the plugin against the
|
||||
maintainer's real machine and by walking the `analyze → plan → implement → rollback` pipeline
|
||||
end-to-end on a throwaway repo copy: **one new lens mode** (`optimize --subtract`) and **14 bugs**
|
||||
(`M-BUG-11`…`M-BUG-20`, `M-BUG-22`…`M-BUG-25`), every one of them a real defect a user could hit.
|
||||
|
||||
The minor bump is carried by `--subtract` alone; the other 14 are fixes. Two themes run through them:
|
||||
**agent-facing commands were scanning config the user cannot act on** (plugin-bundled and vendored
|
||||
copies masking real findings), and **new finding types kept shipping without their matching humanizer
|
||||
entry**, so plain-language output contradicted the finding's own evidence. The rollback chunk found the
|
||||
worst class in the repo: a `restoreBackup` that returned `{restored: [], failed: []}` — a *success-shaped
|
||||
no-op* — because nothing agreed on where a backup lives or what its manifest looks like.
|
||||
|
||||
No count change (scanners **16**, agents **7**, commands **21**, hooks **4**). Frozen `v5.0.0` snapshots
|
||||
untouched throughout; the SC-5 default-output snapshot was regenerated once, for two humanized titles
|
||||
only (`M-BUG-15`). **1398** tests (+54).
|
||||
|
||||
**Known and deliberately not fixed in this release:** `rollback` still cannot delete files that
|
||||
`implement` *created* — a backup cannot hold a file that never existed. It no longer fails silently
|
||||
(manifests carry a `created:` list, `restoreBackup` returns `createdNotRemoved`, and `rollback.md`
|
||||
requires the report), but automatic deletion of user files is destructive and gets its own design.
|
||||
`drift-cli.mjs` still lacks `--output-file` (`M-BUG-21`).
|
||||
|
||||
### Added
|
||||
- **`optimize --subtract` — the subtraction axis (`BP-SUB-001`).** Every command so far asked an
|
||||
addition question: what to add, what to move, what it costs. Nothing asked what no longer earns its
|
||||
always-loaded rent. `--subtract` adds that as a fourth `lensCheck` on the existing hybrid motor — a
|
||||
mode, not a 22nd command or a 17th scanner, because the measured payoff (~18% of one file) justifies
|
||||
a mode and no more. It is **opt-in and proposes only**.
|
||||
It is also the only lens that proposes *removing* config, so it carries a guarantee the others don't
|
||||
need: **a load-bearing block is never a candidate.** Precision is asymmetric — a missed dead line
|
||||
costs a few tokens per turn, a wrongly deleted one costs a broken script or a wrong remote — so the
|
||||
floor is decided in code (`scanners/lib/floor-exclusion.mjs`) *before* the opus judge sees anything,
|
||||
never in prose. That ordering is an invariant, not an implementation detail.
|
||||
Granularity is the leaf block, with two structural exceptions: a paragraph ending in `:` merges with
|
||||
the list it introduces, and an ordered list is a contract whose steps inherit floor from any sibling.
|
||||
Unordered lists deliberately do **not** inherit — a load-bearing bullet and a disposable one routinely
|
||||
share a list.
|
||||
Verified against a hand-built ground truth written *before* any classifier existed, with the
|
||||
comparison machine-checked rather than read by eye: **zero load-bearing blocks proposed**, 11/18
|
||||
deletable groups surfaced, ~756 tok ≈ 18% of a ~4300-token file — inside the pre-registered band. The
|
||||
gate is re-runnable via `scripts/dogfood-subtraction-gate.local.mjs`. Three bugs the dogfood run
|
||||
exposed are now covered by fixtures: JS `\b` is ASCII-only so `/\bunngå\b/` never matched (every
|
||||
Norwegian keyword ending in `æ/ø/å` was silently dead); a bare `word/word` is not a path
|
||||
("pros/cons" vetoed the largest deletable block); "mid-sentence" must key on a preceding lowercase
|
||||
letter, or `**bold labels:**` read as entities and cost 4 of 11 groups.
|
||||
`BP-SUB-001` is grounded entirely in the Anthropic steering blog already cited by
|
||||
`BP-MECH-001..004` and asserts nothing from the talk that motivated the feature.
|
||||
|
||||
### Fixed
|
||||
- **`rollback` — the backup path contract the engine and the commands disagreed on
|
||||
(`M-BUG-22`/`M-BUG-23`/`M-BUG-24`/`M-BUG-25`).** Pipeline step 4 dogfood: `/config-audit rollback`
|
||||
could not see a single one of the four real backups on this machine, and reported "Backup not found"
|
||||
for one sitting right there. Four defects, one root — nothing agreed on where a backup lives or what
|
||||
its manifest looks like.
|
||||
**`M-BUG-22` (high):** `lib/backup.mjs` resolved `~/.config-audit/backups` (pre-v2.2.0) while every
|
||||
command, agent and doc uses `~/.claude/config-audit/backups`. The auto-backup hook and `fix-cli` wrote
|
||||
to the first, `implement` to the second, `rollback` read only the first — so `listBackups()` returned
|
||||
**9 phantom backups from the test suite** and **0 of the 4 real ones**. The canonical root is now
|
||||
`~/.claude/config-audit/backups`, with the legacy root kept **readable** (`legacy: true`) so older
|
||||
backups stay listable and restorable.
|
||||
**`M-BUG-25` (high, the worst failure mode in the file):** `parseManifest` understood only the
|
||||
engine's quoted `original_path:` spelling, but `implement` hand-builds its manifest with
|
||||
`- backup:`/`original:`/`sha256:`. Every implement-made backup parsed to zero files and
|
||||
`restoreBackup` returned `{restored: [], failed: []}` — success-shaped, and silent. Both formats parse
|
||||
now, and a manifest with unparseable entries **throws** instead of pretending to succeed.
|
||||
**`M-BUG-23`:** both session hooks watched `~/.config-audit/sessions`, which does not exist — "check
|
||||
for active sessions" had never fired once. It fires now.
|
||||
**`M-BUG-24`:** the suite called `createBackup()` against the developer's real home, leaving nine stray
|
||||
backups there while `cleanupOldBackups()` deletes past ten. The root is overridable via
|
||||
`CONFIG_AUDIT_BACKUP_ROOT` / `CONFIG_AUDIT_LEGACY_BACKUP_ROOT`, and both test files use it — any new
|
||||
test touching `createBackup()` must too.
|
||||
Verified against backup `20260717_032636` on a throwaway copy, through the previously broken engine
|
||||
path: 3/3 files restored **byte-exact** (sha256 match), zero writes outside the copy, backup dir
|
||||
unmodified.
|
||||
- **`rules-validator` — `globToRegex` corrupted mid-pattern `/**/` globs (`M-BUG-19`).** The
|
||||
`?` → `[^/]` replacement ran *after* the `{{GLOBSTAR_SLASH}}` placeholder was restored to `(?:/.+/|/)`,
|
||||
corrupting the group opener `(?:` into `([^/]:`. Every rule pattern containing a mid-pattern `/**/`
|
||||
silently matched only the zero-dir branch, so live rules were flagged "matches no files" (`CA-RUL`).
|
||||
Found by dogfooding `/config-audit implement` on a throwaway repo copy: the implementer agent's
|
||||
correct `posts/**/post.md` rule was flagged dead. Fixture outcomes byte-identical.
|
||||
- **`analyze` persists the agent-returned report (`M-BUG-18`).** The Claude Code subagent harness
|
||||
instructs spawned agents *not* to write report/summary/findings/analysis `.md` files — the parent
|
||||
reads the final text message. Verified live: `analyzer-agent` skipped `Write` entirely, so
|
||||
`analysis-report.md` never landed on disk and the plan/interview/status phases found nothing to read.
|
||||
New orchestrator-writes contract: the agent returns the complete report as its final message and the
|
||||
`analyze` command saves it verbatim before presenting the summary. The harness note is **file-type
|
||||
specific** — `plan` was dogfooded afterwards and writes `action-plan.md` without friction, so the same
|
||||
fix is *not* needed there.
|
||||
- **`implement` pins `>>` append discipline on the shared log (`M-BUG-20`).** `implement.md` spawns
|
||||
implementer agents in parallel batches, all appending to the same `implementation-log.md`. Dogfooding
|
||||
showed agents satisfying "append result to:" with a full-file `Write` — the last writer clobbered 4 of
|
||||
6 entries. Both contracts now pin the mechanism: append with a Bash `>>` heredoc, never the Write/Edit
|
||||
tool on a shared log.
|
||||
- **`optimize` lens scopes out plugin-bundled CLAUDE.md + keys candidates by absolute path
|
||||
(`M-BUG-11`).** The lens CLI fed its precision-gate agent every CLAUDE.md discovery returned, including
|
||||
the 256 files under `~/.claude/plugins/` — vendored copies across every cached version plus their
|
||||
fixtures and examples. `optimize --global` produced 454 candidates across 92 "files", ~250 of them from
|
||||
plugin-internal files a user cannot act on (the plugin overwrites them on update). Second defect:
|
||||
candidates were keyed by `relPath || absPath`, and `relPath` collides across scopes — a repo-root
|
||||
`CLAUDE.md` and `~/.claude/CLAUDE.md` both key to `CLAUDE.md`, so the two files that actually matter
|
||||
merged into one indistinguishable bucket and the agent's `Read(file)` would resolve the wrong one.
|
||||
Dogfood: candidates **454→45**, distinct files **92→11**, repo vs user-global now distinct.
|
||||
- **`feature-gap` scopes presence checks to authored config and reads the settings cascade
|
||||
(`M-BUG-13`).** The GAP scanner's 25 presence checks ran over the full `includeGlobal` discovery, so
|
||||
this plugin's own `examples/optimal-setup` (vendored across plugin-cache versions) satisfied every
|
||||
tier-3 check — masking real feature gaps to **GAP=0 on any target**. And the real
|
||||
`~/.claude/settings.json` was invisible to the settings-key checks (the `includeGlobal` gotcha plus the
|
||||
`maxFiles` cap), which would have flipped `statusLine`/`autoMode` into false positives the moment the
|
||||
maskers were removed. Both halves are fixed together: `isAuthoredConfig` excludes plugin-bundled and
|
||||
nested `examples/`/`tests/fixtures/` config, and `readSettingsCascade` reads user→project→local
|
||||
directly. Empty target: ~0 (masked) → **18** humanized opportunities.
|
||||
- **`posture --output-file` humanizes findings in default mode (`M-BUG-12`).** `feature-gap.md` and
|
||||
`posture.md` both read findings from `posture.mjs --output-file` and group on the humanizer fields,
|
||||
but `posture.mjs` only humanized the stderr scorecard — its `--output-file` JSON wrote the raw
|
||||
v5.0.0-shape result, so every finding's humanizer fields were `undefined` and both commands silently
|
||||
degraded to the raw tier-fallback. v5.1.0 plain-language output was dead for `feature-gap` and for
|
||||
`posture`'s finding-level grouping. The payload is now humanized in default mode (applied to
|
||||
`result.scannerEnvelope`, which is where posture nests it); `--json`/`--raw` stay raw.
|
||||
- **AGT findings humanize to "Wasted tokens", not "Other" (`M-BUG-17`).** The agent-listing scanner emits
|
||||
an always-loaded per-turn token cost — "the dominant single always-loaded source" — but
|
||||
`SCANNER_TO_CATEGORY` had no AGT entry, so its findings fell through to the `Other` fallback, a bucket
|
||||
that isn't even in the analyzer-agent's category list. All 16 orchestrator scanner prefixes are now
|
||||
covered by the category map, closing the class.
|
||||
- **On-demand copy for the oversized skill-body finding (`M-BUG-16`).** The v5.11 B7 finding measures a
|
||||
skill *body*, which loads only when the skill is invoked — but with no `SKL.static` entry it fell
|
||||
through to `SKL._default` ("using more of the listing budget than it should"), so the humanized title
|
||||
claimed a listing-budget cost and directly contradicted its own humanized evidence ("loads on demand
|
||||
only … NOT every turn").
|
||||
- **Honest absence-state copy for two GAP enhancement findings (`M-BUG-15`).** The "No path-scoped rules"
|
||||
and "No subagent isolation" checks fire on an *empty* collection too, but the humanized titles
|
||||
presupposed the feature exists — "Your subagents share Claude's main work folder" appeared in the same
|
||||
report as "You haven't set up any specialized helper agents yet". A user cannot simultaneously have no
|
||||
subagents and have subagents that lack isolation. Both titles now use the house "You haven't set up X
|
||||
yet" framing, which is honest for the zero-state *and* the has-but-unconfigured state. Fixed in the
|
||||
humanizer, not by gating the scanner — a presence gate would have moved the frozen v5.0.0
|
||||
marketplace-medium baseline.
|
||||
- **Size-neutral copy for "CLAUDE.md not modular" (`M-BUG-14`).** The check is a pure presence check with
|
||||
no length gate, but the copy claimed the file is "one big block" and that splitting makes it "easier on
|
||||
the loading time" — an unconditional size overclaim that simply lies for a ~625-token CLAUDE.md. Copy
|
||||
softened to the honest structural framing; no length gate added, which would have made it the only
|
||||
size-gated check among its siblings.
|
||||
|
||||
## [5.12.5] - 2026-06-26
|
||||
|
||||
### Summary
|
||||
"Dogfood denoise" — a samle-release of the Fase-3 scanner false-positive batch: `M-BUG-2/6/7/8/10`,
|
||||
all dogfooding finds from running config-audit on the maintainer's real `~/.claude`. The shared theme
|
||||
is **non-user / non-live config wrongly counted as the user's authored cascade**: installed plugins'
|
||||
bundled config, frozen backup copies, doc examples, and forward-compatible settings keys all produced
|
||||
findings the user could neither act on nor was responsible for. No new scanner, command, agent, or hook
|
||||
(counts stay scanners **16**, agents **7**, commands **21**, hooks **4**); all five fixes are byte-stable
|
||||
— the frozen v5.0.0 + SC-5 + default-output snapshots are untouched and **no fixture was re-seeded**
|
||||
(verified per bug: each affected fixture's findings are genuinely unchanged because the snapshot fixtures
|
||||
contain none of the triggering paths/tokens). **1344** tests (+37).
|
||||
|
||||
### Fixed
|
||||
- **`conflict-detector` segregates plugin-bundled config (`M-BUG-2`).** CNF compared every discovered
|
||||
`settings.json`/`hooks.json` pairwise regardless of origin, so it treated installed plugins' bundled
|
||||
configs — each plugin's own settings/hooks plus its shipped fixtures and examples under
|
||||
`~/.claude/plugins/` — as the user's cascade. A "conflict" between two plugins' bundled test fixtures
|
||||
is not user-resolvable, yet these dominated the count (dogfood **339** findings: 315 high-sev
|
||||
allow/deny, 18 duplicate-hook, 6 settings-key — Conflicts grade F on ~100% plugin noise). Fix: a new
|
||||
`isPluginBundled` predicate excludes any file whose absolute path is under `.claude/plugins/` from
|
||||
conflict analysis. Kept **CNF-local, not a discovery-level skip** on purpose — an active plugin's
|
||||
contributed `hooks.json`/`.mcp.json` legitimately lives in `plugins/cache` and other scanners need it;
|
||||
only conflict analysis must ignore plugin-bundled files. Same class as `M-BUG-8`. Dogfood **339→0**
|
||||
(the ~3 genuine user-scope local settings have no actually-conflicting keys). +3 tests (plugin-bundled
|
||||
exclusion, discovery-side sanity, over-exclusion guard).
|
||||
- **`file-discovery` skips `backups/` (`M-BUG-8`).** A directory named `backups` holds backup COPIES, not
|
||||
live config, so walking it during an audit produces stale findings. config-audit's own session backups
|
||||
(`~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md`) were the canonical case: a `~/.claude`-scope
|
||||
audit walked 36 frozen copies as if live, polluting CPS and HKV/RUL. Fix: add `backups` to `SKIP_DIRS`
|
||||
(broad, name-based — consistent with `vendor`/`dist`/`.cache`). Dogfood files-under-`/backups/`
|
||||
**36→0**, 717 live config files retained. +3 tests.
|
||||
- **token estimator discounts block-level HTML comments (`M-BUG-6`).** CLAUDE.md token estimates counted
|
||||
block-level `<!-- -->` comments toward always-loaded tokens, but CC strips them before injection
|
||||
(preserved only inside code fences, per `code.claude.com/docs/en/memory`). Fix: new
|
||||
`stripInjectedHtmlComments` + `effectiveMemoryBytes` in `active-config-reader`; the CML cascade and
|
||||
`token-hotspots` now size CLAUDE.md from effective (stripped) bytes while raw byte figures stay honest.
|
||||
Block-level only — inline comments retained (conservative, verified scope). Dogfood `~/.claude` CLAUDE.md
|
||||
~3386→3301 tok (~85 tok). +13 tests.
|
||||
- **`cache-prefix-stability` ignores code + CC-stable path vars (`M-BUG-7`).** CPS flagged
|
||||
`${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` (CC-provided stable paths) and `{date}`/timestamp tokens
|
||||
shown in documentation as cache-busters. Fix: skip fenced code blocks, strip inline-code spans, and
|
||||
whitelist CC-stable vars before pattern-matching. Suppress-only — frozen v5.0.0 snapshots untouched
|
||||
(CPS yields `findings:[]` there). Dogfood **5→2** (3 doc false-positives suppressed; 2 remaining are
|
||||
own volatile test fixtures). +6 tests.
|
||||
- **`settings-validator` typo-gates unknown keys (`M-BUG-10`).** The CC settings schema is passthrough
|
||||
(verified against the 2.1.193 binary): it forwards unrecognized keys unchanged rather than rejecting
|
||||
them, so an arbitrary unknown key is forward-compatible, not an error — the finding's "silently ignored"
|
||||
claim was factually wrong. The only real risk is a TYPO of a real key (the intended setting then
|
||||
silently has no effect). Fix: flag an unknown key only when it closely matches a known key (new
|
||||
`levenshtein` helper; edit distance ≤2, both keys ≥4 chars); severity medium→low; honest passthrough
|
||||
framing in scanner + humanizer. Also refreshed `KNOWN_KEYS` with 6 binary-verified keys
|
||||
(`agentPushNotifEnabled`, `remoteControlAtStartup`, `skipAutoPermissionPrompt`,
|
||||
`skipDangerousModePermissionPrompt`, `skipWorkflowUsageWarning`, `tui`). Dogfood
|
||||
`~/.claude/settings.json` **6→0** (all 6 were false unknown-key findings; 0 typo flags introduced across
|
||||
167 walked files). +12 tests.
|
||||
|
||||
## [5.12.4] - 2026-06-26
|
||||
|
||||
### Summary
|
||||
"Rooted rules" — fixes `M-BUG-9` (dogfooding find) in `scanners/rules-validator.mjs`. The RUL
|
||||
"Rule path pattern matches no files" check resolved a rule's `paths:`/`globs:` glob against the
|
||||
outer **scan root** instead of the rule's **own project root** (the directory containing its
|
||||
`.claude/`), and `collectProjectFiles` carried a `depth>4` cutoff that never reached deep matching
|
||||
files. As a result, a live rule in a **nested repo** — e.g. a marketplace checkout under
|
||||
`~/.claude/plugins/marketplaces/<mkt>/.claude/rules/` — was wrongly flagged "never activates" (high
|
||||
severity), a false F-grade for any user with rules in a nested repo. Same scope-conflation family as
|
||||
`M-BUG-1/2` (the scanner treats a nested repo's config as scoped to the outer scan root). The fix is a
|
||||
no-op for the common single-repo scan (`projectRoot === targetPath`), so the frozen v5.0.0 +
|
||||
default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands
|
||||
**21**). **1307** tests (+2).
|
||||
|
||||
### Fixed
|
||||
- **`rules-validator` glob base (`M-BUG-9`).** The dead-rule check now resolves each rule against its
|
||||
own project root:
|
||||
- `deriveProjectRoot(ruleAbsPath)` returns the parent of the rule's `.claude` segment.
|
||||
- Project files are collected and globbed **per project root** (cached), relative to that root, so a
|
||||
nested repo's rule matches against its own tree where its files live. This also sidesteps the old
|
||||
`depth>4` cutoff, because the walk now starts at the nearby project root.
|
||||
- User-global rules (`projectRoot === $HOME`, i.e. `~/.claude/rules/`) skip the no-match check: they
|
||||
scope against whatever project is active at runtime, not a fixed tree, so "matches 0 files here" is
|
||||
not a dead-rule signal (and this avoids a `$HOME`-wide file walk).
|
||||
- TDD: 2 failing tests (nested-repo false-positive + HOME guard) → fix → full suite 1307/0, frozen
|
||||
v5.0.0 + default-output snapshots untouched (RUL findings appear in none). Real-machine verify: the
|
||||
two `ktg-privat` false positives clear and a previously-hidden genuine dead rule (a false negative)
|
||||
surfaces in the bundled `optimal-setup` example; zero new false positives.
|
||||
|
||||
## [5.12.3] - 2026-06-26
|
||||
|
||||
### Summary
|
||||
"Phantom agents" — fixes `M-BUG-3/4/5` (dogfooding finds) in `scanners/lib/active-config-reader.mjs`
|
||||
so that `enumerateAgents` counts only the agents Claude Code actually **registers**. Per the official
|
||||
subagents documentation, an agent file must carry valid `name`+`description` frontmatter, and CC
|
||||
scans the agents directory **recursively** while silently skipping frontmatter-less files. The reader
|
||||
violated all three rules: it counted every `.md` regardless of frontmatter (`M-BUG-5`), never recursed
|
||||
into agent subdirectories (`M-BUG-3`), and double-counted entries when the project directory equals the
|
||||
user directory — the case when the scope root is `$HOME` (`M-BUG-4`, the root cause, which also affected
|
||||
rules and output-styles). Real-machine verify: the user-agent count dropped **13→0** (all 12 user
|
||||
agents plus `REMEMBER.md` are frontmatter-less, so CC registers none of them) and the HOME `project`
|
||||
duplicate dropped **13→0**; the corrected always-loaded baseline is ≈ **53**, not 66. Agent enumeration
|
||||
is machine-dependent and therefore absent from the frozen snapshots, so the v5.0.0 + SC-5 +
|
||||
default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands
|
||||
**21**). **1305** tests (+4).
|
||||
|
||||
### Fixed
|
||||
- **`active-config-reader` agent enumeration (`M-BUG-3/4/5`).** Three surgical fixes:
|
||||
- `listMarkdownFiles` gains an opt-in `recursive` flag so agent enumeration descends into
|
||||
subdirectories the way Claude Code does (`M-BUG-3`).
|
||||
- `configDirs` now de-duplicates the project and user paths when they resolve to the same directory
|
||||
(the case when the scope root is `$HOME`), the root cause that also double-counted rules and
|
||||
output-styles (`M-BUG-4`).
|
||||
- `enumerateAgents` requires a valid `name`+`description` frontmatter block before counting a file,
|
||||
matching CC's actual registration rule — frontmatter-less files are silently skipped (`M-BUG-5`).
|
||||
- Added a `hasText` frontmatter helper. TDD: 4 failing tests (one per bug) → fix → full suite
|
||||
1305/0, frozen v5.0.0 + SC-5 + default-output snapshots untouched (agent enumeration is
|
||||
machine-dependent and never seeded into a snapshot).
|
||||
|
||||
## [5.12.2] - 2026-06-24
|
||||
|
||||
### Summary
|
||||
"Honest census" — fixes a plugin-enumeration bug (`M-BUG-1`, dogfooding find) that made the
|
||||
always-loaded inventory untrustworthy on two common setups: machines with **disabled plugins** and
|
||||
**polyrepo marketplaces**. `enumeratePlugins` walked `~/.claude/plugins/marketplaces/<mkt>/plugins/`
|
||||
and ignored both enable-state and the polyrepo cache layout, so it **over-counted phantom agents**
|
||||
from disabled/unenabled plugins while **missing the entire enabled polyrepo set** (whose plugins
|
||||
live under `cache/`, not `marketplaces/<mkt>/plugins/`). It now gates on `installed_plugins.json` +
|
||||
`enabledPlugins` and enumerates each plugin from its active `installPath`, with the marketplaces
|
||||
walk as fallback. Affects `manifest`, `whats-active`, the agent-listing (AGT) and `token-hotspots`
|
||||
for every such user. No new finding ID or scanner (count stays **16**, agents **7**, commands
|
||||
**21**); `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 + default-output snapshots
|
||||
are untouched. **1301** tests (+4).
|
||||
|
||||
### Fixed
|
||||
- **`active-config-reader` plugin enumeration (`M-BUG-1`).** `enumeratePlugins(repoPath)` now honors
|
||||
`enabledPlugins` (disabled plugins no longer contribute phantom agents/skills/commands) and
|
||||
enumerates polyrepo plugins from their active `installPath` in `installed_plugins.json` (not only
|
||||
`marketplaces/<mkt>/plugins/`). Real-machine verify: the always-loaded agent listing dropped from
|
||||
114 to 104 with the phantom ghosts gone and the true enabled set present. TDD: 4 failing tests →
|
||||
fix → full suite 1301/0, snapshots untouched.
|
||||
|
||||
## [5.12.1] - 2026-06-24
|
||||
|
||||
### Summary
|
||||
"Footgun guard" — Pattern H (stale plugin-cache versions, `token-hotspots`) recommended deleting
|
||||
stale version directories without warning that a currently-running session may still hold one of
|
||||
those versions for its whole lifetime. "Stale" is judged against `installed_plugins.json` (what NEW
|
||||
sessions load), so the recommendation could reproduce the exact failure that broke a live session:
|
||||
deleting the directory pulls the files out from under the running session, which then breaks and
|
||||
must `/exit` + restart. Recommendation text only — no new finding ID or scanner (count stays **16**,
|
||||
agents **7**, commands **21**), no token figures changed, so `--json`/`--raw` stay byte-stable and
|
||||
the frozen v5.0.0 + SC-5 + default-output snapshots are untouched. **1297** tests.
|
||||
|
||||
### Fixed
|
||||
- **Pattern H live-session caveat (`token-hotspots`, `plugin-cache-hygiene`).** The stale-cache
|
||||
cleanup recommendation now cautions against deleting a version a running session still uses, and
|
||||
tells affected sessions to `/exit` + restart to pick up the active version — closing the footgun
|
||||
that broke a live session during the C4 cache cleanup.
|
||||
|
||||
## [5.12.0] - 2026-06-23
|
||||
|
||||
### Summary
|
||||
"Auto-calibration" — completes the deferred half of B8. `--context-window auto` now **probes the
|
||||
configured model** and calibrates SKL/CML budgets to its real context window, instead of always
|
||||
falling back to the conservative advisory anchor. A 1M-tier host self-calibrates without the manual
|
||||
`--context-window 1000000`. No new finding ID or scanner (count stays **16**, agents **7**, commands
|
||||
**21**); the default and explicit `--context-window` paths are unchanged, so `--json`/`--raw` stay
|
||||
byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1296** tests.
|
||||
|
||||
### Added
|
||||
- **B8b — model→window auto-probe.** `lib/context-window.mjs` gains a pure `modelToContextWindow()`
|
||||
that maps a configured model id/alias to its context window:
|
||||
- the explicit `[1m]` tier tag wins (the running session model surfaces as e.g.
|
||||
`claude-opus-4-8[1m]`);
|
||||
- known 1M-tier families `LARGE_CONTEXT_MODEL_IDS` (`claude-fable-5`, `claude-opus-4-8`,
|
||||
`claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6` — verified June 2026 against the
|
||||
platform.claude.com models overview) match by substring, so dated (`-20260528`) and
|
||||
provider-prefixed (`us.anthropic.…`) IDs resolve too;
|
||||
- the short aliases `opus` / `sonnet` / `fable` / `opusplan` resolve to 1M.
|
||||
- Models we cannot confirm (Haiku, older 200k-era IDs, unknown) return `null` — the caller then
|
||||
keeps the conservative anchor rather than guess a relaxed budget.
|
||||
- **New IO helper `lib/active-model.mjs` `resolveActiveModel()`** reads the configured model the way
|
||||
Claude Code resolves it: the shell `ANTHROPIC_MODEL` override first, otherwise the settings cascade
|
||||
`model` field (user `~/.claude` → project `.claude` → project-local, local wins). Reads the cascade
|
||||
files directly (like `isBundledSkillsDisabled`) and takes an injectable `env`, so it stays
|
||||
deterministic and hermetic under the test HOME. Returns `null` when no model is pinned anywhere.
|
||||
|
||||
### Changed
|
||||
- **`resolveContextWindow(arg, opts)` — the `auto` branch now probes.** It maps `opts.model` via
|
||||
`modelToContextWindow()`: a recognized 1M-tier model calibrates to its window (source `auto-probed`,
|
||||
**not** advisory); an unknown or unpinned model keeps the conservative 200k anchor and stays advisory
|
||||
(source `auto-unresolved`, the pre-B8b `auto` behavior). `scan-orchestrator` resolves the active
|
||||
model (only when the flag is `auto`) and threads it in; `posture` inherits this via `runAllScanners`.
|
||||
The default (no flag) and explicit `--context-window <n>` paths ignore `opts.model` and are unchanged.
|
||||
|
||||
## [5.11.0] - 2026-06-23
|
||||
|
||||
### Summary
|
||||
"Precision polish" — the two LOW-priority calibration gaps from the hardening plan, both additive.
|
||||
**B7** flags an oversized SKILL.md body (`CA-SKL-003`), honestly framed as an on-demand cost. **B8**
|
||||
lets `CA-SKL-002` and the CML char-budget calibrate to a real context window via `--context-window`,
|
||||
and downgrade to advisory when the window is unknown — so the 200k anchor stops crying wolf on a 1M
|
||||
host. Scanner count stays **16** (both extend the existing SKL/CML scanners), agents **7**, commands
|
||||
**21**; `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1279** tests.
|
||||
|
||||
### Added
|
||||
- **B7 — oversized skill body (`CA-SKL-003`, low).** `measureActiveSkillListing()` now measures the
|
||||
SKILL.md **body** below the frontmatter (the file was already read in full; only the frontmatter was
|
||||
parsed). A body over ~5,000 tokens (`BODY_TOKEN_THRESHOLD`) fires `CA-SKL-003`, recommending a
|
||||
supporting-file split and `context: fork` for heavy skills.
|
||||
- **Honest framing (Verifiseringsplikt):** `BODY_CALIBRATION_NOTE` marks this as an **on-demand**
|
||||
cost — the body loads only when the skill is invoked, **not** every turn like the always-loaded
|
||||
listing — and an estimate (chars/4), hence low severity. Distinct from the always-loaded
|
||||
listing-budget findings.
|
||||
- **B8 — context-window calibration (`--context-window`).** `CA-SKL-002` (skill-listing budget) and
|
||||
the CML char-budget threshold now calibrate to a resolved context window instead of always
|
||||
anchoring at 200k. `lib/context-window.mjs` gains `resolveContextWindow()` and `scaleForWindow()`:
|
||||
- `--context-window <n>` calibrates the budget to `n` (e.g. `1000000` relaxes the SKL listing budget
|
||||
to ~20,000 tok, so an over-200k listing is within budget and does not fire).
|
||||
- `--context-window auto` keeps the conservative 200k anchor but marks the result **advisory** —
|
||||
SKL/CML emit the finding at **info** rather than as a budget breach (model→window auto-probing is
|
||||
deferred to a later B8b).
|
||||
- No flag → the conservative 200k anchor at full severity, **byte-identical** to the pre-B8 default.
|
||||
- Both SKL and CML keep an untouched default branch (`window === 200k && !advisory`) for
|
||||
byte-stability plus a calibrated branch. The flag is wired through `scan-orchestrator` and
|
||||
`posture`; `runAllScanners` resolves it once and threads `{ contextWindow }` to the scanners
|
||||
(others ignore the third arg).
|
||||
- **CPS intentionally excluded:** it has no window-anchored budget (a fixed 150-line volatility
|
||||
heuristic), so there is nothing to calibrate.
|
||||
|
||||
## [5.10.0] - 2026-06-23
|
||||
|
||||
### Summary
|
||||
"Deferral & injection hygiene" — three additive hardening levers that extend existing scanners
|
||||
toward a tighter always-loaded prefix. **B4** detects config that forces full MCP tool schemas into
|
||||
the always-loaded prefix (deferral defeated), with a CLI-over-MCP companion lever. **B5** adds a
|
||||
hook `additionalContext`-injection advisory plus a filter-before-Claude-reads lever. **B6** extends
|
||||
the cache-prefix scanner to follow `@import`s and flag volatile content in imported files. Scanner
|
||||
count stays **16** (all three extend existing scanners), agents **7**, commands **21**;
|
||||
`--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1257** tests.
|
||||
|
||||
### Added
|
||||
- **B4 — MCP tool-schema deferral (`CA-TOK-006`; tokens patterns 7 → 8).** By default Claude Code
|
||||
defers MCP tool schemas (names-only, ~120 tok; full schemas load on demand via tool search).
|
||||
`CA-TOK-006` detects config-file signals that force the FULL schemas into the always-loaded prefix
|
||||
every turn: `settings.json` `env.ENABLE_TOOL_SEARCH="false"` (high), `"ToolSearch"` in
|
||||
`permissions.deny` (high), a configured Haiku model (medium), and per-server `.mcp.json`
|
||||
`alwaysLoad:true` (CC v2.1.121+, high). Severity scales with the aggregate forced-upfront tokens
|
||||
(medium-confidence reasons cap at medium). New pure engine `lib/mcp-deferral.mjs`
|
||||
(`assessMcpDeferral`, unit-tested, no IO) shared by TOK and GAP. A feature-gap **CLI-over-MCP**
|
||||
lever fires only as a companion to `CA-TOK-006` (prefer `gh`/`aws`/`gcloud` over MCP for common
|
||||
operations — CLI adds zero context tokens until used).
|
||||
- **Honest scoping (Verifiseringsplikt):** triggers on config files ONLY, never `process.env`
|
||||
shell vars. Vertex / custom `ANTHROPIC_BASE_URL` / a runtime `/model` switch are launch state
|
||||
(would flap snapshots machine-dependently), so they are DISCLOSED in every finding, not
|
||||
triggered. Mechanism verified 2026-06-23 against code.claude.com/docs (`context-window.md`,
|
||||
`mcp.md#configure-tool-search` + `#exempt-a-server-from-deferral`, `costs.md`); the
|
||||
prefix-cache-invalidation claim was NOT-CONFIRMED in docs and is not asserted.
|
||||
- **B5 — hook `additionalContext`-injection advisory + filter-before lever.** HKV emits an info
|
||||
advisory when a hook injects unfiltered command output into `additionalContext` (it enters context
|
||||
every turn the hook fires). A feature-gap **filter-before-Claude-reads** companion lever cites the
|
||||
documented `filter-test-output.sh` pattern (filter at the hook, not after Claude reads).
|
||||
- **B6 — CPS `@import` volatile scan.** The cache-prefix scanner now follows `@import`s (one hop) and
|
||||
flags volatile content in the imported file that breaks the cached prefix — a new medium finding
|
||||
("Volatile content in @imported file breaks cached prefix"), keyed on the resolved file with
|
||||
evidence "imported by <file> (@<path> at line N)". Scoped to one hop (the IMP scanner owns deep
|
||||
chains); resolved files that are themselves discovered CLAUDE.md are skipped (own iteration).
|
||||
|
||||
### Notes
|
||||
- Scanner count unchanged at **16** — B4/B5/B6 all extend existing scanners (TOK / HKV + GAP / CPS).
|
||||
`--json`/`--raw` output remains byte-stable; frozen v5.0.0 + SC-5 snapshots untouched.
|
||||
|
||||
## [5.9.0] - 2026-06-23
|
||||
|
||||
### Summary
|
||||
"Machine-wide token lens" — the three highest-impact hardening gaps toward whole-machine token
|
||||
tuning (the shift from *correct?* to *best-practice-tuned?*). **B1** measures the always-loaded
|
||||
agent listing (a new orchestrated scanner), **B2** gives the campaign ledger a machine-wide
|
||||
always-loaded token bill that counts the shared global layer once, and **B3** makes the token and
|
||||
conflict scans cache-aware so stale plugin-cache versions stop polluting them. Every new token
|
||||
figure carries an honesty caveat where the load mechanism is inferred or the number is an
|
||||
upper-bound estimate. `--json`/`--raw` stay byte-stable; frozen v5.0.0 + SC-5 snapshots untouched.
|
||||
Scanner count 15 → **16**, agents **7**, commands **21**; **1215** tests.
|
||||
|
||||
### Added
|
||||
- **B1 — Agent-listing budget (new orchestrated scanner `AGT`, count 15 → 16).** Claude Code injects
|
||||
every active agent's name+description into the system prompt on every turn; on a heavily-plugged
|
||||
machine this is often the dominant single always-loaded source, yet nothing measured it. New
|
||||
`scanners/agent-listing-scanner.mjs` + `scanners/lib/agent-listing-budget.mjs`:
|
||||
- `CA-AGT-001` — per-agent description over the soft bloat cap (advisory; mirrors the TOK 500-char
|
||||
SKILL.md heuristic — agents have no verified per-description cap, so nothing is truncated, the
|
||||
description is simply re-sent in full every turn).
|
||||
- `CA-AGT-002` — the summed agent-listing estimate exceeding the listing budget.
|
||||
- **Intellectual-honesty contract:** both findings are LOW and explicitly **inferred /
|
||||
upper-bound** — the agent-listing mechanism is undocumented (agents are absent from Claude Code's
|
||||
published context breakdown), the per-agent estimate is the frontmatter cap not a measurement,
|
||||
and the budget is a config-audit heuristic anchored on a conservative 200k window. The evidence
|
||||
discloses all three caveats rather than overstating certainty.
|
||||
- **B2 — Machine-wide always-loaded token roll-up (campaign).** The campaign ledger now carries a
|
||||
token bill alongside the severity roll-up. `campaign refresh-tokens` runs a live cross-repo sweep
|
||||
(`readActiveConfig` → `buildManifest` → ownership split) that factors out the **shared global
|
||||
always-loaded layer** (global CLAUDE.md + agent listing + global MCP + unscoped global rules) and
|
||||
counts it **once**, then adds each repo's delta — surfacing one machine-wide always-loaded total
|
||||
plus a ranked "most expensive repos" table. The shared layer is written once from the first
|
||||
readable repo (counted-once guard against the `whats-active` double-count).
|
||||
- **B3 — Stale plugin-cache disk-cleanup finding (`TOK`).** Stale `~/.claude/plugins/cache` versions
|
||||
surface as a finding (`--global`) categorized **Dead config** (not "Wasted tokens"): they occupy
|
||||
disk but load on zero turns. A per-finding category override (`plugin-cache-hygiene`) plus a
|
||||
dedicated humanizer translation keep the prose honest ("safe to delete … zero tokens per turn …
|
||||
housekeeping, not a performance problem").
|
||||
|
||||
### Changed
|
||||
- **B3 — Cache-aware filtering (folds in B0): `--exclude-cache` (default ON) for token-hotspots and
|
||||
the orchestrator.** `~/.claude/plugins/cache` holds *both* active and stale plugin versions —
|
||||
`installed_plugins.json`'s `installPath` points INTO the cache — so a blunt "skip all of
|
||||
plugins/cache" would drop *active* config. Discovery is now **version-aware**: it reads the
|
||||
adjacent `installed_plugins.json`, keeps each plugin's active version dir, and drops only stale
|
||||
ones. Result: stale versions no longer pollute the token-hotspot ranking, and stale cached
|
||||
`hooks.json` no longer inflate CNF "duplicate hook" findings (verified: cache exclusion measurably
|
||||
drops the count). `--no-exclude-cache` restores the full walk. When `installed_plugins.json` is
|
||||
absent or unparseable, nothing is filtered (never silently drop live config).
|
||||
- **B2 — `manifest` ownership split.** `splitManifestByOwnership` separates the shared-global layer
|
||||
from per-repo deltas; `~/.claude.json:projects` MCP is correctly classified as a per-repo delta
|
||||
(its slice is keyed on the repo path), not a shared-global source.
|
||||
- Humanizer: a finding's `category` can now override the scanner-default impact label
|
||||
(`CATEGORY_TO_IMPACT`), used so a TOK finding can read as **Dead config** when that is the honest
|
||||
bucket. The raw `category` field is unchanged, so `--json`/`--raw` stay byte-stable.
|
||||
|
||||
## [5.8.0] - 2026-06-23
|
||||
|
||||
### Summary
|
||||
"Campaign motor" — a durable, machine-wide audit **campaign** that sits ABOVE individual sessions.
|
||||
A single audited repo is one session; a fleet of repos is a campaign. This release adds a durable
|
||||
ledger (per-repo lifecycle + machine-wide roll-up), a read-only reporter and a human-approved write
|
||||
surface behind one `/config-audit campaign` command, a cross-repo prioritized backlog, and per-repo
|
||||
plan export — with execution deliberately **reusing** the existing implement/rollback engines rather
|
||||
than adding new machinery. The whole campaign surface is `-cli`/lib only, so the scanner count stays
|
||||
**15**, agents **7**, and the snapshot suite is untouched; commands 20 → **21**.
|
||||
|
||||
### Added
|
||||
- **Durable campaign ledger (`scanners/lib/campaign-ledger.mjs`).** The ledger that sits above
|
||||
sessions: a repo list + per-repo lifecycle (`pending → audited → planned → implemented`) + a
|
||||
machine-wide roll-up (counts by status and by severity, aggregated across repos). Persists to a
|
||||
single JSON file **outside** the plugin dir (`~/.claude/config-audit/campaign-ledger.json`, next
|
||||
to `sessions/`) so it survives uninstall / reinstall / upgrade. Pure transforms
|
||||
(`createLedger` / `addRepo` / `setRepoStatus` / `rollUp`) with `now` injected (never the clock) +
|
||||
a soft `validateLedger` + a thin IO shell; `schemaVersion` stamped from the start.
|
||||
- **Cross-repo prioritized backlog (`buildBacklog`).** One machine-wide work list — each item is a
|
||||
repo (the ledger tracks per-repo severity *counts*, not individual findings), ordered by a
|
||||
weighted severity score (`critical:1000 / high:100 / medium:10 / low:1`) with a deterministic
|
||||
tie-break so criticals always win. Includes only repos not yet `implemented` that have known
|
||||
findings.
|
||||
- **`/config-audit campaign` (commands 20 → 21).** A thin orchestrator that always **reports** first
|
||||
(read-only `campaign-cli`: status, roll-up, backlog) and, for `init` / `add` / `set-status`,
|
||||
proposes the change and mutates **only on explicit human approval** via `campaign-write-cli` — it
|
||||
never hand-edits the ledger JSON (verification duty). `add --discover <root>` finds git repos
|
||||
under a root to pick from; marking a repo `audited` attaches findings-by-severity from its session
|
||||
(or user-provided counts), never invented.
|
||||
- **Per-repo plan export (`campaign-export-cli`).** `--repo <path>` resolves the repo's linked
|
||||
session, reads its `action-plan.md`, and (under opt-in `--write`) drops a provenance-stamped,
|
||||
byte-faithful copy into that repo's own `docs/config-audit-plan-<session>.md` — a durable record
|
||||
next to the code it changes. Advisory gates for "no session linked" / "no action plan yet".
|
||||
|
||||
### Changed
|
||||
- **Execution by reuse.** A campaign adds no execution engine of its own: the exported `docs/` file
|
||||
is the durable record, while `/config-audit implement` (backup + apply + verify) and
|
||||
`/config-audit rollback` do the work, then `campaign set-status <repo> implemented` records it.
|
||||
- **`knowledge-refresh` wired into the surface.** The v5.7.0 `knowledge-refresh` command shipped but
|
||||
was never routed; it is now in the `/config-audit` router (argument-hint + routing) and the `help`
|
||||
command table.
|
||||
- **CLAUDE.md trimmed to lean invariants.** The 19 per-scanner / per-block implementation notes
|
||||
moved verbatim into `docs/scanner-internals.md` ("Implementation notes"); CLAUDE.md drops
|
||||
540 → 134 lines (raising its own config grade B → A). A real "Conventions" section now points at
|
||||
`.claude/rules/`.
|
||||
|
||||
### Tests
|
||||
- 1091 → **1168** (campaign ledger +28, read-only campaign-cli +8, campaign-write-cli +11,
|
||||
cross-repo backlog +12, plan-export +18).
|
||||
|
||||
## [5.7.0] - 2026-06-21
|
||||
|
||||
### Summary
|
||||
"Optimization lens" — the first detector of the **«is this config optimally shaped?»** axis, on top
|
||||
of the existing «is it correct?» checks, plus a **living** machine-readable knowledge layer that keeps
|
||||
the lens's rules fresh. A config that *works* can still fit a better mechanism (a long CLAUDE.md
|
||||
procedure that belongs in a skill, a lifecycle instruction that belongs in a hook); this release adds
|
||||
a hybrid deterministic + opus motor that surfaces those as opportunities, every finding citing a
|
||||
provenance-stamped best-practices register entry. Scanner count 14 → **15**, commands 18 → **20**,
|
||||
agents 6 → **7**.
|
||||
|
||||
### Added
|
||||
- **Best-practices register (knowledge layer).** `knowledge/best-practices.json` — a provenance-stamped,
|
||||
schema-validated register (each entry: `id` / `claim` / `confidence` / `source` + optional
|
||||
`mechanism` / `lensCheck`), the first runtime-consumed file under `knowledge/` and the source of
|
||||
truth for the optimization lens. Loaded/validated by `scanners/lib/best-practices-register.mjs`.
|
||||
Only **confirmed** entries are user-facing (verification duty).
|
||||
- **Optimization-lens scanner (`CA-OPT`, scanner count 14 → 15).** New orchestrated scanner:
|
||||
`CA-OPT-001` (LOW, *Missed opportunity*) — a CLAUDE.md procedure (≥6 consecutive numbered steps)
|
||||
that would fit better as a skill, with recommendation + provenance from register entry `BP-MECH-003`.
|
||||
Deterministic and conservative (no false positives on the negative corpus).
|
||||
- **`/config-audit optimize` + `optimization-lens-agent` (opus, agents 6 → 7).** The prose-judgment
|
||||
half of the lens, for the cases the deterministic scanner deliberately skips: lifecycle phrasing →
|
||||
hook (`BP-MECH-001`), unscoped path-specific instruction → rule (`BP-MECH-002`), absolute "never"
|
||||
prohibition → permission (`BP-MECH-004`). A pre-filter (`scanners/lib/lens-prefilter.mjs`,
|
||||
unit-tested) does cheap recall; the opus agent is the precision gate, dropping low-confidence
|
||||
candidates and citing the register id + source for each kept one. Agent-driven → deliberately not
|
||||
byte-stable (own command, outside the snapshot suite).
|
||||
- **`/config-audit knowledge-refresh` (commands 19 → 20).** Keeps the register current so the lens
|
||||
never reads stale rules. A deterministic core (`scanners/lib/knowledge-refresh.mjs`, `assessFreshness`
|
||||
with an **injected** reference date — it never reads the clock) classifies each entry fresh/stale by
|
||||
the age of its `source.verified` stamp (default 90-day quarterly cadence); a read-only CLI
|
||||
(`knowledge-refresh-cli.mjs`, `--reference-date` / `--stale-after` / `--dry-run`) reports staleness
|
||||
without ever writing the register or hitting the network. The command layer re-verifies stale sources
|
||||
(WebFetch) and polls the CC changelog + Anthropic blog (WebSearch), then applies **only
|
||||
human-approved writes** (verification duty). Web/judgment-driven → deliberately not byte-stable.
|
||||
|
||||
### Notes
|
||||
- **Byte-stability.** The new OPT scanner's additive entry is stripped at compare time
|
||||
(`tests/helpers/strip-added-scanner.mjs`); the original 13-scanner `--json` / `--raw` output stays
|
||||
byte-stable, and only the SC-5 default-output snapshots are regenerated (additive). The two
|
||||
non-deterministic surfaces (`optimize`, `knowledge-refresh`) live outside the snapshot suite by
|
||||
design. `-cli` / lib modules are not orchestrated scanners (self-audit excludes them), so the
|
||||
scanner count stays **15**. Suite **1091** pass (hermetic HOME), self-audit A/A (config 94,
|
||||
plugin 100), readmeCheck passed.
|
||||
|
||||
## [5.6.0] - 2026-06-20
|
||||
|
||||
### Summary
|
||||
"Steering-model II" — the per-mechanism **load-pattern** and **compaction-survival** model lands
|
||||
end-to-end across enumeration, the `manifest` / `tokens` token accounting, and a new output-style
|
||||
scanner. v5.5.0 shipped A+E (additive to existing scanners); this release adds the **Foundation**
|
||||
those features needed, plus **B** (load-pattern accounting) and **C** (the new `CA-OST` family,
|
||||
scanner count 13 → **14**). Every new claim is anchored to the live Claude Code docs
|
||||
(`code.claude.com/docs`) per the project's verification duty.
|
||||
|
||||
### Added
|
||||
- **Foundation — active-config-reader enumeration + load-pattern model.** `active-config-reader`
|
||||
now enumerates **rules**, **agents**, and **output styles** (alongside the existing
|
||||
CLAUDE.md / plugins / skills / hooks / MCP). Each source carries a `loadPattern`
|
||||
(`always` / `on-demand` / `external`), `survivesCompaction`, and `derivationConfidence`, derived
|
||||
from the published loading model. The frontmatter parser additionally reads YAML **block
|
||||
sequences** (`paths:` as a `- item` list), resolving a pre-existing RUL false-positive.
|
||||
- **C — output-style scanner (`CA-OST`, scanner count 13 → 14).** New orchestrated scanner:
|
||||
`CA-OST-001` (MEDIUM) a user/project custom output style missing `keep-coding-instructions: true`
|
||||
(defaults false) silently strips Claude Code's built-in software-engineering instructions when
|
||||
active (V10); `CA-OST-002` (LOW) a **plugin** output style with `force-for-plugin: true` overrides
|
||||
the user's selected `outputStyle` (V11); `CA-OST-003` (MEDIUM) a settings `outputStyle` matching
|
||||
no built-in (Default / Explanatory / Learning / Proactive) nor discovered custom style is dead
|
||||
config.
|
||||
|
||||
### Changed
|
||||
- **B — load-pattern accounting in `manifest` and `tokens`.** `manifest` now reports
|
||||
**component-level** token sources (the coarse plugin roll-up that double-counted its own
|
||||
skills / rules / agents is gone), tags every source with the load-pattern triple, and leads with
|
||||
an **always-loaded subtotal** ("≈X tokens enter context every turn before you type").
|
||||
`token-hotspots` annotates each ranked hotspot with its load pattern. `manifest`'s `total` changes
|
||||
(de-duplicated) — the intended correctness fix.
|
||||
|
||||
### Notes
|
||||
- **Byte-stability.** `--json` / `--raw` stay byte-stable for the original 13 scanners. The frozen
|
||||
v5.0.0 snapshots are **preserved**: B2's additive hotspot fields and C's additive OST scanner
|
||||
entry are stripped at compare time (`tests/helpers/strip-hotspot-load-pattern.mjs`,
|
||||
`tests/helpers/strip-added-scanner.mjs`); only the SC-5 default-output snapshots are regenerated.
|
||||
Suite 1023 pass, self-audit A/A (config 93, plugin 100), readmeCheck passed.
|
||||
|
||||
## [5.5.0] - 2026-06-20
|
||||
|
||||
### Summary
|
||||
"Steering-model I" — two additive findings on the compaction-durability / dead-config theme,
|
||||
extending the existing RUL, CML, and PLH scanners (scanner count stays **13**; `--json` and `--raw`
|
||||
remain byte-stable). Both are anchored to the live Claude Code docs (`code.claude.com/docs`) per the
|
||||
project's verification duty. The Foundation work (active-config-reader enumeration) and feature B are
|
||||
deferred to v5.6 — A and E are additive to existing scanners and do not consume it.
|
||||
|
||||
### Added
|
||||
- **RUL / CML compaction-durability findings (A)** — per the official "what survives compaction"
|
||||
model (`context-window.md`), only the project-root CLAUDE.md and unscoped rules are re-injected
|
||||
after a context compaction. Two structural findings, both severity **LOW**: a large (>50-line)
|
||||
**path-scoped** rule reloads only on a matching file read and is not re-injected after compaction,
|
||||
so a must-hold rule can silently drop mid-session (RUL); a **nested** (subdirectory) CLAUDE.md is
|
||||
not re-injected after compaction — only the project root is (CML).
|
||||
- **PLH plugin-agent ignored-frontmatter finding (E)** — plugin subagents silently ignore
|
||||
`hooks`/`mcpServers`/`permissionMode` frontmatter (honored only for user/project agents in
|
||||
`.claude/agents/`). Setting them in a plugin agent is dead config: `permissionMode` is **MEDIUM**
|
||||
(it implies a restriction Claude Code does not apply — false security); `hooks`/`mcpServers` are
|
||||
**LOW**. Additive to PLH's agent-frontmatter loop; one humanizer pattern covers the three fields.
|
||||
|
||||
### Known limitations
|
||||
- The lightweight frontmatter parser (`scanners/lib/yaml-parser.mjs`) reads inline `paths: "a, b"`
|
||||
but not YAML block sequences (`paths:\n - a`), so a block-sequence-scoped rule is still seen as
|
||||
unscoped and the RUL durability check (A) does not fire on it. Pre-existing and broader than A;
|
||||
to be fixed in the v5.6 Foundation work.
|
||||
|
||||
## [5.4.1] - 2026-06-20
|
||||
|
||||
### Summary
|
||||
Scanner-correctness patch. Five primary-source-verified fixes to the PLH, HKV, and RUL scanners —
|
||||
no new scanner, no command or output-format change (scanner count stays **13**; `--json` and `--raw`
|
||||
remain byte-stable). All fixes are anchored to the live Claude Code docs (`code.claude.com/docs`,
|
||||
docs map 2026-06-19) per the project's verification duty.
|
||||
|
||||
### Fixed
|
||||
- **HKV hook events** — added `Setup`, `UserPromptExpansion`, and `PostToolBatch` to the valid-event
|
||||
set (verified against `hooks.md`); a valid hook using one of these was wrongly flagged
|
||||
"Unknown hook event … will never fire", which could lead a user to delete a working hook. The
|
||||
"(N total)" hint in the recommendation is now derived from the set so it cannot drift.
|
||||
- **HKV `post-session`** — removed from the valid-event set. The 2.1.169 changelog `post-session`
|
||||
hook is a **self-hosted-runner** workspace-lifecycle hook (runs after the session, before the
|
||||
workspace is deleted), **not** a `settings.json` hook event — it is absent from `hooks.md` (all
|
||||
settings.json events are PascalCase). A bogus `post-session` settings hook is now correctly flagged.
|
||||
- **RUL `globs` rule** — reworded the finding. Only `paths:` is documented as the rule-scoping
|
||||
field; whether Claude Code ever read `globs` is unverified, so the previous "deprecated/legacy"
|
||||
framing overclaimed. The finding (and its humanizer entry) now steer to the documented `paths:`
|
||||
field without asserting deprecation. The coupled fix-engine title match was updated in lockstep.
|
||||
- **PLH required frontmatter** — `model`/`tools`/`name`/`allowed-tools` are optional per the CC
|
||||
agent/command docs; a missing one is no longer flagged.
|
||||
- **PLH CLAUDE.md component sections** — a commands/agents/hooks section is required only for a
|
||||
component the plugin actually ships.
|
||||
|
||||
## [5.4.0] - 2026-06-19
|
||||
|
||||
### Summary
|
||||
|
|
|
|||
390
CLAUDE.md
390
CLAUDE.md
|
|
@ -1,13 +1,10 @@
|
|||
# Config-Audit Plugin
|
||||
|
||||
Claude Code Configuration Intelligence — know if your configuration is correct, find what could improve it, fix it automatically.
|
||||
Claude Code Configuration Intelligence — know if your config is correct, find what could improve it, fix it automatically. Three pillars: **Health** (deterministic scanners), **Opportunities** (context-aware recommendations), **Action** (auto-fix with backup/rollback).
|
||||
|
||||
## What this plugin does
|
||||
Per-command flags, patterns, and feature lists live in `README.md` and `/config-audit help`. This file carries what's invariant for working on the plugin.
|
||||
|
||||
Analyzes and optimizes Claude Code configuration across three pillars:
|
||||
- **Health** — Deterministic scanners verify correctness, consistency, and completeness
|
||||
- **Opportunities** — Context-aware recommendations for features that could benefit your project
|
||||
- **Action** — Auto-fix with backup/rollback
|
||||
**Positioning vs. built-in `/doctor` (measured 2026-08-03, binding):** we are the deterministic/reproducible/all-scope/zero-quota side; `/doctor` is usage-weighted one-shot judgment. Never build a feature whose whole value is duplicating a `/doctor` check — see README «config-audit vs. the built-in /doctor» and `docs/v5.13-model-routing-effort-deadref-plan.md` §A.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -15,14 +12,15 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|
|||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
|
||||
| `/config-audit posture` | Quick health scorecard (A-F grades, 10 quality areas incl. Token Efficiency, Plugin Hygiene) |
|
||||
| `/config-audit tokens` | prompt-cache-aware token hotspots (6 patterns: cache-breaking, redundant perms, deep imports, oversized cascade, bloated SKILL.md desc, MCP tool-schema budget) — optional `--accurate-tokens` API calibration, `--with-telemetry-recipe` cache-hit recipe pointer |
|
||||
| `/config-audit manifest` | Ranked table of every system-prompt token source (CLAUDE.md, plugins, skills, MCP, hooks) sorted by estimated tokens |
|
||||
| `/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` | Full audit with auto-scope detection |
|
||||
| `/config-audit posture` | A-F health scorecard (10 quality areas) |
|
||||
| `/config-audit tokens` | Prompt-cache-aware token hotspots, each tagged with its load pattern; cache-aware |
|
||||
| `/config-audit manifest` | Ranked table of every token source + always-loaded subtotal |
|
||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
||||
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable**. `--subtract` adds the subtraction axis (what no longer earns its always-loaded rent, `BP-SUB-001`) — opt-in, proposes only; `--subtract --apply` executes the removals the operator picks; `--subtract --for-model <name>` annotates the candidates a named model documents as redundant (`BP-PROMPT-001`) |
|
||||
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
||||
| `/config-audit rollback` | Restore configuration from backup |
|
||||
| `/config-audit plan` | Create action plan from audit findings |
|
||||
| `/config-audit plan` | Create action plan from findings |
|
||||
| `/config-audit implement` | Execute plan with backups + auto-verify |
|
||||
| `/config-audit help` | Show all commands |
|
||||
|
||||
|
|
@ -32,7 +30,9 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|
|||
|---------|-------------|
|
||||
| `/config-audit drift` | Compare current config against saved baseline |
|
||||
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
||||
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
|
||||
| `/config-audit whats-active` | Read-only inventory of active plugins/skills/agents/MCP/hooks/CLAUDE.md (with token estimates, and `model`/`effort` per agent) |
|
||||
| `/config-audit knowledge-refresh` | Refresh the best-practices register (stale check + web poll). Human-approved writes; **not byte-stable** |
|
||||
| `/config-audit campaign` | Machine-wide audit ledger + token bill across repos. Human-approved writes; **not byte-stable** |
|
||||
| `/config-audit discover` | Run discovery phase only |
|
||||
| `/config-audit analyze` | Run analysis phase only |
|
||||
| `/config-audit interview` | Gather user preferences (opt-in) |
|
||||
|
|
@ -48,60 +48,216 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|
|||
| planner-agent | Create action plan | opus | yellow | Read, Glob, Write |
|
||||
| implementer-agent | Execute changes | sonnet | magenta | Read, Write, Edit, Bash, Glob |
|
||||
| verifier-agent | Verify results | sonnet | purple | Read, Glob, Grep |
|
||||
| feature-gap-agent | Context-aware feature recommendations | opus | green | Read, Glob, Grep, Write |
|
||||
| feature-gap-agent | Feature recommendations | opus | green | Read, Glob, Grep, Write |
|
||||
| optimization-lens-agent | Mechanism-fit precision gate | opus | orange | Read, Glob, Grep, Write |
|
||||
|
||||
## Hooks
|
||||
|
||||
| Event | Script | Purpose |
|
||||
|-------|--------|---------|
|
||||
| PreToolUse | `auto-backup-config.mjs` | Auto-backup config files before Edit/Write |
|
||||
| PostToolUse | `post-edit-verify.mjs` | Verify config files after Edit/Write, block on new critical/high |
|
||||
| SessionStart | `session-start.mjs` | Checks for active (unfinished) sessions |
|
||||
| Stop | `stop-session-reminder.mjs` | Reminds about current session phase |
|
||||
| PreToolUse | `auto-backup-config.mjs` | Backup config files before Edit/Write |
|
||||
| PostToolUse | `post-edit-verify.mjs` | Verify after Edit/Write, block on new critical/high |
|
||||
| SessionStart | `session-start.mjs` | Check for active (unfinished) sessions |
|
||||
| Stop | `stop-session-reminder.mjs` | Remind about current session phase |
|
||||
|
||||
## Reference docs (read on demand)
|
||||
|
||||
- **Scanner inventory, lib modules, action engines, knowledge base:** `docs/scanner-internals.md`
|
||||
- **Plain-language output (v5.1.0), humanizer vocabularies, output modes:** `docs/humanizer.md`
|
||||
- `docs/scanner-internals.md` — scanner inventory, lib modules, action engines, knowledge base, per-scanner/per-block implementation notes (design rationale, primary-source verification, byte-stability lessons)
|
||||
- `docs/humanizer.md` — plain-language output (v5.1.0), humanizer vocabularies, output modes
|
||||
|
||||
## Plain-Language Output (v5.1.0) — summary
|
||||
## Plain-Language Output (v5.1.0)
|
||||
|
||||
Default output of all 18 commands routes through `humanizeEnvelope` from `lib/humanizer.mjs`. Findings get three decorated fields:
|
||||
|
||||
- `userImpactCategory` — Configuration mistake / Conflict / Wasted tokens / Dead config / Missed opportunity
|
||||
- `userActionLanguage` — Fix this now / Fix soon / Fix when convenient / Optional cleanup / FYI (derived from severity)
|
||||
- `relevanceContext` — `affects-everyone` (default) / `affects-this-machine-only` (`*.local.*` files) / `test-fixture-no-impact`
|
||||
|
||||
`--raw` bypasses the humanizer for byte-stable v5.0.0 output. `--json` is also byte-stable. Full detail and Wave 5 lessons: `docs/humanizer.md`.
|
||||
Default output of all commands routes through `humanizeEnvelope` (`scanners/lib/humanizer.mjs`), decorating each finding with `userImpactCategory`, `userActionLanguage`, and `relevanceContext`. `--raw` and `--json` bypass the humanizer for byte-stable v5.0.0 output. Full detail: `docs/humanizer.md`.
|
||||
|
||||
## Suppressions
|
||||
|
||||
Create `.config-audit-ignore` at project root to suppress known findings:
|
||||
```
|
||||
CA-SET-003 # Exact ID
|
||||
CA-GAP-* # Glob pattern (all GAP findings)
|
||||
```
|
||||
Suppressed findings tracked in envelope's `suppressed_findings` for audit trail. Disable with `--no-suppress`.
|
||||
Create `.config-audit-ignore` at project root — one exact ID or glob per line (`CA-SET-003`, `CA-GAP-*`). Suppressed findings are tracked in the envelope's `suppressed_findings` for audit trail. Disable with `--no-suppress`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Workflow
|
||||
```
|
||||
/config-audit → discover + analyze (auto) → plan → implement → verify
|
||||
```
|
||||
Default: auto-detects scope from git context. Override with `/config-audit full|repo|home|current`. Delta mode: `--delta` (incremental).
|
||||
Workflow: `/config-audit → discover + analyze (auto) → plan → implement → verify`. Auto-detects scope from git context; override with `full|repo|home|current`; `--delta` for incremental. Session state lives under `~/.claude/config-audit/sessions/{id}/` (scope.yaml, discovery.json, state.yaml, findings/, analysis-report.md, action-plan.md, backups/, implementation-log.md).
|
||||
|
||||
### Session Directory
|
||||
```
|
||||
~/.claude/config-audit/sessions/{session-id}/
|
||||
├── scope.yaml, discovery.json, state.yaml
|
||||
├── findings/, analysis-report.md, action-plan.md
|
||||
├── backups/, implementation-log.md
|
||||
└── interview.md (if interview run)
|
||||
```
|
||||
Finding ID format: `CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`, `CA-TOK-005`, `CA-CPS-001`, `CA-SKL-001`, `CA-OST-001`, `CA-OPT-001`, `CA-AGT-001`.
|
||||
|
||||
### Finding ID Format
|
||||
`CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`, `CA-TOK-005`, `CA-CPS-001`, `CA-DIS-001`, `CA-COL-001`, `CA-SKL-001`
|
||||
**GAP dimensions vs. levers (invariant).** `GAP_CHECKS` holds the 24 *dimensions* — always evaluated, always counted in the utilization denominators (`TIER_COUNTS` / `TOTAL_DIMENSIONS` in `scoring.mjs`, and `TITLE_TO_ID` there). A *lever* is a finding the scanner emits after the loop and only under a measured condition; it carries no tier, never enters those denominators, and is registered in the exported `LEVERS` object (code + title in one place, because the finding-code guard needs the code and the humanizer-coverage guard needs the title). Adding a dimension moves every user's utilization score and can flip the reported `segment` in a frozen baseline — adding a lever cannot. When a check is only meaningful for configs that already have some feature, it is a lever.
|
||||
|
||||
**`{NNN}` names the CHECK, never the emission position (invariant).** `scanners/lib/finding-codes.mjs` is the single authority: every `finding()` call passes a `code`, and an undeclared or missing one **throws** — there is no counter fallback, because a fallback lets a half-converted scanner ship IDs that look valid. Adding a check takes the next free number for that scanner, never the next source-order position; removing one moves its key to `RETIRED_CODES` and its number is never reissued. IDs are therefore **not unique per finding** — one check failing in three files emits three findings sharing an ID, and `(id, file, line)` is the instance key that `fix-engine` verification uses. Frozen `v5.0.0` baselines mask IDs (`tests/helpers/mask-finding-ids.mjs`) instead of re-deriving them; the check→number pairs are pinned exhaustively in `tests/lib/finding-codes.test.mjs`.
|
||||
|
||||
**A file's contract cannot exceed its tools (invariant).** `agents/verifier-agent.md` said both
|
||||
"Append to: implementation-log.md" (§Output Format) and "never modifies any files" (§Read-Only
|
||||
Guarantee) while granting only `Read, Glob, Grep`. The failure mode is not a blocked write — it is
|
||||
the agent improvising a full-file `Write` on the log it *shares* with the parallel implementer
|
||||
agents, which is the exact defect `implement-log-append.test.mjs` exists to prevent, entering
|
||||
through the one file that test does not read. `tests/agents/agent-write-contract.test.mjs` asserts
|
||||
the blanket invariant over the whole catalogue rather than a fact about one file: an agent whose
|
||||
`tools:` grant no write capability (`Write`/`Edit`/`NotebookEdit`/`Bash`) must instruct no file
|
||||
write **and** state positively that it returns its findings inline. Both sides are read from the
|
||||
file, so stripping `Write` from any agent whose body still instructs a write turns it red, and the
|
||||
sweep asserts a write-tool-less agent exists so the invariant cannot pass vacuously. Measured
|
||||
2026-08-20: **1 of 7** agents carried the defect; the other six all hold `Write`. The orchestrator
|
||||
half was already correct — `implement.md` Step 5 appends with Bash `>>` and tells the agent not to
|
||||
write — so this was a one-way fix in the agent file, not a two-sided one.
|
||||
|
||||
**Frontmatter contracts are derived, never listed (invariant).** `.claude/rules/agent-development.md`
|
||||
and `.claude/rules/command-development.md` state which keys an agent/command MUST carry and that
|
||||
agent colors are unique; nothing enforced any of it, and the only test that read agent frontmatter
|
||||
checked `name:` against a hand-written 3-of-7 list. `tests/agents/frontmatter-contract.test.mjs`
|
||||
takes nothing by hand: required keys are parsed from each rule's own fenced `yaml` block, the files swept
|
||||
are resolved from each rule's own `paths:`, and the plugin name is read from
|
||||
`.claude-plugin/plugin.json` — add a key to a rule and it is enforced on the next run with no test
|
||||
edit, because a hand-kept list of what to sweep is a premise, not a measurement. The repo was
|
||||
already compliant (7/7 agents, 21/21 commands, 0 duplicate colors), so a green first run proves
|
||||
nothing: every arm was seen red against a temporarily introduced defect **one at a time**,
|
||||
including emptying a rule's yaml block to show the derivation is not vacuous. The color **enum** is
|
||||
deliberately *not* guarded — the official subagent docs list `red/blue/green/yellow/purple/orange/
|
||||
pink/cyan` (no `magenta`) while issue #19292 lists `magenta` but neither `purple` nor `orange`, and
|
||||
this plugin ships both `magenta` and `orange`; pinning an unsettled set in a guard would encode an
|
||||
unverified premise rather than measure one.
|
||||
|
||||
## Conventions
|
||||
|
||||
Enforced conventions live in `.claude/rules/` (auto-loaded as project instructions):
|
||||
- `ux-rules.md` — output/narration/formatting for all commands (never dump raw JSON, narrate before each step, space-separated command suggestions)
|
||||
- `command-development.md` — required command frontmatter + `plugin:action` naming
|
||||
- `agent-development.md` — agent frontmatter + "when to use" conventions
|
||||
- `state-management.md` — update `state.yaml` after every workflow phase
|
||||
|
||||
Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{SCANNER}-{NNN}` ID format; byte-stable CLIs are verified against frozen `tests/snapshots/v5.0.0/` baselines.
|
||||
|
||||
**Write-scope gate (invariant).** Every write target is classified by `scanners/lib/write-scope.mjs` before it reaches an approval surface, and the **scope class decides the gate's strength — never the command asking**. Five command-owned policies would drift apart the way five copies of the lever table did. `SCOPE_CLASSES` is the single source for class, gate (`silent`/`disclose`/`require-ok`), wording and predicate; templates render `disclosures[]` from `write-scope-cli.mjs` rather than restating what a class means. Two orderings in that object are load-bearing and were measured, not reasoned about: `plugin-managed` before `user-scope` (both `~/.claude/config-audit/` and legacy `~/.config-audit/` are live, so the other order fires the gate on every session write and gets it switched off), and `user-scope` before `cross-repo` (`~/.claude/.git` exists, so a plain `.git`-upward walk calls `~/.claude/CLAUDE.md` merely "another repo" and silently downgrades the strongest gate). `disclose` ≠ `require-ok`: `campaign export` is cross-repo *by design*, so tightening it into a refusal breaks the feature. Distinct from the `require-target-dir.mjs` guard, which asks whether a scan **root** is readable (exit 3) — a different invariant, not to be merged.
|
||||
|
||||
**Write-gate coverage (invariant).** The gate above only counts where it is *called*, and for four
|
||||
releases it was called from prose: `write-scope.mjs` existed, but exactly one writer imported it
|
||||
(`lib/subtraction-write.mjs`) while five command templates paraphrased the policy. Measured
|
||||
2026-08-12: 9 files under `scanners/` write to disk, 1 imported the gate. The defect was never
|
||||
"8 ungated writers = 8 bugs" — four of them write the plugin's own bookkeeping and MUST stay
|
||||
ungated, because a gate that fires on every run gets switched off. The defect is that **nothing
|
||||
declared which**, so the question was answered by reading, and answered differently each time.
|
||||
`tests/lib/write-gate-coverage.test.mjs` is now the authority: every writer must either import
|
||||
the gate or hold an `EXEMPT` entry naming **where the bytes land**. Three properties are
|
||||
load-bearing. (1) **A rationale is a claim, not a label** — `scan-orchestrator` was carried in
|
||||
the plan text as exempt while `--save-baseline` derived its path from the *scan target*, so
|
||||
`--global` landed `~/.claude/.config-audit-baseline.json` (`user-scope`/`require-ok`); it is
|
||||
gated, and `lib/baseline.mjs` — which writes only under `~/.config-audit/baselines` — is the
|
||||
genuinely exempt one. (2) **Sync variants count**: `writeFile(` does not match `writeFileSync(`,
|
||||
and `lib/backup.mjs` uses only the sync forms, so the first sweep scored a real writer as clean
|
||||
and was green on its own subject. (3) **The sweep asserts non-emptiness** — a regex that stops
|
||||
matching makes every other assertion here vacuously green. The exemption table is stale-checked
|
||||
in both directions: an entry naming a file that no longer writes, or one that has since been
|
||||
gated, fails. `evaluateWriteTargets` in `write-scope.mjs` is the one reduction (classify →
|
||||
`strongestGate` → dedup disclosures) that all five call sites share; four copies of those four
|
||||
lines is the drift shape `SCOPE_CLASSES` exists to prevent one level down. Approval is carried by
|
||||
`--approve-scope`, and **classifying is not approving**: a template that sets the flag because it
|
||||
already ran `write-scope-cli` has rebuilt the prose contract this guard replaced.
|
||||
|
||||
**Command→CLI flag contract (invariant).** A command template is a caller with no compiler
|
||||
behind it: it names a scanner and an argv, and nothing used to check that the scanner still
|
||||
accepts them. The measured cost is M-BUG-45 — `--stale-after` reached its CLI malformed, was
|
||||
ignored, and the command reported "✓ all 14 entries re-verified within the last 90 days" about
|
||||
a threshold the user had just overridden. `tests/commands/command-cli-contract.test.mjs` closes
|
||||
that seam, and four properties are load-bearing. (1) **The argv is built from the template's own
|
||||
text** (`tests/helpers/command-invocations.mjs`), never hand-typed — a hand-written call is a
|
||||
path no user takes (#63). Flags appear in *three* forms and all three are read: literal,
|
||||
`if …; then RAW_FLAG="--raw"; fi`, and **comment-only** (`GLOBAL_FLAG="" # --global`); the third
|
||||
is the class that dies unobserved, because the default path leaves the variable empty. (2) **The
|
||||
probe proves itself per CLI** — each must first be seen rejecting a flag that certainly does not
|
||||
exist, or a CLI that exits on a required-arg check before reaching flag parsing passes every pair
|
||||
vacuously. Measured 15/15 report the unknown flag first, so no prefix-argv table is needed, and
|
||||
the second copy of `cli-unknown-flag-rejection`'s `GUARDED` table was therefore never created.
|
||||
(3) **"Unknown" is told from "needs a value" by the CLI's own words**, which is only sound because
|
||||
every CLI classifies the two correctly — measured 14/15, and the fifteenth
|
||||
(`campaign-export-cli`, the last hand-rolled parser, whose `argv[i+1] !== undefined` guards let a
|
||||
trailing `--repo` fall through to the catch-all and be reported as an unknown flag) was moved onto
|
||||
the shared `requireValidArgs` gate rather than special-cased in the test. (4) **Probing a flag
|
||||
runs the CLI, and some flags are writers** — the first run of this guard let `drift-cli --save`
|
||||
default its target to the cwd and overwrite the operator's real
|
||||
`~/.config-audit/baselines/default.json`. Every probe now runs under `hermeticEnv()` with its own
|
||||
empty cwd, and the cwd is *asserted* empty afterwards: isolation that is only a convention is not
|
||||
isolation. Not asserted here: that a template calling a gated writer also calls `write-scope-cli`
|
||||
— measured false-red (`discover`/`config-audit` invoke `scan-orchestrator` without reaching its
|
||||
`--save-baseline` write), so that arm stays in `write-scope-gate-shape.test.mjs`.
|
||||
|
||||
**Dead-prose-reference silence list (invariant).** `CA-CML-013` is a precision-first check, so its
|
||||
design lives in what it *declines* to flag, and that list is measured (407 real CLAUDE.md files),
|
||||
never argued. Three rules are load-bearing and each has a guard seen red against its own defect.
|
||||
(1) **Containment is checked against the scan root, not the file's own directory** — a `../` chain
|
||||
that leaves the tree is silenced (`outside-scan-tree`) rather than resolved, because a base a `..`
|
||||
chain can escape is not a base: measured, `../../../../etc/passwd` resolved to the real file and
|
||||
silenced its own finding. A legitimate `../docs/x.md` inside the same repo still resolves.
|
||||
(2) **A bare token is a concept, not a reference** — `README.md` (no separator) and `docs/`
|
||||
(single segment) are excluded on the same reasoning one level apart; admitting bare filenames
|
||||
tripled the output with name-drops of tools living elsewhere, and single-segment folders are 26 %
|
||||
of the remainder, led by a remote namespace prefix. (3) **Rule ORDER is the reported reason** —
|
||||
first match wins, so `npm test` is silenced as `whitespace` (a command), not as `no-separator`,
|
||||
and two silences with different causes keep their own fixtures. The check emits **one finding per
|
||||
file** (the `todo-markers` / `repeated-content` idiom), because per-token emission measured 699
|
||||
findings where per-file measured 128. Silence is the safe failure direction here: a path carrying
|
||||
a trailing `:54-56` locator is a recorded v1 miss, not a bug to fix by loosening a rule.
|
||||
|
||||
**Subtraction floor (invariant).** `optimize --subtract` is the only lens that proposes removing config, so `scanners/lib/floor-exclusion.mjs` runs as a deterministic pre-step *before* the judge — a load-bearing block is never a candidate, and that guarantee must not be moved into the agent prompt. Two rules follow from it: (1) **staleness is not a deletion signal** — an outdated version pin inside a floor block is a `drift`/`CA-CML` dead-reference concern; (2) **tier 2 ≠ tier 3** — a compensatory block that keeps earning its place returns, and reporting it as dead weight is wrong even when the label matches. Norwegian keywords need the Unicode boundaries in `subtraction-prefilter.mjs`; JS `\b` is ASCII-only, so `/\bunngå\b/` silently never matches.
|
||||
|
||||
**Model-scoped annotation (invariant).** `--for-model <name>` (`BP-PROMPT-001`,
|
||||
`scanners/lib/prompting-model-scope.mjs`) is an **annotation on existing
|
||||
`BP-SUB-001` candidates, never a detector** — it tags a subset of what
|
||||
`--subtract` already found and can never widen the candidate set. Four
|
||||
properties are load-bearing. (1) **The model must be named.** There is no
|
||||
auto-detection and there cannot be: a CLAUDE.md has no frontmatter and no
|
||||
resolvable target model, and the operator's own `route` skill deliberately runs
|
||||
a different model per session — so the same file is read by whichever model
|
||||
comes next. (2) **Precision is the TARGET, not the verb list.** The verb set is
|
||||
as broad as `subtraction-prefilter`'s `IMPERATIVE_RE`; what narrows it is
|
||||
requiring a co-occurring reflexive ("your own work", "før du svarer") or
|
||||
delegated ("subagent") target. Measured across the 409-file corpus: 392
|
||||
`BP-SUB-001` candidates, **31 carry a verify verb, 0 also carry a target**, and
|
||||
two independent raw-text greps also found 0 — so the zero is the corpus, not an
|
||||
over-narrow regex. Those 31 (`sjekk relevante config-filer`, `Type-sjekk:
|
||||
pyright`) are exactly the false positives a verb-only version would have
|
||||
produced, which is `BP-JUDG-001`'s 7/7 failure arriving one lens over. The
|
||||
numbers live in the register entry's `note` and are pinned by
|
||||
`best-practices-register.test.mjs`, because a later session that cannot see the
|
||||
measurement reads the zero as a broken detector and loosens it. (3) **`recognized`
|
||||
is not `matchedCount`.** A typo'd model name and a genuinely clean config both
|
||||
yield zero matches; without the separate flag the CLI would report a silent
|
||||
no-op as good news. Dogfooded on the real machine: `opus-5` → `recognized:true,
|
||||
matchedCount:0`; `oppus5` → `recognized:false`. (4) **The citation is
|
||||
conditional wherever a human sees it** — agent report copy and the Step 7a
|
||||
approval listing both say so. `--for-model` and `--apply` are separate CLIs that
|
||||
never see each other's flags, so a CLI-level refusal is impossible; the
|
||||
safeguard has to live in the copy. The payload stays additive: `forModel` and
|
||||
per-candidate `modelScope` appear **only** when the flag is passed, so a plain
|
||||
`--subtract` run is byte-identical to the pre-flag payload (asserted on the
|
||||
serialized bytes, since a key set to `undefined` passes a shallow key check).
|
||||
|
||||
**Backup/restore is one code path (invariant).** `scanners/rollback-cli.mjs` is the only entry to
|
||||
the backup engine, and BOTH pipelines use it: `fix` backs up through `createBackup` directly,
|
||||
`implement` Step 3 through `--create`. Before R1/R2 the two halves each hid the other's failure.
|
||||
The engine verified every checksum before and after each write, but had no `process.argv` (16 CLIs
|
||||
under `scanners/` had one, it did not), so `commands/rollback.md` restored as model prose — an ESM
|
||||
`import` block a template cannot execute, `cp` offered underneath as the runnable path, and
|
||||
"(checksum verified)" pre-rendered in the success output. `cp` establishes no checksum, so the
|
||||
verification was a property of the template. Meanwhile `implement` hand-built its manifest in the
|
||||
template while `parseManifest` knew one frozen sample of that format, pinned by a HAND-WRITTEN
|
||||
fixture rather than by the template's own text — the #63 shape on the data side, where a renamed
|
||||
key yields zero parsed files and a rollback that reports success having restored nothing. Four
|
||||
properties are load-bearing. (1) **Neither half fixes alone**: a CLI over a prose format still
|
||||
parses prose; a clean format with no runnable entry still cannot restore. (2) **A gated restore is
|
||||
exit 1, not 3** — "this write leaves your project" is a verdict about a write that WAS examined and
|
||||
rides in the payload, where a command running under `2>/dev/null` can act on it (F3's class); 3
|
||||
stays reserved for argv errors and a backup id that resolves in neither root. (3) **`--created`
|
||||
records, it does not copy** — no backup can hold a file that does not exist yet, so those paths go
|
||||
into the manifest for `rollback` to report as left in place; `serializeManifest` emits the bare
|
||||
`created:` key, which is why the pre-R2 `created: <timestamp>` (a VALUE, meaning the backup id)
|
||||
never collides with it. (4) **`parseManifest`'s implement-format branch stays** even though nothing
|
||||
writes that shape now — backups already on disk in it must remain restorable, the same reason
|
||||
`getLegacyBackupDir()` is still read; its fixture changed meaning from "a stand-in for the
|
||||
template's text" to "a golden sample of historical bytes". The output contract is guarded by
|
||||
running the CLI: `tests/commands/backup-restore-contract.test.mjs` checks every field
|
||||
`rollback.md` renders against a real payload, so a renamed key fails there instead of becoming a
|
||||
confident sentence in front of a user who is already in trouble. Distinct from the scope gate,
|
||||
which classifies *where* a restore lands — `rollback.md` still calls `write-scope-cli` before its
|
||||
approval surface, and classifying is still not approving.
|
||||
|
||||
**Subtraction write path (invariant).** `--apply` routes through `scanners/lib/subtraction-write.mjs`, never through `fix-engine` or the `plan`/`implement` pipeline, and both exclusions are **measured**: the subtraction axis is absent from the orchestrated envelope, so `verifyFixes`' re-scan would mark every removal `verified` whether or not it happened (a success-shaped no-op), and the findings pipeline needs a finding code — which names a deterministic check, not a prose judgement. Three properties are load-bearing and each has a guard seen red against its own defect: removals are validated against the ORIGINAL content and applied in **descending** line order (an ascending pass shifts later spans out from under themselves); the **range** check is not redundant with the text check (`line: 0` makes `slice(-1, 0)` empty, so an empty `text` matches and `splice(-1, 1)` deletes the file's LAST line); and `createBackup` skips a nonexistent path while still returning an id, so coverage of every file about to be written is **asserted from the manifest** before a byte changes. The floor is *repeated* here, not moved: `floor-exclusion` still vetoes before anything is proposed, and the engine refuses a load-bearing block again so a hand-built approval cannot route around it. The archive rule (`mv` to `_archive/`) is file-level and does not apply to a block excision — the timestamped backup is the recovery artifact, and inventing a second copy with no restorer behind it would be worse than none.
|
||||
|
||||
## Testing
|
||||
|
||||
|
|
@ -109,142 +265,10 @@ Default: auto-detects scope from git context. Override with `/config-audit full|
|
|||
node --test 'tests/**/*.test.mjs'
|
||||
```
|
||||
|
||||
936 tests across 56 test files (17 lib + 29 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`.
|
||||
|
||||
### CML scanner — context-window-scaled char budget
|
||||
|
||||
Beyond the line-count checks (200/500 lines, both MEDIUM), the CML scanner mirrors
|
||||
Claude Code's own startup warning — *"Large CLAUDE.md will impact performance
|
||||
(X chars > 40.0k)"* — as a `char`-based finding:
|
||||
|
||||
- **Char budget** — flags a CLAUDE.md over **~40.0k chars** (CC's startup-warning
|
||||
figure at a 200k-context model). CC 2.1.169 scales that threshold with the model's
|
||||
context window, so the finding anchors on the conservative 200k window (we cannot
|
||||
observe the user's window; the anchor fires earliest) and discloses the relaxed
|
||||
~200,000-char figure at 1M context. Severity MEDIUM (token cost, not an adherence
|
||||
cliff). New `CA-CML` finding.
|
||||
|
||||
It keys on chars, not lines, so it is complementary to the line checks: a file can be
|
||||
long by lines yet under budget (short lines), or short by lines yet over it (long lines).
|
||||
The 200k/1M window constants live in the shared `scanners/lib/context-window.mjs`
|
||||
(single source of truth, also re-exported by `skill-listing-budget.mjs`). The 40.0k
|
||||
figure and context-window scaling are verified against the CC changelog (2.1.169) and
|
||||
the live startup-warning text.
|
||||
|
||||
### DIS scanner — permission-rule hygiene
|
||||
|
||||
Beyond deny/allow overlap, the DIS scanner now also flags:
|
||||
|
||||
- **Ineffective allow wildcards** — unanchored tool-name globs in `permissions.allow`
|
||||
(`*`, `B*`, `mcp__*`) that Claude Code silently skips (auto-approve nothing). Valid
|
||||
only as a glob-free `mcp__<server>__*`. New `CA-DIS` finding, severity low.
|
||||
- **`Tool(*)` deny-all glob** — treated as equivalent to a bare deny (`Bash(*)` ≡ `Bash`),
|
||||
so a bare allow killed by it is correctly reported as dead config.
|
||||
- **Forbidden-param rules** — `Tool(param:value)` whose key is the tool's own canonicalizing
|
||||
field (`command` for Bash/PowerShell, `file_path` for Read/Edit/Write, `path` for
|
||||
Grep/Glob, `notebook_path` for NotebookEdit, `url` for WebFetch). CC ignores these and
|
||||
emits a startup warning. Severity follows intent: **deny/ask = false security (medium)** —
|
||||
the block never applies; **allow = dead config (low)** — `param:value` matching is
|
||||
deny/ask-only. Valid forms (`Bash(npm:*)`, `WebFetch(domain:host)`, `Agent(model:opus)`)
|
||||
are never flagged. Predicate `forbiddenParamRule` in `permission-rules.mjs`.
|
||||
|
||||
These predicates live in `scanners/lib/permission-rules.mjs` (shared with the CNF
|
||||
conflict-detector). Behavior verified against `code.claude.com/docs/en/permissions`.
|
||||
|
||||
### PLH scanner — plugin namespace collision
|
||||
|
||||
The standalone PLH scanner (cross-plugin checks in `scan()`) flags **plugin namespace
|
||||
collisions**: two or more discovered plugins that declare the **same `name`** in
|
||||
`plugin.json`. The search-first finding that shaped this check: Claude Code namespaces
|
||||
every plugin component by the declared `name` — `/name:command`, `name:skill`, agent
|
||||
`name` (verified against `code.claude.com/docs/en/plugins`, and observable in any session's
|
||||
namespaced skill listing). A plugin component therefore can **never** shadow a user- or
|
||||
project-level one; the only shadow that loses components is a same-`name` collision, where
|
||||
the namespaces collapse into one and CC must pick a winner. Resolution between two installed
|
||||
same-name plugins is **undocumented**, so the loser's commands/skills/agents go silently
|
||||
unreachable — hence severity **MEDIUM** (dead config), `category: 'plugin-hygiene'`, with a
|
||||
COL-shaped `details.namespaces` payload (`{ source: 'plugin:<dir>', name, path }`).
|
||||
|
||||
Two design notes: (1) the check keys on the declared `name` field, **not** `basename(dir)` —
|
||||
the folder name is irrelevant to the namespace; `scanSinglePlugin` now returns `declaredName`
|
||||
for this. (2) Name-less plugins are excluded from the collision map (they are flagged by the
|
||||
missing-field check and must never group on an `undefined` key).
|
||||
|
||||
The sibling cross-plugin **command-name** check was corrected to match the same model. Because
|
||||
commands are namespaced (`/name:command`), a command name shared by two **differently-named**
|
||||
plugins is ambiguity — not a hard conflict — so it now mirrors COL's plugin-vs-plugin skill
|
||||
finding: severity **LOW**, `category: 'plugin-hygiene'`, COL-shaped `details.namespaces`, and a
|
||||
group-first shape (one finding per command name listing every namespace, not pairwise). It keys
|
||||
on the declared namespace and fires only when a name spans **2+ distinct** namespaces; when two
|
||||
plugins share the same declared name, the namespace-collision finding above is the right (more
|
||||
severe) signal, so the command check stays silent there to avoid a redundant `"dup, dup"` report.
|
||||
The earlier HIGH `Cross-plugin command name conflict` finding (basename-keyed, "only one wins")
|
||||
is gone, along with its now-inaccurate humanizer entry.
|
||||
|
||||
### PLH scanner — plugin-folder shadowing (`CA-PLH-015`)
|
||||
|
||||
Per-plugin check (in `scanSinglePlugin`, right after the required-field loop): a `plugin.json`
|
||||
component-path key that **replaces** its default folder while that folder still exists on disk →
|
||||
the folder is silently ignored (dead config). Severity **MEDIUM**, `category: 'plugin-hygiene'`,
|
||||
`details: { field, ignoredDir, customPaths }`. Mirrors Claude Code's own warning in `/doctor`,
|
||||
`claude plugin list`, and the `/plugin` detail view (v2.1.140+).
|
||||
|
||||
The field set is **primary-source-pinned** to the *replaces* category only —
|
||||
`SHADOWING_PATH_FIELDS` = `commands`/`agents`/`outputStyles` (defaults `commands/`, `agents/`,
|
||||
`output-styles/`). Deliberately excluded: **`skills`** (per
|
||||
`code.claude.com/docs/.../path-behavior-rules` it *adds to* the default `skills/` scan — both
|
||||
load, never a shadow), and **`hooks`/`mcpServers`/`lspServers`** (own merge rules, not a
|
||||
folder-shadow). Experimental `themes`/`monitors` are omitted because the docs warn their manifest
|
||||
schema may change between releases. The check also honors the doc's explicit-address exception: a
|
||||
custom path that resolves *into* the default folder (`"commands": ["./commands/x.md"]`) is not
|
||||
flagged, because Claude Code keeps scanning the folder in that case (`addressesDefaultDir`
|
||||
predicate). The v5.4.0 plan originally listed `commands/agents/skills/hooks`; that set was
|
||||
corrected here against the live docs (Verifiseringsplikt).
|
||||
|
||||
### PLH scanner — skills:-array validation (`CA-PLH-016`)
|
||||
|
||||
Per-plugin check (in `scanSinglePlugin`, after the shadow check): when `plugin.json` has a
|
||||
`skills` field (string or array), each entry must resolve to an **existing directory inside the
|
||||
plugin root**. The value is normalized `Array.isArray(v) ? v : [v]`, so a single string is one
|
||||
entry — and a non-string top-level value (e.g. `42`) is naturally caught as a single non-string
|
||||
entry (no separate top-level check needed). One finding per bad entry, severity **MEDIUM**,
|
||||
`category: 'plugin-hygiene'`, `details: { field: 'skills', entry, problem }` where `problem` is
|
||||
one of `non-string` / `escapes-root` / `not-found` / `not-a-directory`. Mirrors
|
||||
`claude plugin validate` (~2.1.145).
|
||||
|
||||
Escape detection uses `skillsEntryEscapesRoot` (resolve + `startsWith(pluginDir + sep)`
|
||||
containment — robust against a literal `..foo` dir name), backed by the docs' path-traversal rule
|
||||
(*"Installed plugins cannot reference files outside their directory … such as `../shared-utils`"*).
|
||||
`statOrNull` distinguishes missing from file-vs-dir. **Verifiseringsplikt note:** the v5.4.0 plan
|
||||
claimed CC "suggests the parent directory when an entry points at a file"; that exact error text is
|
||||
**not** in the primary docs, so it was dropped — the finding asserts only the four
|
||||
primary-source-verified conditions. `skills` is deliberately *not* in `SHADOWING_PATH_FIELDS`
|
||||
(it adds to the default scan, never shadows).
|
||||
|
||||
### SET scanner — autoMode validation (`CA-SET`)
|
||||
|
||||
Per-file check in `settings-validator.mjs` (`autoMode` was in `KNOWN_KEYS` but had no nested
|
||||
validation). Two sub-checks, both primary-source-verified against
|
||||
`code.claude.com/docs/en/auto-mode-config`:
|
||||
|
||||
1. **Structure** (severity **MEDIUM**): `autoMode`, if present, must be an object whose only keys
|
||||
are `environment`/`allow`/`soft_deny`/`hard_deny` (`AUTO_MODE_SUBKEYS`), each a **string
|
||||
array** (the literal `"$defaults"` is a valid entry, so it passes the string check for free).
|
||||
`problem` ∈ `not-an-object` / `unknown-subkey` / `not-string-array` in `details`.
|
||||
2. **Dead-config** (severity **LOW**): Claude Code does **not** read `autoMode` from *shared*
|
||||
project settings — verbatim: *"The classifier does not read `autoMode` from shared project
|
||||
settings in `.claude/settings.json`, so a checked-in repo cannot inject its own allow rules."*
|
||||
The check keys on **`file.scope === 'project'`** (file-discovery's `classifyScope` returns
|
||||
`'project'` for a committed `.claude/settings.json`; `'local'`/`'user'`/`'managed'` are read and
|
||||
not flagged). `problem: 'shared-project-scope'`. This is why the plan's "test per-file scope
|
||||
first" gate passed — `ConfigFile` already carries `scope`.
|
||||
|
||||
The two sub-checks are independent (a malformed autoMode in shared scope yields both). SET is in the
|
||||
orchestrator, so SC-5 was re-checked after this change — byte-equal (the snapshot fixture has no
|
||||
`autoMode`, so the block never fires there).
|
||||
Test fixtures in `tests/fixtures/`. Per-scanner and per-build-block implementation notes (design rationale, primary-source verification, byte-stability lessons) live in `docs/scanner-internals.md` → **Implementation notes**.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Session directories accumulate — use `/config-audit cleanup` to manage
|
||||
- Scanners run on Node.js >= 18 (uses node:test, node:fs/promises)
|
||||
- Scanners run on Node.js ≥ 18 (uses node:test, node:fs/promises)
|
||||
- Plugin CLAUDE.md files in node_modules should be excluded via scope
|
||||
|
|
|
|||
131
GOVERNANCE.md
131
GOVERNANCE.md
|
|
@ -1,131 +0,0 @@
|
|||
# Governance
|
||||
|
||||
How this marketplace is maintained, what you can expect from upstream, and how it's meant to be used.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Solo-maintained, AI-assisted development, MIT licensed.
|
||||
- **Fork-and-own is the default model.** Upstream is a starting point, not a vendor.
|
||||
- Issues welcome as signals. Pull requests are not accepted — see [Why no PRs](#pull-requests--no).
|
||||
- No SLA. Best-effort bug fixes and security advisories. Breaking changes happen and are noted in each plugin's CHANGELOG.
|
||||
|
||||
---
|
||||
|
||||
## Can I trust this?
|
||||
|
||||
Be honest with yourself about what you're adopting:
|
||||
|
||||
- **One maintainer.** If I get hit by a bus, the bus wins. The repos stay up under MIT, but no one owes you a fix.
|
||||
- **AI-generated code with human review.** Every plugin is built through dialog-driven development with Claude Code. I read, test, and judge the output before it ships, but I'm not auditing every line the way a security firm would. Treat it accordingly.
|
||||
- **No commercial interests.** I'm not selling a SaaS, not steering you toward a paid tier, not collecting telemetry. The plugins run locally in your Claude Code installation.
|
||||
- **MIT licensed.** Fork it, modify it, ship it under your own name.
|
||||
|
||||
If you work somewhere that needs vendor accountability, support contracts, or signed assurances — **this isn't that.** Use it as a reference implementation, fork it into your own organization, and own the result.
|
||||
|
||||
---
|
||||
|
||||
## How this is meant to be used
|
||||
|
||||
### Fork-and-own
|
||||
|
||||
The intended workflow:
|
||||
|
||||
1. **Fork** the marketplace (or a single plugin) into your own organization or namespace.
|
||||
2. **Tailor** it to your context — terminology, integrations, cycle lengths, regulatory framing, whatever doesn't fit out of the box.
|
||||
3. **Maintain it yourself.** Treat your fork as the canonical version for your team.
|
||||
4. **Watch upstream selectively.** Cherry-pick changes that help, ignore changes that don't. There's no obligation to stay in sync.
|
||||
|
||||
This isn't a workaround for not accepting PRs. It's the actual recommended adoption pattern, especially for plugins like `okr` and `ms-ai-architect` where every Norwegian public sector organization will need its own tildelingsbrev mappings, terminology, and integrations. A central "one true plugin" would be wrong for everyone.
|
||||
|
||||
### What to change first when you fork
|
||||
|
||||
Each plugin differs, but the common edits are:
|
||||
|
||||
- **Identity** — rename the plugin, replace authorship, update README.
|
||||
- **External integrations** — issue trackers, knowledge bases, dashboards, observability backends. The plugins ship as starting points, not pre-wired. Every organization must configure its own integrations.
|
||||
- **Norwegian-specific framing** — relevant for `okr` and `ms-ai-architect`. Other plugins are jurisdiction-neutral. Rewrite for your jurisdiction if you're outside Norway.
|
||||
- **Reference docs** — the knowledge base in each plugin reflects my reading. Replace with your organization's authoritative sources.
|
||||
- **Hooks and policies** — security thresholds, blocked commands, and audit gates are tuned to my taste. Tune them to yours.
|
||||
|
||||
### Staying current with upstream
|
||||
|
||||
If you want to pull in upstream changes later:
|
||||
|
||||
- **Cherry-pick, don't merge.** Each plugin moves independently and breaking changes land without ceremony.
|
||||
- **Read the CHANGELOG first.** Every plugin has one.
|
||||
- **Keep your customizations in clearly-named files.** The harder upstream is to merge cleanly, the more painful staying current becomes. A `local/` directory or `*.local.md` convention helps.
|
||||
|
||||
---
|
||||
|
||||
## What upstream provides
|
||||
|
||||
| | What I do | What I don't |
|
||||
|---|---|---|
|
||||
| **Bug fixes** | Best-effort when I notice or get a clear report | No SLA, no triage commitment |
|
||||
| **Security issues** | Investigate within reasonable time, document in CHANGELOG | No CVE process, no embargo coordination |
|
||||
| **New features** | When they fit my own usage | Not on request |
|
||||
| **Norwegian public sector context** | Kept current as long as the project lives | If I lose interest or change jobs, the framing freezes |
|
||||
| **Breaking changes** | Documented in CHANGELOG | They happen — version pin if you need stability |
|
||||
| **Compatibility** | Tracked against current Claude Code releases | No long-term support branches |
|
||||
|
||||
If any of this is a dealbreaker — fork now, version-pin, and stop reading upstream.
|
||||
|
||||
---
|
||||
|
||||
## How to contribute
|
||||
|
||||
### Issues — yes, please
|
||||
|
||||
Issues are the most valuable thing you can send me:
|
||||
|
||||
- **Bug reports** with reproduction steps. Even a screenshot helps.
|
||||
- **Use-case feedback.** "I tried to use this in my organization and X didn't fit" is genuinely useful, even if I can't fix it for you.
|
||||
- **Pointers to better sources.** If you know a DFØ veileder, an NSM guideline, or an academic paper that contradicts what's in a knowledge base, tell me.
|
||||
- **Security findings.** See each plugin's `SECURITY.md` for disclosure preference where one exists; otherwise email rather than open a public issue.
|
||||
|
||||
### Pull requests — no
|
||||
|
||||
This is deliberate, not laziness:
|
||||
|
||||
- **Solo review is a bottleneck.** Honest PR review takes me longer than rewriting from scratch. The math doesn't work.
|
||||
- **Forks are where the value is.** The fork-and-own model means upstream consolidation isn't the point. Your organization's adaptations belong in your fork, not mine.
|
||||
- **AI-generated code complicates provenance.** Every line here is produced through dialog with Claude Code, with me as the judge. Mixing in PRs from contributors with different processes and licensing assumptions creates a mess I'd rather not untangle.
|
||||
|
||||
If you've built something useful on top of a fork, **publish it under your own name and link back.** I'll happily list notable forks here once they exist.
|
||||
|
||||
### Notable forks
|
||||
|
||||
*(To be populated as forks emerge. If you've forked one of these plugins for production use, open an issue and I'll add a link.)*
|
||||
|
||||
---
|
||||
|
||||
## Relationship between plugins
|
||||
|
||||
These plugins are **independent**. Install one without the others, fork one without the others. They share conventions (slash command naming, hook patterns, AI-generated disclosure) but no runtime dependencies.
|
||||
|
||||
The marketplace is a **catalog**, not a suite. Don't fork the whole repo unless you actually want to maintain everything.
|
||||
|
||||
---
|
||||
|
||||
## Versioning and stability
|
||||
|
||||
- **Semantic versioning per plugin.** Each plugin has its own `CHANGELOG.md` and version number.
|
||||
- **Breaking changes happen.** I bump the major version when they do, but I don't run an LTS branch.
|
||||
- **Pin your version.** If stability matters more than features, install a specific version and stay there until you choose to upgrade.
|
||||
|
||||
---
|
||||
|
||||
## Public sector adoption notes
|
||||
|
||||
For Norwegian etater specifically:
|
||||
|
||||
- **DPIA-relevant data flows are documented in the relevant plugin README where applicable.** Read them before installation.
|
||||
- **No data leaves your machine** beyond what Claude Code itself sends to Anthropic. The plugins themselves do not call external services unless you configure an integration.
|
||||
- **Drøftingsplikt and ledelsesansvar** are not replaced by these tools. The `okr` plugin coaches; it does not decide. The `ms-ai-architect` plugin advises; it does not approve.
|
||||
- **Choose your Claude deployment carefully.** claude.ai vs. API direct vs. Bedrock in EU region have different data residency profiles. The plugins don't choose for you.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT for all plugins in this marketplace. See each plugin's `LICENSE` file.
|
||||
349
README.md
349
README.md
|
|
@ -1,27 +1,46 @@
|
|||
# Config-Audit Plugin for Claude Code
|
||||
# config-audit
|
||||
|
||||
> Know if your configuration is correct. Find what could improve it. Fix it automatically.
|
||||
Multi-agent workflow for analyzing, reporting, and optimizing Claude Code configuration across your entire machine
|
||||
|
||||
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](GOVERNANCE.md) for the full model and what upstream provides.
|
||||
Know if your configuration is correct. Find what could improve it. Fix it automatically.
|
||||
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
|
||||
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md) for the full model and what upstream provides.
|
||||
|
||||

|
||||
*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.*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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. 13 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, and cross-plugin collision detection. Zero external dependencies.
|
||||
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 16 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, output-style, and always-loaded agent-listing-budget detection. Zero external dependencies.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
|
||||
claude plugin install config-audit@ktg-plugin-marketplace
|
||||
```
|
||||
|
||||
Or enable directly in `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"config-audit@ktg-plugin-marketplace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed
|
||||
- Node.js 18+ — the scanners also run standalone from a clone, with no other dependencies
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What's New in v5.4.0](#whats-new-in-v540)
|
||||
- [What Is This?](#what-is-this)
|
||||
- [The Configuration Problem](#the-configuration-problem)
|
||||
- [Quick Start](#quick-start)
|
||||
|
|
@ -39,44 +58,20 @@ A Claude Code plugin that checks configuration health, suggests context-aware im
|
|||
- [Testing](#testing)
|
||||
- [Gotchas](#gotchas)
|
||||
- [Data Storage & Safety Guarantees](#data-storage--safety-guarantees)
|
||||
- [What This Plugin Does Not Cover](#what-this-plugin-does-not-cover)
|
||||
- [Version History](#version-history)
|
||||
- [Non-goals](#non-goals)
|
||||
- [config-audit vs. the built-in /doctor](#config-audit-vs-the-built-in-doctor)
|
||||
- [Changelog](#changelog)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## What's New in v5.4.0
|
||||
|
||||
**Plugin-hygiene & settings-validation hardening.** Three additive findings extend the plugin and
|
||||
settings surfaces — no new scanner, so the count stays **13**:
|
||||
|
||||
- **PLH plugin-folder shadowing** (`CA-PLH-015`) — flags a `plugin.json` component-path key in the
|
||||
*replaces* set (`commands`/`agents`/`outputStyles`) that points at a custom path while the
|
||||
default folder of that name still exists, so the folder is silently ignored (dead config).
|
||||
Mirrors Claude Code's own warning in `/doctor`, `claude plugin list`, and the `/plugin` detail
|
||||
view. `skills` is excluded (it *adds to* the default scan, never shadows), as are
|
||||
`hooks`/`mcpServers`/`lspServers` (own merge rules); a custom path resolving *into* the default
|
||||
folder is not flagged.
|
||||
- **PLH `skills:`-array validation** (`CA-PLH-016`) — validates each `plugin.json` `skills` entry
|
||||
(string or array) resolves to an existing directory inside the plugin root; flags `non-string`,
|
||||
`escapes-root`, `not-found`, and `not-a-directory` entries. Mirrors `claude plugin validate`.
|
||||
- **SET `autoMode` structure + dead-config** — checks that `autoMode` is an object whose only keys
|
||||
are `environment`/`allow`/`soft_deny`/`hard_deny`, each a string array (the literal `"$defaults"`
|
||||
is valid); unknown sub-keys and wrong types are flagged (medium). Separately, `autoMode` placed
|
||||
in **shared** project settings (`.claude/settings.json`) is flagged as dead config (low) —
|
||||
Claude Code's classifier does not read it there.
|
||||
|
||||
All three extend existing PLH and SET scanners. `--json` and `--raw` output remain byte-stable.
|
||||
|
||||
---
|
||||
|
||||
## What Is This?
|
||||
|
||||
Claude Code reads instructions from at least 7 different file types across multiple scopes: `CLAUDE.md`, `settings.json`, `.claude/rules/`, `hooks.json`, `.mcp.json`, `.claudeignore`, and `settings.local.json`. Each can exist at project level, user level, or both. Plugins add more. The system is powerful — but nobody tells you what you're using wrong, what you're missing, or what's silently conflicting.
|
||||
|
||||
This plugin provides three layers of configuration intelligence:
|
||||
|
||||
- **Health** — 13 deterministic scanners verify correctness across every configuration file, catching broken imports, deprecated settings, conflicting rules, format errors, permission contradictions, prompt-cache token waste, cache-prefix instability, dead tool grants, and cross-plugin skill collisions
|
||||
- **Health** — 16 deterministic scanners verify correctness across every configuration file, catching broken imports, deprecated settings, conflicting rules, format errors, permission contradictions, prompt-cache token waste, cache-prefix instability, dead tool grants, cross-plugin skill collisions, output styles that silently strip Claude Code's coding instructions, an oversized always-loaded agent listing, and procedures in CLAUDE.md that would fit better as a skill
|
||||
- **Opportunities** — context-aware recommendations for Claude Code features that could benefit your specific project, backed by Anthropic's official guidance
|
||||
- **Action** — auto-fix with mandatory backups, syntax validation, rollback support, and a human-in-the-loop workflow for anything non-trivial
|
||||
|
||||
|
|
@ -154,28 +149,7 @@ Also **Grade A** — with only 3 opportunities remaining. This project has CLAUD
|
|||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed
|
||||
- Node.js 18+ (for standalone CLI tools)
|
||||
|
||||
### Installation
|
||||
|
||||
Add the marketplace and browse plugins with `/plugin`:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
|
||||
```
|
||||
|
||||
Or enable directly in `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"config-audit@ktg-plugin-marketplace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
Install first — see [Install](#install) above.
|
||||
|
||||
### First Scan
|
||||
|
||||
|
|
@ -198,7 +172,7 @@ The CLI tools work standalone — no Claude Code session needed, just Node.js 18
|
|||
|
||||
Most configuration tools stop at "is it valid?" Config-audit goes further: **what could improve your setup, and is it relevant to your project?**
|
||||
|
||||
The feature opportunity scanner checks 25 dimensions and groups recommendations by impact:
|
||||
The feature opportunity scanner checks 24 dimensions and groups recommendations by impact:
|
||||
|
||||
| Impact Level | Focus | Examples |
|
||||
|--------------|-------|---------|
|
||||
|
|
@ -206,9 +180,20 @@ The feature opportunity scanner checks 25 dimensions and groups recommendations
|
|||
| **Worth Considering** | Workflow efficiency | Path-scoped rules, modular `@imports`, custom agents |
|
||||
| **Explore** | Nice-to-have | Keybindings, status line, output styles, agent teams |
|
||||
|
||||
Alongside the dimensions sit four **conditional levers** — recommendations that only make
|
||||
sense under a measured condition, so they stay silent otherwise. One of them is model/effort
|
||||
routing (`CA-GAP-028`): when you have your own subagents and *not one* of them names a
|
||||
`model:` or an `effort:`, every delegated task runs on the main conversation's model, because
|
||||
`model` defaults to `inherit`. It fires only when you actually have agents, and goes quiet the
|
||||
moment any of them routes either axis — so a deliberate everything-on-one-model setup is not
|
||||
nagged. Writing `model: inherit` out in full does not count as routing; it is the default
|
||||
spelled out.
|
||||
|
||||
Each recommendation is **context-aware** — it considers what your project actually contains. A solo TypeScript project gets different suggestions than a team Python monorepo. Recommendations include *why* (backed by Anthropic's official guidance) and *how* (concrete steps).
|
||||
|
||||
Run `/config-audit feature-gap` to see what's relevant to your project.
|
||||
Run `/config-audit feature-gap` to see what's relevant to your project. To see what each agent
|
||||
currently runs on, `/config-audit whats-active` lists `model` and `effort` per agent, and
|
||||
`/config-audit manifest` shows them on the agent rows.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -275,9 +260,13 @@ Your team configuration changes over time. Track it:
|
|||
|---------|-------------|
|
||||
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
|
||||
| `/config-audit posture` | Quick health scorecard: A-F grades across 10 quality areas (incl. Token Efficiency, Plugin Hygiene) |
|
||||
| `/config-audit tokens` | prompt-cache-aware token hotspots — ranked by estimated waste; 6 patterns + optional `--accurate-tokens` API calibration |
|
||||
| `/config-audit manifest` | Ranked table of every system-prompt token source (CLAUDE.md, plugins, skills, MCP, hooks) sorted by estimated tokens |
|
||||
| `/config-audit tokens` | prompt-cache-aware token hotspots — ranked by estimated waste, each tagged with its load pattern (always / on-demand / external); 8 patterns + optional `--accurate-tokens` API calibration. **Cache-aware:** stale `~/.claude/plugins/cache` versions (superseded installs that load on zero turns) are excluded from the ranking by default — only each plugin's active version is counted; `--no-exclude-cache` restores the full walk. Stale versions surface as a separate **Dead config** disk-cleanup finding |
|
||||
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens — each tagged with its **load pattern** (always-loaded / on-demand / external) plus an **always-loaded subtotal** ("≈X tokens enter context every turn before you type"). Component-level: no coarse plugin roll-up (it would double-count) |
|
||||
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
|
||||
| `/config-audit optimize` | Optimization lens (mechanism-fit): config that works but fits a better mechanism — procedure→skill, lifecycle→hook, unscoped path→rule, "never"→permission. Hybrid motor (deterministic pre-filter + opus precision gate), every finding cites a best-practices-register rule |
|
||||
| `/config-audit optimize --subtract` | **Subtraction lens** — the inverse question no other command asks: what no longer earns its always-loaded rent? Ranks CLAUDE.md blocks that correct general model *behaviour* rather than stating a local fact, split into **dead** (never missed) and **earned** (returns if the model stumbles), with the token payoff (`BP-SUB-001`). **Load-bearing local facts are excluded deterministically before the judge sees anything** — remotes, versions, paths, filenames, policy invariants and unresolvable entity names are never candidates, and an ordered list is treated as a contract. Opt-in and proposes only; add `--apply` to execute the removals you pick. Pair with `--global` to reach the user-level CLAUDE.md, where the always-loaded cost actually sits |
|
||||
| `/config-audit optimize --subtract --apply` | **Execute approved removals.** You pick which blocks go by number; nothing is inferred. Every removal is checked against the file as it reads *now* — an approval that no longer matches is refused rather than applied to whatever moved into those lines — and the floor is re-asserted at write time, so a load-bearing block cannot be removed even by a hand-built approval. A dry run always precedes the write, the backup's manifest is verified to cover the file being written before a byte changes, and `/config-audit rollback` restores it. A removal targeting your machine-wide `~/.claude/CLAUDE.md` is **refused until you approve that scope explicitly** — it costs, and saves, in every project on every turn |
|
||||
| `/config-audit optimize --subtract --for-model <name>` | **Model-scoped subtraction.** Some instructions are dead weight only for a *particular* model: Anthropic documents that Claude Opus 5 verifies its own work and **over-verifies** when told to double-check or to delegate verification to a subagent, adding token cost with no quality gain (`BP-PROMPT-001`). This flag annotates the `--subtract` candidates that carry a reflexive or delegated verification target — "double-check your own work", "use a subagent to verify" — while leaving *external* verification ("check the CI status") untagged. It **never widens the candidate set**, and there is deliberately no auto-detection: a CLAUDE.md has no frontmatter and no resolvable target model, and the same file is read by whichever model the next session runs — so the citation is reported as **conditional**, and an unrecognized model name is reported as unrecognized rather than as a silent zero |
|
||||
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
|
||||
| `/config-audit rollback` | Restore configuration from a previous backup |
|
||||
| `/config-audit plan` | Generate prioritized action plan from audit findings |
|
||||
|
|
@ -291,6 +280,8 @@ Your team configuration changes over time. Track it:
|
|||
| `/config-audit drift` | Compare current config against a saved baseline |
|
||||
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
||||
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
|
||||
| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — flag stale entries (sources older than ~90d) + poll for new/changed Claude Code practices; **human-approved writes only** (Verifiseringsplikt). Deterministic stale core + web candidate poll |
|
||||
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity + a **machine-wide always-loaded token bill** (`refresh-tokens` live cross-repo sweep — the shared global layer counted once + per-repo deltas, ranked "most expensive repos") + a single **cross-repo prioritized backlog** to pick from (severity-weighted) + plan **export** (drop a planned repo's plan into its own `docs/`), resumable across sessions; **human-approved writes only** (read-only report + deterministic write/export CLIs). Execution reuses the existing `/config-audit implement` + `rollback` |
|
||||
| `/config-audit discover` | Run discovery phase only |
|
||||
| `/config-audit analyze` | Run analysis phase only |
|
||||
| `/config-audit interview` | Set preferences for action plan _(optional)_ |
|
||||
|
|
@ -301,29 +292,75 @@ Your team configuration changes over time. Track it:
|
|||
|
||||
By default, `/config-audit` auto-detects scope from your git context. Override with: `/config-audit current`, `/config-audit repo`, `/config-audit home`, `/config-audit full`. Use `--delta` for incremental scanning (only new/changed findings).
|
||||
|
||||
### Where a write is allowed to land
|
||||
|
||||
Reading is machine-wide; **writing is not**. Every write target is classified against the project
|
||||
you are standing in, and the classification — not the command doing the asking — decides how
|
||||
strong the gate is. A change inside your project applies normally. A write into a *different*
|
||||
project is disclosed and then applied, because some commands (like `campaign export`) are
|
||||
cross-repo by design. A write to your machine-wide `~/.claude` configuration, or to a path in no
|
||||
project at all, is **withheld until you approve that scope explicitly** — it costs, and saves, in
|
||||
every project on every turn.
|
||||
|
||||
That gate now runs inside the engines, not only in the command prose that wraps them. It covers
|
||||
`/config-audit fix`, `/config-audit rollback`, `campaign export`, `--save-baseline`, and
|
||||
`optimize --subtract --apply`. For rollback that is now literal: the restore runs through
|
||||
`scanners/rollback-cli.mjs`, which verifies each file's checksum before and after writing it, so
|
||||
"restored and verified" is something the run reports rather than something the output template
|
||||
says. A restore that would land outside your project is refused with nothing written. When a run is withheld, nothing has been written: you get the
|
||||
reason and the affected paths, and you re-run with your approval (`--approve-scope` on the CLIs).
|
||||
Approval is always a separate act — classifying a target is not approving it. The plugin's own
|
||||
bookkeeping (backups, session state, ledgers, and the report file you named with `--output-file`)
|
||||
is deliberately exempt: a gate that fired on every run would be switched off, and then it would
|
||||
guard nothing.
|
||||
|
||||
---
|
||||
|
||||
## Deterministic Scanners
|
||||
|
||||
13 Node.js scanners that perform structural analysis an LLM cannot reliably do: schema validation, circular reference detection, import resolution, conflict detection across scopes, prompt-cache-aware token-cost analysis, cache-prefix stability, dead-tool detection, and cross-plugin skill collisions. Plus a standalone plugin-health scanner. Zero external dependencies.
|
||||
15 Node.js scanners that perform structural analysis an LLM cannot reliably do: schema validation, circular reference detection, import resolution, conflict detection across scopes, prompt-cache-aware token-cost analysis, cache-prefix stability, dead-tool detection, cross-plugin skill collisions, output-style validation, and a best-practice optimization lens (mechanism-fit). Plus a standalone plugin-health scanner. Zero external dependencies.
|
||||
|
||||
**Why deterministic?** LLMs are powerful at understanding intent and context. But they cannot reliably validate JSON schemas, detect circular `@import` chains, or catch that your global `settings.json` contradicts your project-level one. These scanners fill that gap — fast, repeatable, and zero false positives on structural issues.
|
||||
|
||||
| Scanner | Prefix | What It Catches |
|
||||
|---------|--------|-----------------|
|
||||
| `claude-md-linter.mjs` | CML | Oversized files (line count **plus** a context-window-scaled char budget mirroring Claude Code's ~40.0k-char startup warning), missing sections, broken @imports, duplicates, stale TODOs |
|
||||
| `claude-md-linter.mjs` | CML | Oversized files (line count **plus** a context-window-scaled char budget mirroring Claude Code's ~40.0k-char startup warning), missing sections, broken @imports, duplicates, stale TODOs, and **dead prose references** (`CA-CML-013`) — backtick-quoted relative paths in prose that resolve to nothing, next to the file or from the scan root |
|
||||
| `settings-validator.mjs` | SET | Schema violations, unknown/deprecated keys, type mismatches, permission issues |
|
||||
| `hook-validator.mjs` | HKV | Invalid format, missing scripts, wrong event names, timeout risks |
|
||||
| `hook-validator.mjs` | HKV | Invalid format, missing scripts, wrong event names, timeout risks, verbose-stdout scripts, and a low-precision **advisory** (info) when a hook injects un-grepped command output into `hookSpecificOutput.additionalContext` — that payload enters context on every fire (plain stdout does not) |
|
||||
| `rules-validator.mjs` | RUL | Bad glob patterns, orphaned rules, deprecated fields, unscoped rules |
|
||||
| `mcp-config-validator.mjs` | MCP | Invalid server types, exposed env vars, unknown fields |
|
||||
| `import-resolver.mjs` | IMP | Broken @imports, circular references, deep chains, tilde path issues |
|
||||
| `conflict-detector.mjs` | CNF | Settings contradictions across scopes, permission conflicts, hook duplicates |
|
||||
| `feature-gap-scanner.mjs` | GAP | 25 feature checks shown as opportunities, not grades — plus a conditional `disableBundledSkills` recommendation when the active skill listing is over budget |
|
||||
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascades, bloated skill descriptions, MCP tool-schema budget |
|
||||
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 of the CLAUDE.md cascade — beyond the cache-prefix window but still re-loaded every turn |
|
||||
| `feature-gap-scanner.mjs` | GAP | 24 feature checks shown as opportunities, not grades — plus four conditional levers: a `disableBundledSkills` recommendation when the active skill listing is over budget, a **CLI-over-MCP** lever when tool schemas are forced upfront, a **filter-before-Claude-reads** lever when a hook injects unfiltered output into `additionalContext` (companion to the HKV advisory; cites the documented `filter-test-output.sh` pattern), and **agent model/effort routing** (`CA-GAP-028`) when authored subagents exist and none pins either axis (cites `BP-MODEL-001/002`). Levers are not dimensions: they stay out of the utilization denominators |
|
||||
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascades, bloated skill descriptions, MCP tool-schema budget, and stale `~/.claude/plugins/cache` versions (disk-cleanup, zero live-context impact) — cache-aware ranking excludes superseded plugin versions by default (`--no-exclude-cache` to include) |
|
||||
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 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 |
|
||||
| `collision-scanner.mjs` | COL | Cross-plugin skill name collisions; user-vs-plugin overlaps |
|
||||
| `skill-listing-scanner.mjs` | SKL | Skill-listing token budget: a single skill description over the ~1,536-char listing cap Claude Code truncates (`CA-SKL-001`), and the summed active-skill descriptions exceeding the ~2%-of-context listing budget (`CA-SKL-002`) |
|
||||
| `skill-listing-scanner.mjs` | SKL | Skill-listing token budget: a single skill description over the ~1,536-char listing cap Claude Code truncates (`CA-SKL-001`), the summed active-skill descriptions exceeding the ~2%-of-context listing budget (`CA-SKL-002`), and an oversized SKILL.md **body** over ~5,000 tokens (`CA-SKL-003`, low — on-demand cost: the body loads only when the skill runs, not every turn; recommends supporting-file split + `context: fork`). The `CA-SKL-002` (and CML char-budget) findings accept `--context-window <n>` to calibrate to your real window instead of the conservative 200k anchor (`--context-window auto` keeps the anchor but downgrades to advisory) |
|
||||
| `output-style-scanner.mjs` | OST | Output-style validation: a custom (user/project) style missing `keep-coding-instructions: true` that silently strips built-in software-engineering instructions (`CA-OST-001`), a plugin style with `force-for-plugin: true` overriding the user's selected `outputStyle` (`CA-OST-002`), and a settings `outputStyle` resolving to no built-in or custom style — dead config (`CA-OST-003`) |
|
||||
| `optimization-lens-scanner.mjs` | OPT | Optimization lens (mechanism-fit): a multi-step procedure in CLAUDE.md that would fit better as a skill (`CA-OPT-001`) — reads the machine-readable best-practices register, framed as an opportunity, not a failure. The deterministic half of the lens; prose-judgment cases (lifecycle→hook, unscoped path→rule, "never"→permission) are judged by the opus `optimization-lens-agent` via `/config-audit optimize` |
|
||||
| `agent-listing-scanner.mjs` | AGT | Always-loaded agent-listing budget: a per-agent description over the soft bloat cap (`CA-AGT-001`, advisory) and the summed active-agent name+description listing — re-sent every turn — exceeding the listing budget (`CA-AGT-002`). Both LOW and explicitly **inferred / upper-bound**: the agent-listing mechanism is undocumented, so the evidence discloses the estimate and heuristic budget rather than overstating certainty |
|
||||
|
||||
> **Dead prose references — a check designed around what it stays silent about.**
|
||||
> `import-resolver` follows `@import` targets; a path written in ordinary prose was
|
||||
> checked by nothing, so `CA-CML-013` (low) resolves those too. A reference has to be
|
||||
> unambiguously path-shaped to qualify: a separator, plus either a trailing `/` or a
|
||||
> known file extension, resolved both next to the CLAUDE.md and from the scan root —
|
||||
> a nested file may legitimately write repo-root-relative paths. One finding per file,
|
||||
> carrying the count and the first few paths.
|
||||
>
|
||||
> The design work is the silence list, and every entry on it was measured against 407
|
||||
> real CLAUDE.md files rather than argued for. Left alone: commands (`npm test`), URLs,
|
||||
> globs and placeholders (`CA-GAP-*`, `${CLAUDE_PLUGIN_ROOT}/…`), absolute and `~/`
|
||||
> paths, config keys and flags (`model:`, `--raw`), bare filenames (`README.md` — a
|
||||
> filename in prose is a concept, and admitting them tripled the output with name-drops
|
||||
> of tools that exist elsewhere), org/repo slugs and package names
|
||||
> (`ktg/some-repo`, `@anthropic-ai/claude-agent-sdk`), bare folder names (`docs/`,
|
||||
> `open/` — the same reasoning one level up), fenced code blocks, and anything resolving
|
||||
> outside the scanned tree. That last rule is not fussiness: without it a `../../../../etc/passwd`
|
||||
> resolved to the real file and silenced its own finding. A path carrying a trailing
|
||||
> `:54-56` locator is a known v1 miss — for a precision-first check, silence is the safe
|
||||
> failure direction.
|
||||
|
||||
> **Cross-scanner remediation — diagnosis meets the fix.** SKL diagnoses an over-budget
|
||||
> skill listing (`CA-SKL-002`); GAP prescribes the remedy. When the active skill listing
|
||||
|
|
@ -410,9 +447,10 @@ All tools work standalone — no Claude Code session needed:
|
|||
| **Posture** | `node scanners/posture.mjs <path> [--json] [--global] [--full-machine] [--output-file path]` |
|
||||
| **Fix** | `node scanners/fix-cli.mjs <path> [--apply] [--json] [--global]` |
|
||||
| **Drift** | `node scanners/drift-cli.mjs <path> [--save] [--baseline name] [--json]` |
|
||||
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
|
||||
| **Manifest** | `node scanners/manifest.mjs <path> [--json]` — ranked system-prompt source table |
|
||||
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--no-exclude-cache] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
|
||||
| **Manifest** | `node scanners/manifest.mjs <path> [--json]` — ranked component-level source table with per-source load pattern + always-loaded subtotal |
|
||||
| **What's active** | `node scanners/whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` |
|
||||
| **Backup / restore** | `node scanners/rollback-cli.mjs [--list] [--restore <id>] [--delete <id>] [--create --target <path> …] [--created <path>] [--dry-run] [--approve-scope] [--repo <root>] [--json] [--output-file path]` |
|
||||
| **Self-audit** | `node scanners/self-audit.mjs [--json] [--fix] [--check-readme]` |
|
||||
| **Full scan** | `node scanners/scan-orchestrator.mjs <path> [--global] [--full-machine] [--no-suppress]` |
|
||||
|
||||
|
|
@ -430,6 +468,7 @@ Six specialized agents collaborate through the audit workflow, each matched to a
|
|||
| **implementer-agent** | Sonnet | Change execution with mandatory backups | Read, Write, Edit, Bash, Glob |
|
||||
| **verifier-agent** | Sonnet | Post-implementation verification | Read, Glob, Grep |
|
||||
| **feature-gap-agent** | Opus | Context-aware feature recommendations | Read, Glob, Grep, Write |
|
||||
| **optimization-lens-agent** | Opus | Mechanism-fit precision gate — judges prose-judgment lens candidates (lifecycle→hook, path→rule, never→permission), cites the best-practices register | Read, Glob, Grep, Write |
|
||||
|
||||
### Orchestration Flow
|
||||
|
||||
|
|
@ -483,7 +522,12 @@ Skills activate automatically when your question matches their trigger patterns.
|
|||
|
||||
### Finding ID Format
|
||||
|
||||
Every finding has a unique ID: `CA-{SCANNER}-{NNN}` — where `{SCANNER}` is the scanner prefix (see table above) and `{NNN}` is a sequential number. Examples: `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`.
|
||||
Every finding carries an ID of the form `CA-{SCANNER}-{NNN}` — where `{SCANNER}` is the scanner prefix (see table above) and `{NNN}` **names the check**, not the finding's position in a run. Examples: `CA-CML-001`, `CA-SET-003`, `CA-HKV-002`, `CA-RUL-005`.
|
||||
|
||||
Two consequences worth knowing before you pin one:
|
||||
|
||||
- **The same check always has the same ID.** It does not move when you fix an unrelated finding, when a check stops firing, or when a later release adds or retires one. That is what makes an ID safe to write into `.config-audit-ignore`. A number withdrawn from service is never reissued — a suppression naming a retired check goes dead rather than quietly matching a different one.
|
||||
- **An ID is not unique per finding.** One check failing in three files produces three findings that share an ID; `file` and `line` tell them apart. Suppressing by ID suppresses the check everywhere in scope.
|
||||
|
||||
### Suppression
|
||||
|
||||
|
|
@ -502,6 +546,8 @@ CA-PLH-*
|
|||
|
||||
Suppressed findings are tracked in the scan envelope's `suppressed_findings` array for audit trail — nothing is silently hidden. Use `--no-suppress` to see everything.
|
||||
|
||||
A pattern that names no known check is reported back in the envelope's `unknown_suppressions` array, so a pin that has gone stale (a typo, or a check retired in a later release) is visible instead of silently protecting nothing. Scanner-wide globs like `CA-GAP-*` are validated only down to the prefix, which is why a glob is the durable way to pin a whole scanner.
|
||||
|
||||
---
|
||||
|
||||
## Examples & Self-Audit
|
||||
|
|
@ -523,7 +569,7 @@ node scanners/posture.mjs examples/optimal-setup/
|
|||
|
||||
### Self-Audit: Scanning the Scanner
|
||||
|
||||
The plugin runs all 13 scanners + the standalone plugin-health scanner on itself via `self-audit.mjs`. Test fixtures and example files are automatically excluded from scoring — a configuration plugin that ships deliberately broken examples shouldn't fail its own audit. Use `--check-readme` to verify badge counts are in sync with the filesystem.
|
||||
The plugin runs all 16 scanners + the standalone plugin-health scanner on itself via `self-audit.mjs`. Test fixtures and example files are automatically excluded from scoring — a configuration plugin that ships deliberately broken examples shouldn't fail its own audit. Use `--check-readme` to verify badge counts are in sync with the filesystem.
|
||||
|
||||
```bash
|
||||
node scanners/self-audit.mjs
|
||||
|
|
@ -550,6 +596,7 @@ Shared modules used by all scanners — useful if you're reading the source or e
|
|||
| `suppression.mjs` | `.config-audit-ignore` parsing, finding suppression, audit trail |
|
||||
| `active-config-reader.mjs` | Read-only inventory of plugins/skills/MCP/hooks/CLAUDE.md cascade with token estimates |
|
||||
| `tokenizer-api.mjs` | Anthropic `count_tokens` wrapper for `--accurate-tokens` (v5 N5); 5s timeout, 429 backoff, key masking |
|
||||
| `write-scope.mjs` | Classifies a write target against the current project (`SCOPE_CLASSES`, `classifyWriteTarget()`); one source for class, gate strength and wording |
|
||||
|
||||
### Action Engines
|
||||
|
||||
|
|
@ -559,9 +606,11 @@ Shared modules used by all scanners — useful if you're reading the source or e
|
|||
| `rollback-engine.mjs` | `listBackups()`, `restoreBackup()`, `deleteBackup()` |
|
||||
| `fix-cli.mjs` | CLI entry point for auto-fix |
|
||||
| `drift-cli.mjs` | CLI entry point for drift detection |
|
||||
| `manifest.mjs` | CLI: ranked system-prompt source table (v5 N2) |
|
||||
| `manifest.mjs` | CLI: ranked component-level source table w/ load-pattern accounting (v5 N2; v5.6 B) |
|
||||
| `whats-active.mjs` | CLI: read-only active-config inventory (v3.1.0+) |
|
||||
| `token-hotspots-cli.mjs` | CLI: token hotspots ranking with optional `--accurate-tokens` |
|
||||
| `write-scope-cli.mjs` | CLI: classify write targets before an approval surface (`--target`, repeatable) |
|
||||
| `rollback-cli.mjs` | CLI: the runnable entry to backup and restore (`--list` / `--create` / `--restore` / `--delete`). Both pipelines back up through this one code path, so the manifest format is never written or read by hand |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -580,6 +629,73 @@ Reference documents that inform the feature-gap agent and context-aware recommen
|
|||
| `prompt-cache-patterns.md` | Token-cost dynamics (prompt-cache patterns) — patterns powering the TOK scanner |
|
||||
| `cache-telemetry-recipe.md` | `jq` recipe for verifying prompt-cache hit rate from session transcripts |
|
||||
|
||||
**Machine-readable register (`best-practices.json`).** Alongside the human-readable documents
|
||||
above, `knowledge/best-practices.json` is a provenance-stamped, schema-validated register of
|
||||
best-practice claims and mechanism-fit rules — each entry carries `source.url`, a `verified`
|
||||
date, and a `confidence`. It is the source of truth for the optimization lens (OPT scanner +
|
||||
`/config-audit optimize`); the Markdown files remain the human-readable mirror. Loaded and validated by
|
||||
`scanners/lib/best-practices-register.mjs` (zero-dependency, native JSON). See
|
||||
`docs/v5.7-optimization-lens-plan.md`.
|
||||
|
||||
### The subtraction floor
|
||||
|
||||
`optimize --subtract` is the only lens that proposes *removing* configuration, so it carries a
|
||||
guarantee the others do not need: **a load-bearing block is never a candidate.** Precision here
|
||||
is asymmetric — a missed dead line costs a few tokens per turn, while a deleted load-bearing
|
||||
line costs a wrong remote or a broken script — so the floor is decided in code
|
||||
(`scanners/lib/floor-exclusion.mjs`), before the opus judge sees anything, rather than being
|
||||
left to prose judgement.
|
||||
|
||||
A block is floored when it carries an underivable local literal (inline code span, rooted path,
|
||||
domain, concrete filename, version pin), when it states a policy invariant (secrets,
|
||||
credentials, production, prompt-injection boundaries — floor *by decision*, not by
|
||||
classification), or when it names a capitalized entity the mechanism cannot resolve without a
|
||||
dictionary. That last rule is a deliberate conservative default: it declines to decide and
|
||||
keeps the block, paying in recall rather than risk.
|
||||
|
||||
### Model-scoped candidates (`--for-model`)
|
||||
|
||||
Some instructions are dead weight only for a *particular* model. Anthropic documents that
|
||||
Claude Opus 5 verifies its own work, and that explicit instructions to double-check or to
|
||||
delegate verification to a subagent cause **over-verification** — token cost with no gain in
|
||||
quality. `--subtract --for-model opus-5` annotates the subtraction candidates that carry a
|
||||
reflexive target ("double-check your own work", "før du svarer") or a delegated one ("verify
|
||||
with a subagent"), while leaving *external* verification ("check the CI status") untagged.
|
||||
|
||||
It is an annotation, never a detector: it tags a subset of what `--subtract` already found and
|
||||
can never widen the candidate set. There is deliberately **no auto-detection** — a CLAUDE.md
|
||||
has no frontmatter and no resolvable target model, and the same file is read by whichever model
|
||||
the next session happens to run. That is also why the citation is reported as *conditional*
|
||||
rather than as a settled fact about the file, both in the report and in the approval listing
|
||||
shown before anything is written.
|
||||
|
||||
Precision comes from requiring the target, not from a narrow verb list. Measured across 409
|
||||
real CLAUDE.md files: 392 subtraction candidates, **31 carry a verify verb, and 0 also carry a
|
||||
reflexive or delegated target** — a zero confirmed by two independent raw-text greps, so it is
|
||||
the corpus rather than an over-narrow rule. Those 31 verb-only blocks are precisely the false
|
||||
positives a looser version would have produced. A model name the register does not cover is
|
||||
reported as **unrecognized** rather than as a bare zero, so a typo never reads as "your config
|
||||
is already clean".
|
||||
|
||||
The write half (`--apply`) keeps the same asymmetry. It is not a `fix` action and not a
|
||||
`plan`/`implement` step, and both exclusions are measurements rather than preferences: the
|
||||
subtraction axis never enters the orchestrated envelope, so `fix`'s re-scan verification would
|
||||
report every removal as verified whether or not it happened — a success-shaped no-op — and the
|
||||
findings pipeline expects a finding code, which by invariant names a deterministic check, not a
|
||||
prose judgement. `scanners/lib/subtraction-write.mjs` owns the execution instead, and it
|
||||
re-asserts the floor rather than trusting that the pre-filter already did: the veto stays where
|
||||
it is, and is simply repeated as the last red line before the delete.
|
||||
|
||||
Granularity is the **leaf block** — one list item including its wrapped continuation lines, or
|
||||
one paragraph — with two structural exceptions: a paragraph ending in `:` merges with the list
|
||||
it introduces, and an *ordered* list is treated as a contract whose steps inherit floor from
|
||||
any sibling. Unordered lists deliberately do not inherit, so a load-bearing bullet and a
|
||||
disposable one can coexist in the same list. Measured against a hand-built ground truth over
|
||||
a real 250-line CLAUDE.md (48 classified blocks, 19 of them genuinely ambiguous, ~65 % floor):
|
||||
**zero load-bearing blocks proposed**, 11 of 18 deletable line-ranges surfaced, ≈18 % of an
|
||||
always-loaded file. On a well-maintained config this axis is mostly a no-op — which is itself
|
||||
the finding, and the reason precision-over-recall is the only defensible tuning.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
|
@ -588,7 +704,7 @@ Reference documents that inform the feature-gap agent and context-aware recommen
|
|||
node --test 'tests/**/*.test.mjs'
|
||||
```
|
||||
|
||||
635 tests across 36 test files (12 lib + 23 scanner + 1 hook). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
|
||||
That one command runs the whole suite from a clean clone: **1477 tests, 370 suites** (measured 2026-08-03, all passing). Nothing runs it automatically — there is no CI in this organisation, so the command above is the verification, not a badge. Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -631,22 +747,89 @@ This plugin is cautious by design — configuration files are important, and a b
|
|||
| **Verification pass** | A separate agent confirms changes actually work |
|
||||
| **Human-in-the-loop** | You approve the plan before anything is implemented |
|
||||
| **Post-edit guard** | Hook blocks the session if a new critical/high finding is introduced |
|
||||
| **Scope disclosed before every write** | Each write target is classified against the project you are in, and the approval surface says when a change leaves it |
|
||||
|
||||
### Writes That Leave Your Project
|
||||
|
||||
config-audit reads configuration across projects and machine-wide, so some of what
|
||||
it proposes does not land where you are standing. A count of files cannot tell those
|
||||
cases apart: a plan that edits `~/.claude/CLAUDE.md` and one that edits your
|
||||
project's own `CLAUDE.md` are both "1 file".
|
||||
|
||||
Every write target is therefore classified before you are asked to approve it, and
|
||||
the class — not the command — decides how strong the gate is:
|
||||
|
||||
| Where the write lands | What happens |
|
||||
|---|---|
|
||||
| Inside the project you are working in | No extra gate; the usual confirmation applies |
|
||||
| config-audit's own session state and backups | No extra gate; this is the plugin's bookkeeping, not your configuration |
|
||||
| Your machine-wide Claude configuration (`~/.claude`) | Stated plainly, and it needs an explicit go-ahead — a change here affects every project you open |
|
||||
| A different project | Stated plainly, including that directories will be created there. `campaign export` does this deliberately, so this is disclosure, not refusal |
|
||||
| Anywhere else | Stated plainly, and it needs an explicit go-ahead |
|
||||
|
||||
Where a machine-wide or cross-project write is involved, the safe option is listed
|
||||
first — the default is never "proceed".
|
||||
|
||||
---
|
||||
|
||||
## What This Plugin Does Not Cover
|
||||
## Non-goals
|
||||
|
||||
- **Runtime behavior** — this plugin audits configuration files, not what Claude actually does at runtime. For runtime defense, see [claude-code-llm-security](https://git.fromaitochitta.com/open/claude-code-llm-security)
|
||||
- **Runtime behavior** — this plugin audits configuration files, not what Claude actually does at runtime. For runtime defense, see [llm-security](https://git.fromaitochitta.com/open/llm-security)
|
||||
- **Secret scanning** — config-audit checks for structural issues, not leaked credentials. Use llm-security for secret detection
|
||||
- **Custom scanner rules** — scanners check against known Claude Code configuration schemas. Custom rule definitions are not supported
|
||||
- **Remote/team configuration** — managed settings, SSO-provisioned config, and organization-level policies are detected as gaps but not managed
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
## config-audit vs. the built-in /doctor
|
||||
|
||||
Claude Code ships `/doctor` (alias `/checkup`, v2.1.205+): an agent-driven setup checkup that
|
||||
diagnoses and — with your confirmation — fixes issues. The overlap with config-audit was
|
||||
measured (2026-08-03, CC 2.1.220, prediction-before-measurement protocol), and the two tools
|
||||
divide cleanly:
|
||||
|
||||
| | Built-in `/doctor` | config-audit |
|
||||
|---|---|---|
|
||||
| Engine | Model-driven judgment, one machine, one run | Deterministic scanners, byte-stable, reproducible |
|
||||
| Cost | A full agent session per run (quota) | Free, seconds, scriptable/CI-able |
|
||||
| Scope | Current setup; trims **checked-in** CLAUDE.md only | All scopes incl. **local/private** files, plus cross-repo `campaign` |
|
||||
| Evidence | **Usage telemetry** (transcripts, lifetime counters) — its unique edge | Static analysis with provenance-stamped best-practices register |
|
||||
| Memory | None between runs | Baselines (`drift`), suppressions with audit trail, backup/rollback |
|
||||
|
||||
Division of labor per area: `/doctor` parses settings — the SET scanner validates the schema
|
||||
exhaustively (unknown/deprecated keys, types, whole cascade). `/doctor` measures hook
|
||||
*latency* — the HKV scanner validates hook *correctness*. `/doctor` judges conflicts per run —
|
||||
the CNF scanner detects them deterministically. `/doctor` trims derivable content from
|
||||
checked-in CLAUDE.md — `optimize --subtract` covers **all** scopes with a coded load-bearing
|
||||
floor (`/doctor` itself refers local-file trimming to `optimize --subtract`). `/doctor`
|
||||
estimates context weight once — `tokens`/`manifest` measure it deterministically and
|
||||
cache-aware. Run `/doctor` for a usage-weighted one-shot cleanup; run config-audit for
|
||||
reproducible, all-scope, zero-quota auditing.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
Full detail in [CHANGELOG.md](CHANGELOG.md). Highlights per release:
|
||||
|
||||
| Version | Date | Highlights |
|
||||
|---------|------|-----------|
|
||||
| **6.0.0** | 2026-08-18 | "Prose is not a contract" — a MAJOR release whose subject is a *class* of defect rather than a feature area. Three sweeps (Q1, Q2, Q_AUDIT) kept surfacing the same shape: a command template stated an invariant in prose, code on the other side depended on it, and nothing checked that the two still agreed. **Breaking (`M-BUG-28`):** a finding ID's `{NNN}` names the **check**, not its emission position — so IDs are **not unique per finding** (one check failing in three files emits three findings sharing an ID) and any consumer keying on `id` alone must move to `(id, file, line)`. `finding-codes.mjs` is the single authority and **throws** on an undeclared code, with no counter fallback — a fallback lets a half-converted scanner ship IDs that look valid. **Q1 — the write gate runs in code:** `write-scope.mjs` existed for four releases while exactly *one* writer imported it and five templates paraphrased the policy; measured, 9 files under `scanners/` write to disk and 1 imported the gate. Every writer must now import it or hold an `EXEMPT` entry naming **where the bytes land** — four of them write the plugin's own bookkeeping and must stay ungated, because a gate that fires on every run gets switched off. **Q2 — the argv is checked against the CLI that receives it:** `--stale-after 30` reached its CLI as one argument under zsh, matched no flag, and the command reported "✓ all 14 entries re-verified within the last 90 days" about a threshold the user had just overridden. The probe builds argv from each template's **own text**, and proves itself per CLI by first being seen rejecting a flag that cannot exist. **Q_AUDIT — the third instance, measured not fixed:** data contracts (`state.yaml`, backup manifests) are hand-built by the model and parsed by engines knowing one frozen example; ranked R1–R9 with the **recovery path** on top — `rollback` has no CLI entry at all and runs as model prose that pre-renders "(checksum verified)". **Added:** `optimize --subtract --apply` (the subtraction axis can now remove what it proposes — validated against the ORIGINAL content, applied in **descending** line order, coverage asserted from the backup manifest before a byte changes, and the load-bearing floor re-checked in the engine so a hand-built approval cannot route around it); `--for-model <name>` (`BP-PROMPT-001`), an annotation on existing candidates that can never widen the set, reporting `recognized` separately from `matchedCount` so a typo'd model name cannot read as a clean config; a cross-repo write disclosure before approval (`M-BUG-41`); and model/effort routing as a **lever**, not a 25th GAP dimension — a dimension would move every user's utilization score. **Removed:** the `No autoMode classifier` GAP dimension as a `/doctor` duplicate (**25 → 24**); its title lived in **four** tables, not the two the removal was scoped against. **Fixed:** the CLI-argument class across all 14 CLIs, shell state assumed to survive between fenced blocks (**20 places across 9 files**), stdout discarded against a pipe, a nonexistent target path graded instead of erroring, and `drift` crashing on a moved finding humanized as if it were flat. **1752** tests, 0 failing; frozen `v5.0.0` untouched. No component-count change (scanners **16**, agents **7**, commands **21**, hooks **4**). |
|
||||
| **5.13.0** | 2026-07-31 | "Pipeline hardening" — the batch release of everything found by dogfooding the plugin against the maintainer's real machine and by walking the `analyze → plan → implement → rollback` pipeline end-to-end on a throwaway repo copy: one new lens mode plus **14 bugs** (`M-BUG-11`…`M-BUG-20`, `M-BUG-22`…`M-BUG-25`). **Added — `optimize --subtract` (`BP-SUB-001`):** the subtraction axis, asking what no longer earns its always-loaded rent. Opt-in, proposes only, and the only lens that removes config — so a **load-bearing block is never a candidate**, decided in code (`scanners/lib/floor-exclusion.mjs`) *before* the judge runs, never in prose. Verified against a hand-built ground truth written before any classifier existed: **zero load-bearing blocks proposed**, 11/18 groups, ~756 tok ≈ 18% of a ~4300-token file. **Fixed — `rollback` (`M-BUG-22/23/24/25`):** nothing agreed on where a backup lives; `listBackups()` returned 9 phantom test backups and 0 of 4 real ones, and `restoreBackup` returned `{restored:[],failed:[]}` — a **success-shaped no-op** — because `parseManifest` knew only one of the two manifest spellings in use. Canonical root now, legacy kept readable, unparseable manifests **throw**. **`M-BUG-19`:** `globToRegex` corrupted mid-pattern `/**/`, flagging live rules dead. **`M-BUG-18`/`M-BUG-20`:** the subagent harness won't write report-shaped `.md` (analyze now persists the returned report), and parallel agents clobbered the shared log with `Write` (pinned to Bash `>>`). **`M-BUG-11`/`M-BUG-13`:** `optimize` and `feature-gap` scanned vendored plugin config a user cannot act on — `optimize` candidates **454→45**, `feature-gap` **~0 (masked) → 18** opportunities. **`M-BUG-12`/`M-BUG-14`/`M-BUG-15`/`M-BUG-16`/`M-BUG-17`:** plain-language output that contradicted its own evidence (posture's `--output-file` never humanized; four finding types with no humanizer entry). Known, deliberately unfixed: `rollback` cannot delete files `implement` *created* — it now reports them (`createdNotRemoved`) instead of failing silently; automatic deletion of user files gets its own design. No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); frozen v5.0.0 untouched, SC-5 regenerated once for two humanized titles. **1398** tests (+54). |
|
||||
| **5.12.5** | 2026-06-26 | "Dogfood denoise" — a samle-release of the Fase-3 scanner false-positive batch (`M-BUG-2/6/7/8/10`, all dogfooding finds on the maintainer's real machine). Five scanners stop counting non-user / non-live config as the user's: **CNF** (`M-BUG-2`) excludes files under `.claude/plugins/` from conflict analysis (`isPluginBundled`) — installed plugins' bundled settings/hooks/fixtures are not a user-resolvable cascade (dogfood **339→0**, Conflicts was an F on pure plugin noise). **file-discovery** (`M-BUG-8`) adds `backups` to `SKIP_DIRS` — a `backups/` tree holds frozen copies, never live config (dogfood files-under-`/backups/` **36→0**, 717 live retained). **token estimator** (`M-BUG-6`) strips block-level `<!-- -->` HTML comments from CLAUDE.md sizing — CC strips them before injection, so they were never always-loaded tokens (dogfood ~3386→3301, ~85 tok). **CPS** (`M-BUG-7`) skips fenced/inline code and whitelists CC-stable path vars (`${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}`) before cache-buster matching (dogfood **5→2**). **SET** (`M-BUG-10`) typo-gates the unknown-settings-key finding — the CC schema is passthrough (verified against the 2.1.193 binary), so an unknown key is forward-compatible, not an error; it now flags only a near-miss of a known key (levenshtein ≤2), severity medium→low, +6 binary-verified `KNOWN_KEYS` (dogfood **6→0**). No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); all five are byte-stable — frozen v5.0.0 + SC-5 + default-output snapshots untouched, no re-seed (each fixture's findings are genuinely unchanged). **1344** tests (+37). |
|
||||
| **5.12.4** | 2026-06-26 | "Rooted rules" — fixes `M-BUG-9` (dogfooding find) in `scanners/rules-validator.mjs`: the RUL "Rule path pattern matches no files" check now resolves a rule's `paths:`/`globs:` pattern against the rule's **own project root** (the dir containing its `.claude/`), not the outer scan root. Previously `countGlobMatches` globbed against the scan target and `collectProjectFiles`' `depth>4` cutoff never reached deep matching files, so a live rule in a **nested repo** (e.g. a marketplace checkout under `~/.claude`) was wrongly flagged "never activates" (high) — a false F-grade for anyone with rules in a nested repo. The fix derives each rule's project root, collects+globs per root (cached), and skips the check for user-global rules (`root === $HOME`), which scope against the active project at runtime. Same scope-conflation family as `M-BUG-1/2`. No count change (scanners **16**, agents **7**, commands **21**); the fix is a no-op when `projectRoot === targetPath` (the common single-repo scan), so frozen v5.0.0 + default-output snapshots stay byte-stable. **1307** tests (+2 TDD: nested-repo false-positive + HOME guard). |
|
||||
| **5.12.3** | 2026-06-26 | "Phantom agents" — fixes `M-BUG-3/4/5` (dogfooding finds) in `scanners/lib/active-config-reader.mjs`: `enumerateAgents` now counts only the agents Claude Code actually **registers**. Per the official subagents doc, an agent needs valid `name`+`description` frontmatter, and CC scans recursively and silently skips frontmatter-less files. The reader previously (`M-BUG-5`) counted every `.md` regardless of frontmatter, (`M-BUG-3`) never recursed into agent subdirs, and (`M-BUG-4`) double-counted when the project dir equals the user dir (scanning `$HOME` — root cause, also affecting rules/output-styles). Real-machine verify: user-agent count **13→0** (all 12 user agents + `REMEMBER.md` are frontmatter-less → CC registers none), HOME `project`-dup **13→0**; corrected always-loaded baseline ≈ **53** (was 66). Agent enumeration is machine-dependent and absent from the frozen snapshots, so the v5.0.0 + SC-5 + default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands **21**). **1305** tests. |
|
||||
| **5.12.2** | 2026-06-24 | "Honest census" — fixes `M-BUG-1` (dogfooding find): `enumeratePlugins` walked `~/.claude/plugins/marketplaces/<mkt>/plugins/` and ignored both enable-state and the polyrepo cache layout, so it **over-counted phantom agents** from disabled plugins while **missing the entire enabled polyrepo set** (whose plugins live under `cache/`). It now gates on `installed_plugins.json` + `enabledPlugins` and enumerates each plugin from its active `installPath`, with the marketplaces walk as fallback. Fixes `manifest`/`whats-active`/AGT/`token-hotspots` for any user with disabled plugins or a polyrepo marketplace. No count change (scanners **16**, agents **7**, commands **21**); `--json`/`--raw` byte-stable, frozen v5.0.0 + SC-5 + default-output snapshots untouched. Real-machine verify: agent listing 114→104, ghosts gone. **1301** tests. |
|
||||
| **5.12.1** | 2026-06-24 | "Footgun guard" — Pattern H (stale plugin-cache versions, `token-hotspots`) recommended deleting stale version dirs with **no warning** that a currently-running session may still hold one of those versions for its whole lifetime. "Stale" is judged against `installed_plugins.json` (what NEW sessions load), so the recommendation could reproduce the exact failure that breaks a live session: deleting the dir pulls the files out from under the running session, which then must `/exit` + restart. The `plugin-cache-hygiene` recommendation now carries the live-session caveat. **Recommendation string only** — no new finding ID or scanner (count stays **16**, agents **7**, commands **21**), no token figures changed, so `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 + default-output snapshots are untouched. **1297** tests. |
|
||||
| **5.12.0** | 2026-06-23 | "Auto-calibration" — completes the deferred B8 half (**B8b**): `--context-window auto` now **probes the configured model** instead of always falling back to advisory. New pure `modelToContextWindow()` maps known 1M-tier model IDs (Fable 5, Opus 4.8/4.7/4.6, Sonnet 4.6 — verified June 2026 — plus the explicit `[1m]` tier tag, dated/provider-prefixed IDs, and the `opus`/`sonnet`/`fable` aliases) to the 1M window; new IO helper `lib/active-model.mjs` `resolveActiveModel()` reads the model the way Claude Code resolves it (shell `ANTHROPIC_MODEL` override, then the settings cascade local > project > user). When `auto` resolves a recognized model the budget calibrates to its window (`auto-probed`, not advisory); when no model is pinned or it is unrecognized it keeps the conservative anchor and stays advisory (`auto-unresolved`) — the honest fallback. No new finding ID or scanner (count stays **16**, agents **7**, commands **21**); the default and explicit `--context-window` paths are unchanged, so `--json`/`--raw` stay byte-stable and the frozen v5.0.0 + SC-5 snapshots are untouched. **1296** tests. |
|
||||
| **5.11.0** | 2026-06-23 | "Precision polish" — the two LOW-priority calibration gaps, both additive (scanner count stays **16**, agents **7**, commands **21**; `--json`/`--raw` byte-stable, frozen v5.0.0 + SC-5 untouched). **B7 — oversized skill body (`CA-SKL-003`, low):** the SKL scanner now measures the SKILL.md **body** (it already read the file in full) and flags bodies over ~5,000 tokens, recommending a supporting-file split + `context: fork`. Honestly framed as an **on-demand** cost — the body loads only when the skill is invoked, **not** every turn like the always-loaded listing — hence low severity. **B8 — context-window calibration (`--context-window`):** `CA-SKL-002` (skill-listing budget) and the CML char-budget now calibrate to a real context window via `--context-window <n>` (e.g. `1000000` stops the 200k anchor crying wolf on a 1M host) instead of always anchoring at 200k; `--context-window auto` keeps the conservative anchor but **downgrades budget findings to info/advisory** rather than firing a breach (model→window auto-probing deferred to a later B8b). No flag → byte-identical to the pre-B8 200k default. CPS is intentionally excluded (no window-anchored budget to calibrate). 1279 tests |
|
||||
| **5.10.0** | 2026-06-23 | "Deferral & injection hygiene" — three additive hardening levers that extend existing scanners toward a tighter always-loaded prefix (scanner count stays **16**, agents **7**, commands **21**; `--json`/`--raw` byte-stable, frozen v5.0.0 + SC-5 untouched). **B4 — MCP tool-schema deferral (`CA-TOK-006`; tokens patterns 7→8):** Claude Code defers MCP tool schemas (names-only, ~120 tok; full schemas load on demand) by default, so `CA-TOK-006` detects config-file signals that force the FULL schemas into the always-loaded prefix every turn — `env.ENABLE_TOOL_SEARCH="false"` (high), a `"ToolSearch"` deny (high), a configured Haiku model (medium), or per-server `alwaysLoad:true` (CC v2.1.121+, high); severity scales with the aggregate forced-upfront tokens. New pure engine `lib/mcp-deferral.mjs` shared by TOK + GAP, plus a feature-gap **CLI-over-MCP** companion lever (prefer `gh`/`aws`/`gcloud`). Triggers on config files ONLY — Vertex / custom `ANTHROPIC_BASE_URL` / a runtime `/model` switch are launch state and are disclosed, never triggered; the prefix-cache-invalidation claim was NOT-CONFIRMED in docs and is not asserted. **B5 — hook `additionalContext` advisory + filter-before lever:** HKV emits an info advisory when a hook injects unfiltered output into `additionalContext`, with a feature-gap **filter-before-Claude-reads** companion citing the documented `filter-test-output.sh` pattern. **B6 — CPS `@import` volatile scan:** the cache-prefix scanner now follows `@import`s (one hop) and flags volatile content in the imported file that breaks the cached prefix — a new medium finding, keyed on the resolved file. 1257 tests |
|
||||
| **5.9.0** | 2026-06-23 | "Machine-wide token lens" — the three highest-impact hardening gaps toward whole-machine token tuning. **B1 — agent-listing budget (new orchestrated scanner AGT, count 15→16):** the always-loaded agent listing (name+description re-sent every turn) is now measured — `CA-AGT-001` per-agent description bloat (advisory), `CA-AGT-002` aggregate listing over budget; both LOW and explicitly **inferred / upper-bound** (the mechanism is undocumented — the evidence discloses it rather than overstating). **B2 — machine-wide always-loaded token roll-up:** the campaign ledger now carries a token bill — `campaign refresh-tokens` does a live cross-repo sweep that counts the **shared global always-loaded layer once** + per-repo deltas, with a ranked "most expensive repos" table (the `whats-active` double-count, avoided by construction). **B3 — cache-aware filtering (folds in B0):** `~/.claude/plugins/cache` holds *both* active and stale plugin versions (installPaths point INTO it), so token-hotspots + CNF are now **version-aware** — `--exclude-cache` (default ON) keeps each plugin's active version and drops only stale ones (`installed_plugins.json`-driven), so stale versions stop polluting the hotspot ranking and inflating duplicate-hook conflicts; stale versions surface as a separate **Dead config** disk-cleanup finding (zero live-context impact). `--json`/`--raw` byte-stable; frozen v5.0.0 + SC-5 snapshots untouched. 1215 tests |
|
||||
| **5.8.0** | 2026-06-23 | "Campaign motor" — a durable, machine-wide audit **campaign** that sits ABOVE individual sessions (one repo = one session; a fleet of repos = a campaign). **Ledger:** `~/.claude/config-audit/campaign-ledger.json` (outside the plugin dir → survives uninstall/upgrade) tracks a repo list + per-repo lifecycle (pending→audited→planned→implemented) + a machine-wide roll-up by status & severity; pure transforms with injected `now`. **`/config-audit campaign` (commands 20→21):** read-only report (`campaign-cli`) + human-approved writes (`campaign-write-cli`: init / add / set-status) — reports first, mutates only on explicit approval, never hand-edits the ledger. **Cross-repo backlog:** one severity-weighted prioritized pick-list (`buildBacklog`, `critical:1000/high:100/medium:10/low:1`). **Plan export + execution-by-reuse:** `campaign-export-cli --write` drops a planned repo's plan verbatim into its own `docs/`; execution reuses the existing `/config-audit implement` + `rollback` (no new execution machinery). All campaign code is `-cli`/lib → scanner count stays **15**, agents **7**, byte-stable. Plus pre-release cleanup: `knowledge-refresh` wired into the router + help; CLAUDE.md trimmed 540→134 lines (impl notes → `docs/scanner-internals.md`, config grade B→A). 1168 tests |
|
||||
| **5.7.0** | 2026-06-21 | "Optimization lens" — first detector of the «optimally shaped?» axis (vs «correct?»), plus a living knowledge layer. **Register:** `knowledge/best-practices.json`, a provenance-stamped, schema-validated best-practices register (first runtime-consumed `knowledge/` file). **OPT scanner (count 14→15):** `CA-OPT-001` (LOW) a ≥6-step CLAUDE.md procedure that would fit better as a skill, citing register entry `BP-MECH-003`. **`/config-audit optimize` + `optimization-lens-agent` (opus, agents 6→7):** prose-judgment lens for lifecycle→hook (`BP-MECH-001`), unscoped path→rule (`BP-MECH-002`), "never"→permission (`BP-MECH-004`); pre-filter recall + opus precision gate. **`/config-audit knowledge-refresh` (commands 19→20):** deterministic stale-check (injected reference date, 90-day cadence) + web re-verify/poll, human-approved writes only. Last two are agent/web-driven (not byte-stable). 1091 tests |
|
||||
| **5.6.0** | 2026-06-20 | "Steering-model II" — the load-pattern / compaction-survival model lands end-to-end. **Foundation:** `active-config-reader` now enumerates rules, agents, and output styles (alongside CLAUDE.md/plugins/skills/hooks/MCP), each tagged `loadPattern` (always / on-demand / external) + `survivesCompaction` from the published loading model; the frontmatter parser also reads YAML block sequences (`paths:` lists). **B (load-pattern accounting):** `manifest` reports component-level sources (the double-counting plugin roll-up is gone), tags every source with the load-pattern triple, and leads with an **always-loaded subtotal** ("tokens that enter context every turn"); `token-hotspots` annotates each ranked hotspot with its load pattern. **C (output styles):** new orchestrated **OST** scanner (count 13→**14**) — `CA-OST-001` a custom style stripping built-in coding instructions (missing `keep-coding-instructions: true`, V10), `CA-OST-002` a plugin style with `force-for-plugin: true` overriding the user's `outputStyle` (V11), `CA-OST-003` a settings `outputStyle` resolving to no known style (dead config). Doc-verified; frozen v5.0.0 snapshots preserved via strip-helpers, SC-5 regenerated. 1023 tests |
|
||||
| **5.5.0** | 2026-06-20 | "Steering-model I" — two additive compaction-durability / dead-config findings (count stays **13**, `--json`/`--raw` byte-stable). Per the official "what survives compaction" model: RUL flags a large (>50-line) **path-scoped** rule not re-injected after compaction (LOW); CML flags a **nested** (subdir) CLAUDE.md not re-injected after compaction (LOW). PLH flags a plugin agent setting `hooks`/`mcpServers`/`permissionMode` — Claude Code ignores these for plugin subagents, so it's dead config (`permissionMode` = MEDIUM false-security, `hooks`/`mcpServers` = LOW). Known limitation: the frontmatter parser reads inline `paths:` but not YAML block sequences (deferred to v5.6 Foundation). 961 tests |
|
||||
| **5.4.1** | 2026-06-20 | Scanner-correctness patch (count stays **13**, `--json`/`--raw` byte-stable). HKV: added `Setup`/`UserPromptExpansion`/`PostToolBatch` to the valid-event set (a valid hook using one was wrongly flagged "will never fire"), and **removed** `post-session` (the 2.1.169 `post-session` is a self-hosted-runner workspace-lifecycle hook, **not** a settings.json event — absent from `hooks.md`; verified 2026-06-20). RUL: globs-rule wording corrected — only `paths:` is documented, so the finding drops the unverified "deprecated/legacy" claim and steers to the documented field. PLH: optional `model`/`tools`/`name`/`allowed-tools` frontmatter no longer required; CLAUDE.md component-section required only for components the plugin actually ships. 954 tests |
|
||||
| **5.4.0** | 2026-06-19 | Plugin-hygiene & settings-validation hardening. Three additive findings extend existing PLH and SET scanners (count stays **13**): PLH plugin-folder shadowing (`CA-PLH-015` — a `plugin.json` component-path key in the *replaces* set `commands`/`agents`/`outputStyles` pointing at a custom path while the default folder still exists) mirroring CC's `/doctor` & `claude plugin list` warning; PLH `skills:`-array validation (`CA-PLH-016` — each entry must resolve to a directory in the plugin root; flags `non-string`/`escapes-root`/`not-found`/`not-a-directory`) mirroring `claude plugin validate`; SET `autoMode` structure (only `environment`/`allow`/`soft_deny`/`hard_deny` string arrays) + dead-config (`autoMode` in shared `.claude/settings.json` is not read by CC). `--json`/`--raw` byte-stable. 949 tests |
|
||||
| **5.3.0** | 2026-06-19 | Permission-rule & plugin-hygiene hardening. Five additive scanner findings extend existing scanners (count stays **13**): DIS forbidden-param rules (`Tool(param:value)` on a canonicalizing field — deny/ask = false security, allow = dead config) and ineffective allow-wildcards + `Tool(*)` deny-all; CML context-window-scaled 40.0k-char CLAUDE.md budget mirroring CC's startup warning; PLH plugin namespace collision (two plugins declaring the same `name`); feature-gap `disableBundledSkills` lever under skill-listing pressure. PLH cross-plugin command-name overlap reframed HIGH → LOW (namespacing keeps both reachable). `--json`/`--raw` byte-stable. 936 tests |
|
||||
| **5.2.0** | 2026-06-18 | CC 2.1.114→181 compatibility + skill-listing budget. New orchestrated scanner **SKL** (`CA-SKL-001` 1,536-char listing cap, `CA-SKL-002` listing-budget sum) → 13 orchestrated scanners. Five validators refreshed for CC 2.1.114–181 settings/hook surface (`xhigh` effort, `MessageDisplay` + post-session events, 28 hook events). False positives eliminated in MCP (auto-injected/POSIX env vars, invented `trust` field) and permissions (param-aware DIS/CNF). Hermetic HOME isolation across all CLI-spawning tests. 875 tests |
|
||||
|
|
@ -668,8 +851,6 @@ This plugin is cautious by design — configuration files are important, and a b
|
|||
| **1.0.0** | 2026-02-11 | Cross-platform support |
|
||||
| **0.7.0** | 2026-02-07 | Initial version (version reset from inflated 1.2.0) |
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for full details.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
|
|
|||
34
SECURITY.md
Normal file
34
SECURITY.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report privately to <security@fromaitochitta.com> - do not open a
|
||||
public issue.
|
||||
Canonical repository: https://git.fromaitochitta.com/open/config-audit
|
||||
|
||||
Please include the affected version or commit, a minimal reproduction,
|
||||
and the impact you see. We acknowledge every report within 5 working
|
||||
days, agree a fix and disclosure timeline with the reporter, and aim to
|
||||
disclose within 90 days of the initial report.
|
||||
|
||||
## Response process
|
||||
|
||||
1. Acknowledge within 5 working days.
|
||||
2. Triage and confirm severity within 10 working days.
|
||||
3. Develop and test a fix.
|
||||
4. Publish an advisory and credit the reporter unless they prefer
|
||||
to remain anonymous.
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 5.13.x | Yes |
|
||||
| < 5.13 | No |
|
||||
|
||||
Only the latest tagged release receives security fixes. There is no
|
||||
long-term support line.
|
||||
|
||||
## Advisories
|
||||
|
||||
Security-relevant fixes are noted in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
|
@ -51,11 +51,16 @@ In `--raw` mode, fall back to v5.0.0 severity prefiks and verbatim scanner title
|
|||
5. **Identify optimizations**: Rules to globalize, missing configs, orphaned files
|
||||
6. **Security scan**: Aggregate secret warnings, check for insecure patterns
|
||||
7. **CLAUDE.md quality assessment**: Score each file against rubric, assign letter grades
|
||||
8. **Generate report**: Write comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
|
||||
8. **Generate report**: Compose the comprehensive markdown report — group findings by `userImpactCategory`, lead with `userActionLanguage`
|
||||
|
||||
## Output
|
||||
|
||||
Write to: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
||||
Return the complete report as your final message — do not write it to a file
|
||||
yourself. The Claude Code subagent harness instructs agents not to write
|
||||
report/analysis files; your text output IS the deliverable. The orchestrating
|
||||
command saves your returned report verbatim to
|
||||
`~/.claude/config-audit/sessions/{session-id}/analysis-report.md` for the
|
||||
downstream plan/interview/status phases.
|
||||
|
||||
**Output MUST NOT exceed 300 lines.** Prioritize findings by severity. Use tables, not prose.
|
||||
|
||||
|
|
@ -183,4 +188,4 @@ Verify report: all findings referenced, recommendations actionable, severity lev
|
|||
|
||||
- Process findings in memory (typically < 1MB total)
|
||||
- Generate report in single pass
|
||||
- No file modifications (read-only except report output)
|
||||
- No file modifications (read-only; the report is returned as your final message)
|
||||
|
|
|
|||
|
|
@ -146,6 +146,11 @@ Move content from one file to another.
|
|||
|
||||
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
|
||||
|
||||
**Append discipline (shared log):** other implementer agents may be writing this
|
||||
log concurrently. ALWAYS append your entry with a Bash `>>` heredoc;
|
||||
NEVER use the Write or Edit tool on the log file — a full-file Write silently
|
||||
clobbers entries other agents appended after you read the file.
|
||||
|
||||
### Success
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
183
agents/optimization-lens-agent.md
Normal file
183
agents/optimization-lens-agent.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
---
|
||||
name: optimization-lens-agent
|
||||
description: |
|
||||
Judges CLAUDE.md mechanism-fit for the v5.7 optimization lens (CA-OPT). Reads
|
||||
deterministic pre-filter candidates and decides, with prose judgement, whether
|
||||
each is a genuine "you use mechanism X, but Y fits this better" opportunity —
|
||||
lifecycle phrasing → hook, unscoped path-specific instruction → path-scoped
|
||||
rule, absolute "never" prohibition → permission. Precision-gated: cites the
|
||||
best-practices register rule + source, and stays silent when unsure.
|
||||
model: opus
|
||||
color: orange
|
||||
tools: ["Read", "Glob", "Grep", "Write"]
|
||||
---
|
||||
|
||||
# Optimization Lens Agent
|
||||
|
||||
You are the **precision gate** of the optimization lens's hybrid motor. A cheap
|
||||
deterministic pre-filter (`lens-prefilter`) has already surfaced candidate lines
|
||||
in CLAUDE.md that *might* fit a better mechanism. Your job is to read each
|
||||
candidate **in its real context** and keep only the genuine opportunities.
|
||||
|
||||
This is the "is the config **optimal?**" axis, not "is it **correct?**" — every
|
||||
finding is a *Missed opportunity*, never a mistake. The config works as written;
|
||||
you are pointing at a mechanism that would fit the content better.
|
||||
|
||||
## The judgement you make
|
||||
|
||||
For each candidate, the register rule names the better-fit mechanism. Decide
|
||||
whether the line is *really* that kind of instruction:
|
||||
|
||||
| lensCheck | Register | Keep it ONLY if the line is… | Better mechanism |
|
||||
|---|---|---|---|
|
||||
| `claude-md-lifecycle-phrasing` | BP-MECH-001 | a recurring automation the model is *told* to perform ("after every commit, run X") — something that should happen deterministically, not at the model's discretion | a **hook** (PreToolUse / PostToolUse / Stop) |
|
||||
| `unscoped-path-specific-instruction` | BP-MECH-002 | a constraint that only applies when a *specific* file/path/glob is touched, sitting in root CLAUDE.md where it loads every turn regardless | a **path-scoped rule** (`.claude/rules/` with `paths:` frontmatter) |
|
||||
| `never-instruction` | BP-MECH-004 | an *absolute* prohibition — something that must NEVER happen, where relying on the model to remember is the wrong guarantee | a **permission deny rule** or PreToolUse hook |
|
||||
| `compensatory-instruction` | BP-SUB-001 | **`--subtract` mode only.** an instruction that corrects general model *behaviour* rather than stating a local fact — so it pays an always-loaded token cost without telling the model anything it could not work out | **removal**, re-added only if the model actually stumbles |
|
||||
|
||||
## The subtraction lens (`--subtract` only)
|
||||
|
||||
Present only when the payload has a `subtract` block. It asks the inverse of
|
||||
every other lens: *what is no longer earning its always-loaded rent?* Four
|
||||
things make it different, and all four are non-negotiable.
|
||||
|
||||
**1. The floor is not yours to decide.** A deterministic pre-step has already
|
||||
excluded every block carrying a local fact — a code span, path, domain, version
|
||||
pin, policy invariant, or an unresolved capitalized entity — plus the steps of
|
||||
any ordered list whose siblings carry one. You never see those blocks, and you
|
||||
must not reason about whether some *other* block ought to be deleted. Judge only
|
||||
what you are given. Precision is asymmetric: a missed dead line costs a few
|
||||
tokens per turn; a deleted load-bearing line costs a wrong remote, a broken
|
||||
script, or a lost afternoon.
|
||||
|
||||
**2. Staleness is NOT a deletion signal.** A block that pins an outdated version
|
||||
("use Opus 4.8") is a *dead-reference* problem for `drift` / `CA-CML`, not a
|
||||
subtraction finding. The instruction is still load-bearing — it encodes a
|
||||
decision only the operator can make; it is merely out of date. Recommending
|
||||
deletion because content looks stale is a category error. Say "this looks
|
||||
outdated" if you must, but never as a removal candidate.
|
||||
|
||||
**3. Tier 2 is not tier 3.** Deletable splits into *earned* (compensatory, but
|
||||
this model still stumbles on it, so it returns) and *dead* (never missed). Sort
|
||||
every candidate into one of the two and say which. A block that has visibly
|
||||
earned its place — its subject matter recurs in the repo's own history — is tier
|
||||
2 even when its classification is "compensatory". Reporting it as dead weight is
|
||||
wrong even though the label matches.
|
||||
|
||||
**4. A model-scoped citation is conditional, never authoritative.** When a
|
||||
candidate carries `modelScope` (only under `--for-model`), apply the SAME tier-2
|
||||
/ tier-3 judgement as any other `compensatory-instruction` candidate — the model
|
||||
tag sharpens the citation, it does not bypass precision rule 1. State it as
|
||||
conditional in the report copy ("redundant if targeting {model}; this operator
|
||||
may run other models in other sessions"), never as a settled fact for all future
|
||||
sessions. The tag is an annotation on a candidate the general detector already
|
||||
found; it is not evidence that the block is dead.
|
||||
|
||||
Rank kept candidates by always-loaded token cost, and state the total payoff.
|
||||
Frame it as *rent*, never as a mistake: this config was correct when written.
|
||||
|
||||
## Input
|
||||
|
||||
You receive an `optimize-lens` payload (JSON) with:
|
||||
- `target` — the repo path.
|
||||
- `deterministic` — OPT scanner findings already confirmed (CA-OPT-001:
|
||||
procedure → skill). Report these **as-is**; do not re-judge them.
|
||||
- `candidates` — pre-filter candidates, each with `file`, `line`, `lensCheck`,
|
||||
`mechanism`, `signalText`, and a `register` block (`id`, `claim`,
|
||||
`recommendation`, `severity`, `source`). Only CONFIRMED register rules reach
|
||||
you.
|
||||
- `register` — the full confirmed prose-judgment entries, for reference.
|
||||
- `subtract` — **present only under `--subtract`.** `{ enabled, candidates,
|
||||
register, detectors }`. Each candidate spans `line`–`endLine` (a whole leaf
|
||||
block, not one line) and carries `signalText` plus the BP-SUB-001 register
|
||||
block. Everything load-bearing was already removed before you saw this.
|
||||
Under `--for-model <name>` the block also carries `forModel`
|
||||
(`{ requested, recognized, matchedCount }`), and a SUBSET of its candidates
|
||||
carry `modelScope` (`{ registerId, claim, requestedModel }`). If
|
||||
`recognized` is `false`, the operator named a model the register does not
|
||||
cover — say so, and do not treat the absence of tags as evidence of a clean
|
||||
config.
|
||||
|
||||
Always **Read the actual CLAUDE.md file(s)** named in the candidates before
|
||||
judging — `signalText` is one line out of context; the surrounding lines decide
|
||||
whether it is really lifecycle/path-specific/prohibition phrasing.
|
||||
|
||||
## Precision rules (non-negotiable)
|
||||
|
||||
1. **Keep only high-confidence opportunities.** When the line is ambiguous,
|
||||
rhetorical, an example, a heading, or already correctly placed (e.g. it is
|
||||
*inside* a path-scoped rule, or already references a hook) — **drop it**. A
|
||||
missed suggestion is far cheaper than a wrong one (Verifiseringsplikt).
|
||||
2. **Never invent a recommendation.** Use the `register.recommendation` and cite
|
||||
`register.id` + `register.source.url`. If a candidate has no register block,
|
||||
skip it.
|
||||
3. **De-duplicate.** If one line yields two candidates (e.g. "never edit
|
||||
src/config.ts"), pick the single mechanism that fits best and say why,
|
||||
rather than emitting two findings for one line.
|
||||
4. **No false urgency.** These are LOW-severity opportunities. Do not imply the
|
||||
config is broken.
|
||||
|
||||
## Output
|
||||
|
||||
Write `optimization-lens-report.md` to the session directory (≤120 lines).
|
||||
|
||||
```markdown
|
||||
# Optimization Lens — mechanism-fit
|
||||
|
||||
**Date:** YYYY-MM-DD | **Target:** {repo}
|
||||
**Confirmed opportunities:** {N kept} · **Candidates reviewed:** {M} · **Dropped (low confidence):** {M-N}
|
||||
|
||||
> The config works as written. These are places where a different Claude Code
|
||||
> mechanism would fit the content better — usually cheaper per turn or more
|
||||
> reliable.
|
||||
|
||||
## Procedures → skills (deterministic)
|
||||
|
||||
{For each `deterministic` finding — render title/recommendation verbatim, cite CA-OPT-001 + BP-MECH-003.}
|
||||
|
||||
## Lifecycle → hooks
|
||||
|
||||
{Kept BP-MECH-001 findings. For each:}
|
||||
**{file}:{line}** — {one-line restatement of the line}
|
||||
Why: {register.claim, condensed}
|
||||
Move to: {register.recommendation}
|
||||
Source: {register.source.url}
|
||||
|
||||
## Path-specific → scoped rules
|
||||
|
||||
{Kept BP-MECH-002 findings, same shape.}
|
||||
|
||||
## Absolute prohibitions → permissions
|
||||
|
||||
{Kept BP-MECH-004 findings, same shape.}
|
||||
|
||||
## What I deliberately left alone
|
||||
|
||||
{Brief, honest: candidates you dropped and why — "line 22 mentions a path but is
|
||||
a cross-reference, not an instruction." This is the precision gate showing its
|
||||
work. Keep to a few lines.}
|
||||
|
||||
## No longer earning its rent (--subtract only)
|
||||
|
||||
{Omit entirely unless the payload has a `subtract` block. Two sub-lists —
|
||||
**Dead** (tier 3, out and never missed) and **Earned** (tier 2, out but likely
|
||||
to return) — ranked by token cost, with a payoff total. For each:}
|
||||
**{file}:{line}-{endLine}** — {what the block says, in one line} · ~{N} tok/turn
|
||||
Tier: {dead | earned — and why}
|
||||
Source: {register.source.url}
|
||||
Model-scoped: {only if the candidate carries `modelScope`} {claim}, for
|
||||
{requestedModel} (conditional — verify this is still the model you target)
|
||||
```
|
||||
|
||||
Omit any section with zero kept findings (except keep the "left alone" note when
|
||||
you dropped anything). If nothing survived the gate, say so plainly — a clean
|
||||
CLAUDE.md is a good outcome, not a failure to find problems.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Frame everything as *opportunities*, never failures.
|
||||
- Cite the register rule id + source URL on every finding — provenance is the
|
||||
product.
|
||||
- Be concrete: name the file and line, and what the replacement mechanism is.
|
||||
- Prefer dropping a borderline candidate over stretching to keep it.
|
||||
- Do not recommend a mechanism the project already uses for that exact content.
|
||||
|
|
@ -171,18 +171,10 @@ Total backup size: ~6.4 KB
|
|||
**Rationale**:
|
||||
Code style rules found in 3 projects are identical. Moving to global reduces duplication.
|
||||
|
||||
**Content**:
|
||||
```markdown
|
||||
# Code Style Rules
|
||||
|
||||
## Language Preferences
|
||||
- TypeScript > JavaScript
|
||||
- Explicit > implicit
|
||||
- Lesbarhet > cleverness
|
||||
|
||||
## Commit Format
|
||||
- Conventional Commits: `type(scope): description`
|
||||
```
|
||||
**Content outline** (describe it — do not inline the file):
|
||||
Language preferences, then commit format. The implementer reads the source
|
||||
files and writes the content itself; a full file body pasted here is what the
|
||||
200-line budget above forbids.
|
||||
|
||||
**Validation**:
|
||||
- File exists after creation
|
||||
|
|
|
|||
|
|
@ -140,7 +140,15 @@ Checking for secrets...
|
|||
|
||||
## Output Format
|
||||
|
||||
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
|
||||
Return the report below as your final message. Do NOT write it to a file: this
|
||||
agent is read-only by design (`tools: Read, Glob, Grep`) and has no write tool,
|
||||
so a write instruction here would be a contract it cannot keep.
|
||||
|
||||
The orchestrator appends what you return to
|
||||
`~/.claude/config-audit/sessions/{session-id}/implementation-log.md` itself,
|
||||
with Bash `>>` (`commands/implement.md` Step 5) — never the Write tool. That log
|
||||
is shared with the implementer agents running in parallel, and a full-file Write
|
||||
on it silently clobbers their entries.
|
||||
|
||||
```markdown
|
||||
## Verification Report
|
||||
|
|
@ -243,8 +251,8 @@ Optional: Generate before/after comparison:
|
|||
|
||||
This agent:
|
||||
- Only uses Read, Glob, Grep tools
|
||||
- Never modifies any files
|
||||
- Reports findings without taking action
|
||||
- Never modifies any files, including the shared implementation log
|
||||
- Reports findings without taking action — every result is returned inline
|
||||
- Safe to run multiple times
|
||||
|
||||
## Model policy
|
||||
|
|
|
|||
|
|
@ -20,9 +20,16 @@ Generate comprehensive analysis report from discovery findings.
|
|||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Verify session state
|
||||
### Step 1: Resolve the session and verify its state
|
||||
|
||||
Read `~/.claude/config-audit/sessions/{session-id}/state.yaml` using the Read tool and verify discovery phase completed. If not, tell the user: "Discovery hasn't been run yet. Start with `/config-audit discover` or just run `/config-audit` for a full audit."
|
||||
Find the session first — never guess which one `{session-id}` refers to:
|
||||
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
Sort by modification time — the most recently modified session wins
|
||||
```
|
||||
|
||||
Every `{session-id}` below is that session's id. Read its `state.yaml` using the Read tool and verify discovery phase completed. If the Glob returns nothing, or discovery hasn't completed, tell the user: "Discovery hasn't been run yet. Start with `/config-audit discover` or just run `/config-audit` for a full audit."
|
||||
|
||||
### Step 2: Tell the user what's happening
|
||||
|
||||
|
|
@ -37,17 +44,16 @@ This includes hierarchy mapping, conflict detection, and prioritized recommendat
|
|||
|
||||
Tell the user: **"Generating analysis (this takes about 30 seconds)..."**
|
||||
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
```
|
||||
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
|
||||
prompt below is **not** a shell, so a variable assigned in a bash block cannot be
|
||||
referenced from it. Substitute `{mode}` literally with `--raw` or `humanized`.
|
||||
|
||||
```
|
||||
Agent(subagent_type: "config-audit:analyzer-agent")
|
||||
model: sonnet
|
||||
prompt: |
|
||||
Analyze all findings in: ~/.claude/config-audit/sessions/{session-id}/findings/
|
||||
Mode: $RAW_FLAG (empty = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
|
||||
Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
|
||||
Generate comprehensive report covering:
|
||||
1. Executive summary with key metrics, grouped by userImpactCategory
|
||||
2. Hierarchy map visualization
|
||||
|
|
@ -60,12 +66,21 @@ Agent(subagent_type: "config-audit:analyzer-agent")
|
|||
raw severity. The humanizer already replaced jargon-heavy
|
||||
title/description/recommendation strings with plain-language
|
||||
equivalents — render them verbatim, do not paraphrase.
|
||||
Output to: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
|
||||
Return the complete report as your final message. Do not write it
|
||||
to a file — the orchestrating command saves it to the session directory.
|
||||
```
|
||||
|
||||
### Step 4: Present summary
|
||||
### Step 4: Save the report
|
||||
|
||||
After the agent completes, read the generated report and show a brief summary:
|
||||
The agent returns the complete report as its final message — the Claude Code
|
||||
subagent harness instructs agents not to write report/analysis files themselves,
|
||||
so the command must persist it. Write the returned report verbatim (no edits,
|
||||
no truncation) to `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
||||
using the Write tool. Downstream phases (`plan`, `interview`, `status`) read this file.
|
||||
|
||||
### Step 5: Present summary
|
||||
|
||||
After saving the report, show a brief summary:
|
||||
|
||||
```markdown
|
||||
### Analysis Complete
|
||||
|
|
@ -84,6 +99,6 @@ Full report: `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`
|
|||
- **`/config-audit fix`** — Auto-fix deterministic issues right away
|
||||
```
|
||||
|
||||
### Step 5: Update state
|
||||
### Step 6: Update state
|
||||
|
||||
Update `state.yaml` with `current_phase: "analyze"`, `next_phase: "plan"`.
|
||||
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires: `current_phase: "analyze"`, `completed_phases` (append `analyze` to the existing array — read it first), `next_phase: "plan"`, and `updated_at`. A write that names only two of the four silently deletes the other two.
|
||||
|
|
|
|||
344
commands/campaign.md
Normal file
344
commands/campaign.md
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
---
|
||||
name: config-audit:campaign
|
||||
description: Machine-wide audit campaign — track which repos are pending/audited/planned/implemented across sessions, with a machine-wide roll-up. Human-approved writes only.
|
||||
argument-hint: "[init | add <path>... | set-status <path> <status> | refresh-tokens | export <path>]"
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Config-Audit: Campaign
|
||||
|
||||
A single config-audit session audits **one** scope. A **campaign** sits above sessions: a
|
||||
durable ledger of every repo you mean to bring up to standard, each repo's lifecycle status
|
||||
(**pending → audited → planned → implemented**), and a machine-wide roll-up of findings by
|
||||
severity. It persists to `~/.claude/config-audit/campaign-ledger.json` — **outside** the
|
||||
plugin dir, next to `sessions/` — so it survives plugin uninstall/reinstall/upgrade and
|
||||
resumes across sessions.
|
||||
|
||||
**The Iron rule (Verifiseringsplikt): nothing is ever auto-written.** Reporting is read-only.
|
||||
Every mutation — creating the ledger, adding a repo, changing a status — is proposed first
|
||||
and applied **only on explicit approval**, by invoking one deterministic write-CLI subcommand.
|
||||
The command never hand-edits the ledger JSON.
|
||||
|
||||
This is the **THIN** campaign surface (ledger + roll-up + status + a cross-repo prioritized
|
||||
backlog to pick from + plan **export**). It does not run audits or apply fixes itself: it tracks
|
||||
where each repo stands, shows what to tackle next, exports a planned repo's plan into that repo's
|
||||
own `docs/`, and points at the **existing** per-repo `/config-audit implement` (backup + apply +
|
||||
verify) and `/config-audit rollback` for execution — Block 4c reuses that machinery, it does not
|
||||
reinvent it.
|
||||
|
||||
## Three CLIs back this command
|
||||
|
||||
- **Read (report):** `scanners/campaign-cli.mjs` — loads + validates the ledger, emits the
|
||||
repo list + roll-up + backlog. Never writes.
|
||||
- **Write (mutate):** `scanners/campaign-write-cli.mjs` — `init` / `add` / `set-status` /
|
||||
`refresh-tokens`, each a thin wrapper over the invariant-enforcing lib transforms + save.
|
||||
Invoked **only** after the user approves a specific action. `refresh-tokens` is the live
|
||||
cross-repo token sweep: it runs the manifest's always-loaded accounting across every tracked
|
||||
repo and folds the result into the machine-wide token bill (shared global layer counted once
|
||||
+ per-repo deltas).
|
||||
- **Export:** `scanners/campaign-export-cli.mjs` — `--repo <path>` resolves the repo's linked
|
||||
session, reads its `action-plan.md`, and assembles a `docs/config-audit-plan-<sessionId>.md`.
|
||||
Read-only (a preview) by default; it writes the file **only** under `--write`, which is
|
||||
invoked **only** after the user approves. The CLI writes the file byte-faithfully — the plan is
|
||||
never re-typed.
|
||||
|
||||
All take `--ledger-file <path>` (defaults to the durable path) and `--output-file <path>`; the
|
||||
write-CLI + export-CLI also take `--reference-date <YYYY-MM-DD>` (the date stamp), and the
|
||||
export-CLI takes `--sessions-dir <path>` (defaults to `~/.claude/config-audit/sessions`).
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Parse arguments
|
||||
|
||||
From `$ARGUMENTS`, pick the mode:
|
||||
|
||||
- *(empty)* or `report` → **report** (read-only). Default.
|
||||
- `init` → initialize the ledger.
|
||||
- `add <path>...` → add one or more repo paths.
|
||||
- `add --discover <root>` → find git repos under `<root>` and let the user pick which to add.
|
||||
- `set-status <path> <status>` → transition a tracked repo (`status` ∈ pending/audited/planned/implemented).
|
||||
- `refresh-tokens` → live cross-repo token sweep: compute the machine-wide always-loaded bill.
|
||||
- `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.
|
||||
|
||||
### Step 2: Always report current state first
|
||||
|
||||
Whatever the mode, start by showing where the campaign stands (read-only):
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-cli.mjs \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-report.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit **0** = a campaign exists, **1** = not initialized yet (advisory — normal first run),
|
||||
**3** = real error → "The campaign ledger couldn't be read — it may be corrupt." (Stop; do not
|
||||
attempt a write over a corrupt ledger.)
|
||||
|
||||
Read `~/.claude/config-audit/sessions/campaign-report.json` with the Read tool (per the UX
|
||||
rules — never show the raw JSON). It has `initialized`, `repos[]` (each: `path, name, status,
|
||||
sessionId, findingsBySeverity, tokens, updatedDate`), `rollUp {totalRepos, byStatus, bySeverity,
|
||||
reposWithFindings, tokens}`, and `backlog[]` — the single cross-repo prioritized work list (each:
|
||||
`path, name, status, findingsBySeverity, totalFindings, weightedScore, rank`), already sorted
|
||||
DESC by severity (most critical work first).
|
||||
|
||||
Present it as two short tables:
|
||||
|
||||
**Campaign roll-up**
|
||||
|
||||
| Status | Repos |
|
||||
|--------|-------|
|
||||
| pending / audited / planned / implemented | … |
|
||||
|
||||
…plus a one-line severity total across audited repos (e.g. "Findings so far: 3 critical,
|
||||
8 high, 5 medium, 12 low across 4 audited repos").
|
||||
|
||||
**Repos**
|
||||
|
||||
| Repo | Status | Findings (C/H/M/L) | Last updated |
|
||||
|------|--------|--------------------|--------------|
|
||||
|
||||
**Prioritized backlog** — the one cross-repo list to pick from, highest-severity work first.
|
||||
Render `backlog[]` in `rank` order (it is already sorted); omit this table entirely when the
|
||||
backlog is empty (nothing outstanding — say "Backlog clear — no outstanding findings across
|
||||
tracked repos."). Implemented repos and repos with no known findings are deliberately absent.
|
||||
|
||||
| # | Repo | Status | Findings (C/H/M/L) | Total |
|
||||
|---|------|--------|--------------------|-------|
|
||||
|
||||
After it, point the user at the top item: "Highest priority: **`<name>`** (`<status>`) — pick it
|
||||
with `/config-audit` (audit), `/config-audit plan`, or `/config-audit implement` in that repo,
|
||||
then record progress here with `set-status`." The backlog is a **pick-list**, not an executor —
|
||||
this command does not run audits or fixes (that is the later execution block).
|
||||
|
||||
**Machine-wide token bill** — render from `rollUp.tokens` (the whole-machine always-loaded
|
||||
accounting). Note the shape: `sharedGlobal`, `perRepoDelta`, and `machineWide` are flat
|
||||
`{always, onDemand, external, unknown}` number maps; `byRepo[]` is `{name, path, always,
|
||||
onDemand, external}` already sorted DESC by always-loaded cost; `reposWithTokens` is the count.
|
||||
|
||||
If `reposWithTokens` is `0`, no sweep has run yet — say: "No token bill yet — run
|
||||
`/config-audit campaign refresh-tokens` to compute the machine-wide always-loaded cost." and
|
||||
omit the table. Otherwise lead with the headline and the once-vs-delta split:
|
||||
|
||||
> **Always-loaded every turn, machine-wide: ~`machineWide.always` tokens** — `sharedGlobal.always`
|
||||
> paid once (global config + installed plugins, in *every* repo) + `perRepoDelta.always` across
|
||||
> `reposWithTokens` repos' own project config.
|
||||
|
||||
Then the **most expensive repos** (their per-repo delta — what each adds beyond the shared layer):
|
||||
|
||||
| # | Repo | Always-loaded delta |
|
||||
|---|------|---------------------|
|
||||
| 1 | `<byRepo[0].name>` | `<byRepo[0].always>` |
|
||||
|
||||
Add one plain-language line so the number is actionable, e.g. "The shared global layer is the
|
||||
biggest lever — trim `~/.claude/CLAUDE.md`, the global agent listing, or rarely-used plugins
|
||||
to cut cost in every repo at once." The bill reflects the **last** sweep; re-run
|
||||
`refresh-tokens` after config changes.
|
||||
|
||||
If `initialized` is false, say so plainly: "No campaign yet. Run `/config-audit campaign init`
|
||||
to start one." Then — if the mode was `init` or `add` — continue to that step (those bootstrap
|
||||
a campaign); for `report`/`set-status` on an uninitialized ledger, stop after this message.
|
||||
|
||||
### Step 3 (mode `init`): Initialize
|
||||
|
||||
If already initialized, say so and stop (no clobber). Otherwise tell the user what will happen,
|
||||
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 $?
|
||||
```
|
||||
|
||||
Exit **0** = created, **1** = already initialized (advisory). Confirm: "Campaign ledger created
|
||||
at `~/.claude/config-audit/campaign-ledger.json`." Then suggest `add`.
|
||||
|
||||
### Step 4 (mode `add`): Add repos — propose, approve, write
|
||||
|
||||
**Gather candidates.**
|
||||
- Explicit paths: use the paths given after `add`.
|
||||
- `--discover <root>`: find git repos (depth-limited), e.g.
|
||||
```bash
|
||||
find "<root>" -maxdepth 3 -type d -name .git 2>/dev/null | sed 's:/\.git$::'
|
||||
```
|
||||
Present the discovered repos as a numbered list and ask **which** to add (and confirm any
|
||||
that are already tracked will be skipped). Use Glob as a fallback if `find` is unavailable.
|
||||
|
||||
**Confirm, then write.** Show the final list and ask for explicit approval. On approval, 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>" ... \
|
||||
--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.
|
||||
|
||||
### Step 5 (mode `set-status`): Transition a repo — propose, approve, write
|
||||
|
||||
Confirm the repo is tracked (from Step 2's report) and that `status` is one of
|
||||
pending/audited/planned/implemented. State the transition ("`<name>`: pending → audited") and
|
||||
ask for approval.
|
||||
|
||||
When marking a repo **audited**, optionally attach its findings-by-severity so the machine-wide
|
||||
roll-up stays meaningful. Two honest sources, in order of preference:
|
||||
1. If the repo was audited in a config-audit session, read that session's finding counts and
|
||||
build `{"critical":C,"high":H,"medium":M,"low":L}` — pass `--session <id>` too.
|
||||
2. Otherwise, use counts the user provides. **Never invent counts** (Verifiseringsplikt) — if
|
||||
none are available, transition the status without `--findings`.
|
||||
|
||||
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>" \
|
||||
--reference-date "$TODAY" \
|
||||
[--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session "<id>"] \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit **3** = invalid status, untracked repo, or no ledger → report the message plainly and do
|
||||
not retry blindly. On success, read the result and re-show the updated roll-up + repo row.
|
||||
|
||||
### Step 6 (mode `refresh-tokens`): Sweep tokens machine-wide — propose, approve, write
|
||||
|
||||
The token bill in Step 2 reflects the **last** sweep. `refresh-tokens` recomputes it: for every
|
||||
tracked repo it runs the manifest's always-loaded accounting and refreshes the machine-wide bill —
|
||||
the shared global layer (global CLAUDE.md + installed plugins + global agents/MCP) counted **once**,
|
||||
plus each repo's own project-config delta.
|
||||
|
||||
It writes only the ledger's token fields (never status or findings), is idempotent (a re-sweep
|
||||
replaces, never accumulates), and **skips — never aborts on** — any repo that can't be read. Tell
|
||||
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 $?
|
||||
```
|
||||
|
||||
Exit **0** = swept (or nothing to sweep — a benign no-op on an empty campaign), **3** = no/corrupt
|
||||
ledger. Read the result: `swept[]` (repos accounted for), `skipped[]` (each `{path, reason}`), and
|
||||
the refreshed `rollUp.tokens`. Re-render the **Machine-wide token bill** (Step 2) with the new
|
||||
numbers. If anything was skipped, name those repos plainly so the user knows the bill omits them
|
||||
(honest coverage — Verifiseringsplikt).
|
||||
|
||||
### Step 7 (mode `export`): Export a repo's plan to its own `docs/` — preview, approve, write
|
||||
|
||||
"Planer følger arbeidsstedet": a planned repo's action plan belongs in **that repo's** `docs/`,
|
||||
not buried in a session dir. This step copies it there, byte-faithfully.
|
||||
|
||||
**Preview first (read-only — never writes).** The repo must be tracked and have a linked session
|
||||
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 $?
|
||||
```
|
||||
|
||||
Exit **0** = previewable, **1** = tracked but not exportable yet, **3** = error (untracked repo,
|
||||
no/corrupt ledger). Read `~/.claude/config-audit/sessions/campaign-export.json` with the Read tool.
|
||||
|
||||
- **Exit 1 — read `problems`** and guide, then stop (nothing to export):
|
||||
- `no-session-linked` → "`<name>` has no linked audit session. Link one with
|
||||
`/config-audit campaign set-status <path> <status> --session <id>`, or audit + plan it first."
|
||||
- `no-action-plan` → "`<name>`'s session has no plan yet. Run `/config-audit plan` in that repo
|
||||
first, mark it `planned`, then export."
|
||||
- **Exit 0 — show, then ask.** Tell the user the destination (`targetPath`) and a **short** preview
|
||||
— 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 $?
|
||||
```
|
||||
|
||||
Confirm: "Plan exported to `<targetPath>`." Then hand off to the **existing** execution machinery
|
||||
(Block 4c reuses it — this command does not run it for you):
|
||||
|
||||
> To **execute**: run `/config-audit implement` in `<path>` — it backs up every changed file,
|
||||
> applies the plan, and verifies. To **undo**: `/config-audit rollback`. When done, record it:
|
||||
> `/config-audit campaign set-status <path> implemented`.
|
||||
|
||||
### Step 8: Next steps
|
||||
|
||||
Tailor to where the campaign stands:
|
||||
|
||||
- **Just initialized / few repos:** "`/config-audit campaign add --discover ~/repos` to enroll
|
||||
your repos."
|
||||
- **Pending repos exist:** "Run `/config-audit` in a pending repo to audit it, then
|
||||
`/config-audit campaign set-status <path> audited` to record the result here."
|
||||
- **Audited but not planned:** "`/config-audit plan` in that repo, then mark it `planned`."
|
||||
- **Planned repos exist:** "`/config-audit campaign export <path>` to drop the plan into that
|
||||
repo's own `docs/`, then `/config-audit implement` there to execute it (backup + verify)."
|
||||
- **Backlog has items:** point at the top backlog repo and the natural next verb for its status
|
||||
(audit → plan → export → implement).
|
||||
- **No token bill yet (or config changed):** "`/config-audit campaign refresh-tokens` to compute
|
||||
the machine-wide always-loaded cost — the shared global layer is the biggest lever."
|
||||
- Always: the campaign survives this session — re-run `/config-audit campaign` anytime to see
|
||||
the machine-wide picture.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Read-only report, human-approved writes.** `campaign-cli` never writes; every mutation —
|
||||
ledger changes via `campaign-write-cli`, plan exports via `campaign-export-cli --write` — happens
|
||||
only after explicit approval, exactly mirroring how `/config-audit knowledge-refresh` gates
|
||||
register writes. The export-CLI's default (no `--write`) is a read-only preview.
|
||||
- **Deterministic core, not byte-stable command.** The lib transforms + all three CLIs are
|
||||
unit-tested and deterministic (`--reference-date` injected); this command's orchestration is
|
||||
judgment-driven and deliberately **not** in the snapshot suite (like `/config-audit optimize`
|
||||
and `knowledge-refresh`).
|
||||
- The `-cli` suffix keeps all three CLIs out of the scan-orchestrator, so the scanner count and
|
||||
the byte-stable snapshot suite are unaffected.
|
||||
- **THIN scope:** ledger + roll-up (findings + machine-wide token bill) + status + a cross-repo
|
||||
prioritized backlog + plan export. The token sweep reuses the manifest's existing always-loaded
|
||||
accounting per repo — it does not reinvent measurement, only aggregates it machine-wide.
|
||||
Execution is **not** reinvented here — `export` drops a planned repo's plan into its own `docs/`
|
||||
(a durable record), and the user runs the existing `/config-audit implement` (backup + apply +
|
||||
verify) + `/config-audit rollback` to execute and undo. This command tracks state and routes the
|
||||
work; it does not run audits or apply fixes itself.
|
||||
|
|
@ -75,7 +75,13 @@ Manage and clean up accumulated config-audit sessions in `~/.claude/config-audit
|
|||
- Warn before deleting active sessions: "Session {id} is still active (phase: {phase}). Delete anyway?"
|
||||
|
||||
6. **Execute cleanup**:
|
||||
- For each session to delete: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
|
||||
- **Validate the id before it ever reaches `rm -rf`.** Each `{session-id}`
|
||||
must match `^[0-9]{8}_[0-9]{6}$` (the id format `discover` generates) or be
|
||||
an existing directory name read verbatim from the Glob in step 1. If an id
|
||||
is empty or fails to match, **refuse to delete it**, report it, and continue
|
||||
with the rest. An empty id expands the path to
|
||||
`~/.claude/config-audit/sessions//`, which deletes *every* session.
|
||||
- For each validated session: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
|
||||
- Track deleted count and freed space
|
||||
|
||||
7. **Output summary**:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: config-audit
|
||||
description: Claude Code Configuration Intelligence - audit, analyze, and optimize your configuration
|
||||
argument-hint: "[posture|tokens|manifest|feature-gap|fix|rollback|plan|implement|help|discover|analyze|interview|drift|plugin-health|whats-active|status|cleanup]"
|
||||
argument-hint: "[posture|tokens|manifest|feature-gap|optimize|fix|rollback|plan|implement|help|discover|analyze|interview|drift|plugin-health|whats-active|campaign|knowledge-refresh|status|cleanup]"
|
||||
allowed-tools: Read, Write, Glob, Grep, Bash, Agent, AskUserQuestion
|
||||
model: opus
|
||||
---
|
||||
|
|
@ -17,6 +17,7 @@ If a subcommand is provided, route to it:
|
|||
- `tokens` → `/config-audit:tokens`
|
||||
- `manifest` → `/config-audit:manifest`
|
||||
- `feature-gap` → `/config-audit:feature-gap`
|
||||
- `optimize` → `/config-audit:optimize`
|
||||
- `fix` → `/config-audit:fix`
|
||||
- `rollback` → `/config-audit:rollback`
|
||||
- `plan` → `/config-audit:plan`
|
||||
|
|
@ -28,6 +29,8 @@ If a subcommand is provided, route to it:
|
|||
- `drift` → `/config-audit:drift`
|
||||
- `plugin-health` → `/config-audit:plugin-health`
|
||||
- `whats-active` → `/config-audit:whats-active`
|
||||
- `campaign` → `/config-audit:campaign`
|
||||
- `knowledge-refresh` → `/config-audit:knowledge-refresh`
|
||||
- `status` → `/config-audit:status`
|
||||
- `cleanup` → `/config-audit:cleanup`
|
||||
|
||||
|
|
@ -80,29 +83,41 @@ This is a silent infrastructure step — do NOT show output to the user.
|
|||
|
||||
### Step 3: Run scanners and posture assessment
|
||||
|
||||
Tell the user: **"Running 12 configuration scanners..."**
|
||||
Tell the user: **"Running 16 configuration scanners..."**
|
||||
|
||||
Run both scanners and posture in a single Bash command. Default mode runs the humanizer, so each finding in `scan-results.json` carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. If the user passed `--raw`, thread it through to both CLIs to get v5.0.0 verbatim output.
|
||||
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
if echo "$ARGUMENTS" | grep -qE -- '(^| )--raw( |$)'; then RAW_FLAG="--raw"; fi
|
||||
# Set to the ONE scope flag the detected scope calls for, otherwise leave empty.
|
||||
# Exactly one token: zsh does not word-split an unquoted expansion, so a variable
|
||||
# holding "--flag value" would reach argv as a single unrecognised argument.
|
||||
# A placeholder in square brackets does not start with a dash either, so both
|
||||
# CLIs' arg loops would take it as the TARGET PATH instead of a flag.
|
||||
SCOPE_FLAG="" # e.g. SCOPE_FLAG="--full-machine" or SCOPE_FLAG="--global"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json $SCOPE_FLAG $RAW_FLAG >/dev/null 2>/dev/null; ORCH_STATUS=$?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $SCOPE_FLAG $RAW_FLAG >/dev/null 2>~/.claude/config-audit/sessions/{session-id}/posture-stderr.txt; POSTURE_STATUS=$?
|
||||
echo "$ORCH_STATUS $POSTURE_STATUS"
|
||||
```
|
||||
|
||||
Use `--full-machine` for `full` scope, `--global` for `home` scope. For `repo` and `current`, pass the resolved path directly.
|
||||
|
||||
Check the echoed exit code:
|
||||
- `0`, `1`, or `2` → continue normally
|
||||
- `3` → tell user: "Scanner encountered an unexpected error. Try `/config-audit posture` for a quick check instead." and stop.
|
||||
Two exit codes are echoed — the orchestrator's first, posture's second. They must be read **independently**; a single trailing `echo $?` would report only the last command, hiding an orchestrator failure behind posture's success.
|
||||
|
||||
- both in `0`, `1`, `2` → continue normally
|
||||
- **either** is `3` → tell user: "Scanner encountered an unexpected error. Try `/config-audit posture` for a quick check instead." and stop.
|
||||
|
||||
Posture's stderr goes to a **file**, not `/dev/null`: it carries the humanized scorecard headline that step 6 renders, and that headline exists nowhere in the JSON payload. Writing it to a file keeps UX rule 2 intact (the user still never sees raw scanner output) while leaving the text readable.
|
||||
|
||||
### Step 4: Analyze results
|
||||
|
||||
Tell the user: **"Scanners complete. Preparing your results..."**
|
||||
|
||||
Read BOTH output files using the Read tool:
|
||||
Read all three output files using the Read tool:
|
||||
- `~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json`
|
||||
- `~/.claude/config-audit/sessions/{session-id}/posture.json`
|
||||
- `~/.claude/config-audit/sessions/{session-id}/posture-stderr.txt` — the humanized scorecard. Take the `Health: {grade} ({score}/100) — {prose}` headline from it; that prose is not in either JSON payload.
|
||||
|
||||
Extract these metrics from the JSON:
|
||||
|
||||
|
|
@ -143,7 +158,7 @@ Present results using this template. The humanizer has already replaced jargon-h
|
|||
|
||||
**Health: {overallGrade}** | {qualityAreaCount} areas scanned
|
||||
|
||||
{Use the headline line from the humanized stderr scorecard — it carries grade-context prose already. Avoid hardcoding a separate per-grade prose ladder.}
|
||||
{Use the `Health: …` headline read from `posture-stderr.txt` in step 4 — it carries grade-context prose already. Avoid hardcoding a separate per-grade prose ladder. If that file is missing or empty, say the grade plainly without inventing prose for it.}
|
||||
|
||||
Scanned {files_scanned} files | {real_finding_count} findings ({severity_breakdown})
|
||||
{If test_fixture_count > 0: "({test_fixture_count} additional findings in test fixtures were excluded.)"}
|
||||
|
|
@ -157,9 +172,11 @@ Scanned {files_scanned} files | {real_finding_count} findings ({severity_breakdo
|
|||
| Settings | {grade} | {count} | {status} |
|
||||
| Hooks | {grade} | {count} | {status} |
|
||||
| Rules | {grade} | {count} | {status} |
|
||||
| MCP Servers | {grade} | {count} | {status} |
|
||||
| MCP | {grade} | {count} | {status} |
|
||||
| Imports | {grade} | {count} | {status} |
|
||||
| Conflicts | {grade} | {count} | {status} |
|
||||
| Token Efficiency | {grade} | {count} | {status} |
|
||||
| Plugin Hygiene | {grade} | {count} | {status} |
|
||||
|
||||
{For the status column, use the humanized title from the most-severe finding in that area, or a one-phrase plain-language summary. Findings carry userImpactCategory which already groups by impact bucket — use that vocabulary, not raw scanner names.}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,14 +72,19 @@ Run the scan orchestrator silently to discover and scan files. Default mode emit
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set to the flag itself for full/home scope, otherwise leave empty. Never pass
|
||||
# a placeholder wrapped in square brackets: it does not start with a dash, so
|
||||
# the orchestrator's arg loop takes it as the SCAN TARGET and silently scans a
|
||||
# path that does not exist.
|
||||
SCOPE_FLAGS="" # e.g. SCOPE_FLAGS="--full-machine" or SCOPE_FLAGS="--global"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json $SCOPE_FLAGS $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Check exit code: 0/1/2 → normal. 3 → "Discovery encountered an error. Try a narrower scope."
|
||||
|
||||
### Step 6: Save scope and state
|
||||
|
||||
Write `scope.yaml` and `state.yaml` to session directory. Update state with `current_phase: "discover"`, `next_phase: "analyze"`.
|
||||
Write `scope.yaml` and `state.yaml` to session directory. Update state with all four fields `.claude/rules/state-management.md` requires: `current_phase: "discover"`, `completed_phases: [discover]`, `next_phase: "analyze"`, and `updated_at`. The last two are what make an interrupted run resumable.
|
||||
|
||||
### Step 7: Present summary
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,10 @@ Tell the user: **"Saving current configuration as baseline..."**
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --save --name <baseline-name> $RAW_FLAG 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<path>" --save --name "<baseline-name>" --json $RAW_FLAG 2>/dev/null
|
||||
```
|
||||
|
||||
Read stdout for confirmation. Tell the user:
|
||||
`--save` writes its human confirmation to **stderr**, which `2>/dev/null` discards — pass `--json` so the `{saved, name, path}` object lands on stdout. Read stdout for confirmation. Tell the user:
|
||||
|
||||
```markdown
|
||||
### Baseline Saved
|
||||
|
|
@ -50,10 +50,16 @@ Tell the user: **"Comparing current configuration against baseline..."**
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --baseline <name> $RAW_FLAG 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<path>" --baseline "<name>" --output-file /tmp/config-audit-drift.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read stdout. In default mode the diff sections are humanized — finding titles, descriptions, and recommendations have already been replaced with plain-language equivalents. New/resolved/changed finding lists carry `userImpactCategory`, `userActionLanguage`, and `relevanceContext` so you can group and prioritize without re-deriving severity prose. If `--raw` was passed, the v5.0.0 diff is verbatim — present it in a code block as-is.
|
||||
Exit codes: `0` = stable/improving, `1` = degrading (both normal — present the result either way), `3` = a real error.
|
||||
|
||||
Then read `/tmp/config-audit-drift.json` with the **Read tool**. The default-mode report itself goes to stderr, so `--output-file` is the only way this command sees the diff at all.
|
||||
|
||||
**Check `_baselineAnchor` first.** If the baseline was saved from a different directory than the one being scanned, the diff is not a drift signal — every baseline finding shows as "resolved" and every current finding as "new", which renders as a falsely reassuring "improving" trend. When the anchor differs, say so plainly and offer to re-anchor with `/config-audit drift --save` instead of presenting the numbers as drift.
|
||||
|
||||
In default mode the diff sections are humanized — finding titles, descriptions, and recommendations have already been replaced with plain-language equivalents. New/resolved/changed finding lists carry `userImpactCategory`, `userActionLanguage`, and `relevanceContext` so you can group and prioritize without re-deriving severity prose. If `--raw` was passed, the v5.0.0 diff is verbatim — present it in a code block as-is.
|
||||
|
||||
If baseline not found, tell the user:
|
||||
|
||||
|
|
@ -96,9 +102,14 @@ When iterating new/resolved findings, prefer `userActionLanguage` over raw `seve
|
|||
If `$ARGUMENTS` contains `--list`:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list --output-file /tmp/config-audit-baselines.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
The human-readable listing goes to stderr, which `2>/dev/null` discards — read
|
||||
`/tmp/config-audit-baselines.json` with the Read tool and render the `baselines`
|
||||
array (`name`, `findingCount`, `savedAt`) as a table. If the array is empty, tell
|
||||
the user no baselines are saved yet and point at `/config-audit drift --save`.
|
||||
|
||||
### What's next
|
||||
|
||||
After viewing drift:
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Generate session ID (`YYYYMMDD_HHmmss`) if no active session exists.
|
|||
mkdir -p ~/.claude/config-audit/sessions/{session-id}/findings 2>/dev/null
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $RAW_FLAG 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
If exit code is non-zero: "Assessment couldn't run. Check that the path exists and contains configuration files."
|
||||
|
|
@ -55,8 +55,8 @@ Extract GAP findings from `scannerEnvelope.scanners` (find scanner with `scanner
|
|||
|
||||
Detect project context:
|
||||
```bash
|
||||
test -f <target-path>/package.json && echo "has_package_json" || echo "no_package_json"
|
||||
ls <target-path>/*.py <target-path>/requirements.txt <target-path>/pyproject.toml 2>/dev/null | head -3
|
||||
test -f "<target-path>"/package.json && echo "has_package_json" || echo "no_package_json"
|
||||
ls "<target-path>"/*.py "<target-path>"/requirements.txt "<target-path>"/pyproject.toml 2>/dev/null | head -3
|
||||
```
|
||||
|
||||
### Step 4: Build numbered recommendations
|
||||
|
|
@ -128,15 +128,23 @@ If the user picks numbers: parse the selection and proceed to Step 6.
|
|||
|
||||
For each selected recommendation:
|
||||
|
||||
1. **Create backup** of any files that will be modified:
|
||||
1. **Create backup** of any files that will be modified.
|
||||
|
||||
Do **not** reach for `fix-cli.mjs` here. It is dry-run by default, so calling
|
||||
it without `--apply` creates no backup at all and returns `backupId: null` —
|
||||
and calling it *with* `--apply` would execute unrelated auto-fixes that the
|
||||
user never selected. Copy the files yourself:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <target-path> --json 2>/dev/null
|
||||
BACKUP_DIR=~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files
|
||||
mkdir -p "$BACKUP_DIR" 2>/dev/null
|
||||
# repeat per file that will be touched:
|
||||
cp "<file-to-modify>" "$BACKUP_DIR/" 2>/dev/null; echo $?
|
||||
```
|
||||
Or create manual backup:
|
||||
```bash
|
||||
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
|
||||
```
|
||||
Copy each file that will be touched.
|
||||
|
||||
Tell the user where the copies landed. These are plain file copies — they are
|
||||
restored by copying them back, **not** by `/config-audit rollback`, which only
|
||||
knows about backups written by `fix` and `implement`.
|
||||
|
||||
2. **Apply the template** from gap-closure-templates.md. Use the Write or Edit tool to create or modify the relevant configuration file.
|
||||
|
||||
|
|
@ -151,9 +159,12 @@ Implementing 3 recommendations...
|
|||
|
||||
4. **Verify** by re-running posture:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --json --output-file /tmp/config-audit-verify-$$.json 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --json --output-file /tmp/config-audit-verify.json >/dev/null 2>/dev/null
|
||||
```
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-verify.json` for the new `overallGrade`
|
||||
and score — stdout is discarded on purpose (the same envelope is 255 KB).
|
||||
|
||||
### Step 7: Show results
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
|
|
@ -15,8 +15,13 @@ Auto-fix deterministic configuration issues. Scans, plans fixes, backs up origin
|
|||
- `$ARGUMENTS` may contain:
|
||||
- A target path (default: current working directory)
|
||||
- `--dry-run`: Show fix plan without applying
|
||||
- `--global`: Include user-scope config (`~/.claude`) in the scan **and** the fix run
|
||||
- `--raw`: Pass-through to scanners; produces v5.0.0 verbatim envelope (bypasses the humanizer) for byte-stable diff tooling
|
||||
|
||||
`--global` must be passed to **every** step below. The scan that builds the table and
|
||||
the scan that plans the fixes are two different runs; if only one of them sees the
|
||||
user scope, the plan and the table describe different config.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Greet and scan
|
||||
|
|
@ -34,7 +39,11 @@ Parse flags and run scanners silently. Default mode emits humanized JSON — eac
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <path> --output-file /tmp/config-audit-fix-scan-$$.json [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set to --global when the user asked for global scope, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the arg loop
|
||||
# would take it as the scan/fix TARGET instead of a flag.
|
||||
GLOBAL_FLAG=""
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs "<path>" --output-file /tmp/config-audit-fix-scan.json $GLOBAL_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check your configuration."
|
||||
|
|
@ -44,13 +53,31 @@ Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check
|
|||
Run fix planner silently. The fix-cli emits humanized prose to stderr in default mode and v5.0.0-shape JSON to stdout when `--json` is set; we use `--json` here for structured data and let the humanizer-aware rendering layer (this command's prose output below) supply the plain-language wording from the scan envelope above:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --json 2>/dev/null
|
||||
# Re-assign here: each fenced block is its own Bash call, so the value
|
||||
# set in Step 1 is empty by the time this block runs.
|
||||
GLOBAL_FLAG="" # --global when the user asked for global scope
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" $GLOBAL_FLAG --output-file /tmp/config-audit-fix-plan.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read the JSON output using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (`/tmp/config-audit-fix-scan-$$.json`) by finding ID to recover the humanized `title`/`description`/`recommendation` plus `userImpactCategory`/`userActionLanguage` for grouping.
|
||||
Exit codes: 0 = plan produced, 2 = one or more fixes failed (apply step only), 3 = argument or tool error. On 3, show the stderr message — an unknown flag is rejected by design, not silently ignored.
|
||||
|
||||
Read `/tmp/config-audit-fix-plan.json` using the Read tool. Cross-reference each fix-plan entry against the humanized scan envelope (`/tmp/config-audit-fix-scan.json`) by finding ID to recover the humanized `title`/`description`/`recommendation` plus `userImpactCategory`/`userActionLanguage` for grouping.
|
||||
|
||||
### Step 3: Present fix plan
|
||||
|
||||
First classify where the auto-fixable entries write. With `--global` the run
|
||||
takes `~/.claude` into the *fix* pass, so machine-wide and project rows land in
|
||||
one table; without a marker they read as equally local. Pass one `--target` per
|
||||
distinct file in the auto-fixable set:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-fix-scope.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit 0 = classified; 3 = argument error (show the stderr message). Read
|
||||
`/tmp/config-audit-fix-scope.json` and carry each file's `scopeClass` into the
|
||||
table below.
|
||||
|
||||
Show what will be fixed and what needs manual attention. Group by `userActionLanguage` so the urgency phrasing stays consistent with the rest of the toolchain:
|
||||
|
||||
```markdown
|
||||
|
|
@ -62,9 +89,9 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
|
|||
|
||||
#### {userActionLanguage}
|
||||
|
||||
| # | ID | Issue | File |
|
||||
|---|-----|-------|------|
|
||||
| 1 | {id} | {humanized title} | {file} |
|
||||
| # | ID | Issue | File | Scope |
|
||||
|---|-----|-------|------|-------|
|
||||
| 1 | {id} | {humanized title} | {file} | {scopeClass, or blank when "in-repo"} |
|
||||
|
||||
**Manual ({M} issues — require human judgment), grouped by impact:**
|
||||
|
||||
|
|
@ -77,7 +104,10 @@ Show what will be fixed and what needs manual attention. Group by `userActionLan
|
|||
|
||||
### Step 4: Confirm with user
|
||||
|
||||
If not `--dry-run`, ask for confirmation:
|
||||
If not `--dry-run`, ask for confirmation. Render each distinct string in the scope
|
||||
payload's `disclosures[]` verbatim first.
|
||||
|
||||
When `requiresApproval` is false:
|
||||
|
||||
```
|
||||
AskUserQuestion:
|
||||
|
|
@ -88,24 +118,59 @@ AskUserQuestion:
|
|||
- "Cancel"
|
||||
```
|
||||
|
||||
When `requiresApproval` is true — which is what `--global` produces, since
|
||||
`~/.claude` is machine-wide — the question MUST say so and the safe option MUST
|
||||
come first:
|
||||
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "{K} of {N} fixes change configuration outside this project. Apply all {N}?"
|
||||
options:
|
||||
- "Show dry-run only"
|
||||
- "Yes — apply all, including outside this project"
|
||||
- "Cancel"
|
||||
```
|
||||
|
||||
### Step 5: Apply fixes
|
||||
|
||||
If confirmed, apply:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --apply --json 2>/dev/null
|
||||
# Re-assign here: each fenced block is its own Bash call, so the value
|
||||
# set in Step 1 is empty by the time this block runs.
|
||||
GLOBAL_FLAG="" # --global when the user asked for global scope
|
||||
# --approve-scope carries the answer the user just gave to the Step-4 question.
|
||||
# The engine runs the same scope gate as Step 3 and withholds a `require-ok`
|
||||
# write on its own, so leaving this empty after the user answered "Yes — apply
|
||||
# all, including outside this project" makes the run refuse the very fixes they
|
||||
# approved. Set it ONLY on that answer — never as a default, and never because
|
||||
# Step 3 already classified the targets: classifying is not approving.
|
||||
APPROVE_SCOPE="" # --approve-scope when the user approved the outside-project fixes
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs "<path>" --apply $GLOBAL_FLAG $APPROVE_SCOPE --output-file /tmp/config-audit-fix-applied.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read the JSON output to get applied/failed counts and backup location.
|
||||
Read `/tmp/config-audit-fix-applied.json` with the Read tool to get applied/failed counts and the backup ID. Exit code 2 means at least one fix failed — report it; `failed[]` carries the reason per fix.
|
||||
|
||||
The payload also carries the engine's own scope verdict. When it reads
|
||||
`"status": "refused"` with `"reason": "scope-gate"`, nothing was written: render
|
||||
each line of `disclosures` verbatim, then ask the Step-4 question again rather
|
||||
than re-running with the flag on the user's behalf. A refusal is a verdict about
|
||||
a config that WAS examined, so the exit code stays in the normal 0/1/2 range —
|
||||
do not report it as a tool error.
|
||||
|
||||
### Step 6: Show results
|
||||
|
||||
Run a quick posture check to measure improvement:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <path> --json --output-file /tmp/config-audit-fix-posture-$$.json 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<path>" --json --output-file /tmp/config-audit-fix-posture.json >/dev/null 2>/dev/null
|
||||
```
|
||||
|
||||
Use the Read tool on `/tmp/config-audit-fix-posture.json` and take `overallGrade`
|
||||
and the score from there. That read is the only source for the numbers below:
|
||||
`--json` prints the same envelope to stdout, but 255 KB of raw JSON in the
|
||||
transcript to recover one grade is exactly the waste this plugin exists to find.
|
||||
|
||||
Present results:
|
||||
|
||||
```markdown
|
||||
|
|
@ -139,7 +204,8 @@ Run `/config-audit plan` to get a step-by-step guide for addressing these.
|
|||
|
||||
## Safety
|
||||
|
||||
- Backup is **mandatory** — every fix creates a backup first
|
||||
- Backup is **mandatory** — every fix creates a backup first, including file renames (the source file is backed up before the rename, so rollback can restore it at its original path)
|
||||
- Dry-run by default — user must confirm before changes
|
||||
- Verify after fix — re-scans to confirm findings resolved
|
||||
- Verify after fix — re-scans in the **same scope** the fix run used, so a `--global` run is verified against user scope too
|
||||
- Rollback always available — `/config-audit rollback <backup-id>`
|
||||
- A failed fix is reported, never swallowed — exit 2 plus a `failed[]` entry
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
|||
| `/config-audit tokens` | prompt-cache token hotspots; optional `--accurate-tokens` API calibration |
|
||||
| `/config-audit manifest` | Ranked table of every system-prompt token source |
|
||||
| `/config-audit feature-gap` | Deep analysis of features you're not using |
|
||||
| `/config-audit optimize` | Optimization lens — config that works but fits a better mechanism (procedure→skill, lifecycle→hook, path→rule, never→permission) |
|
||||
| `/config-audit fix` | Auto-fix deterministic issues; a copy of every changed file is saved first so you can roll back with one command |
|
||||
| `/config-audit rollback` | Restore configuration from a saved copy |
|
||||
|
||||
|
|
@ -53,6 +54,8 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
|||
| `/config-audit drift` | Compare current config against a saved baseline |
|
||||
| `/config-audit plugin-health` | Audit plugin structure and the metadata block at the top of each command/agent file |
|
||||
| `/config-audit whats-active` | Show active plugins/skills/MCP/hooks/CLAUDE.md with token estimates |
|
||||
| `/config-audit campaign` | Track a machine-wide audit campaign across repos — per-repo status + roll-up, resumable across sessions (human-approved writes) |
|
||||
| `/config-audit knowledge-refresh` | Keep the built-in best-practices knowledge current — flags guidance that's gone stale and checks sources for updates (changes need your approval) |
|
||||
|
||||
### Utility
|
||||
|
||||
|
|
|
|||
|
|
@ -22,24 +22,47 @@ Execute the action plan with full backup, verification, and rollback support.
|
|||
|
||||
### Step 1: Parse flags, load and verify
|
||||
|
||||
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
|
||||
prompt in Step 4 is **not** a shell, so a variable assigned in a bash block cannot
|
||||
be referenced from it. Substitute `{mode}` literally with `--raw` or `humanized`.
|
||||
|
||||
Find the most recent session with a plan (use the **Glob tool** for
|
||||
`~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match — Read
|
||||
does not expand `*`). If none: "No action plan found. Run `/config-audit plan` first."
|
||||
|
||||
Use the Read tool on the action plan and count actions.
|
||||
|
||||
Now classify where those actions actually write. A plan whose actions target
|
||||
`~/.claude/CLAUDE.md` and a plan whose actions target `./CLAUDE.md` are the same
|
||||
count of actions — presenting only the count made a machine-wide change look
|
||||
identical to a project-local one. Pass one `--target` per distinct file the plan
|
||||
touches (absolute paths, as written in the plan):
|
||||
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-implement-scope.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Find the most recent session with a plan. If none: "No action plan found. Run `/config-audit plan` first."
|
||||
|
||||
Use the Read tool on the action plan and count actions. Tell the user:
|
||||
Exit 0 = classified; 3 = argument error (show the stderr message). Read
|
||||
`/tmp/config-audit-implement-scope.json`. Tell the user:
|
||||
|
||||
```
|
||||
## Implementing Action Plan
|
||||
|
||||
Found {N} actions to execute across {M} files.
|
||||
A backup will be created before any changes are made.
|
||||
|
||||
{For each target whose `gate` is not "silent", one line:}
|
||||
- `{target}` — {scopeClass}
|
||||
```
|
||||
|
||||
### Step 2: Get user approval
|
||||
|
||||
Render each distinct string in `disclosures[]` verbatim before asking — they are
|
||||
already plain-language, and the payload carries them so this template never has
|
||||
to restate what a scope class means.
|
||||
|
||||
When `requiresApproval` is false, ask as before:
|
||||
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "Ready to implement {N} actions? Backup created automatically — you can roll back with one command."
|
||||
|
|
@ -49,15 +72,47 @@ AskUserQuestion:
|
|||
- "Cancel"
|
||||
```
|
||||
|
||||
### Step 3: Create backup
|
||||
When `requiresApproval` is true, the question MUST name the scope, and the
|
||||
safe option MUST come first — a plan that edits machine-wide configuration
|
||||
affects every project the user opens, so the default must not be "proceed":
|
||||
|
||||
Create backup silently:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "This plan changes configuration outside this project ({K} of {M} files). Proceed?"
|
||||
options:
|
||||
- "Review plan first" (then show the plan file path)
|
||||
- "Yes — change files outside this project too"
|
||||
- "Cancel"
|
||||
```
|
||||
|
||||
Copy each file to be modified. Generate `manifest.yaml` with checksums.
|
||||
### Step 3: Create backup
|
||||
|
||||
Create the backup through the code that owns the format. Pass one `--target` per
|
||||
pre-existing file the plan will MODIFY, and one `--created` per file the plan
|
||||
will CREATE — a backup cannot hold a file that does not exist yet, so those are
|
||||
recorded rather than copied, and `/config-audit rollback` reads them back to tell
|
||||
the user which files it is leaving behind.
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --create --target "<file-1>" --target "<file-2>" --created "<new-file-1>" --repo "$PWD" --output-file /tmp/config-audit-implement-backup.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
| Exit | Meaning |
|
||||
|------|---------|
|
||||
| 0 | every target was backed up |
|
||||
| 1 | at least one target was not there — read `skipped[]` before continuing |
|
||||
| 3 | the CLI could not run (show the stderr message); do not edit anything |
|
||||
|
||||
Read `/tmp/config-audit-implement-backup.json`. The payload's `backupId` is the
|
||||
ID to quote from here on — **never re-derive it**. Shell state does not survive
|
||||
to the next block, and a second `date` call that straddles a second boundary
|
||||
would hand the user a rollback ID that does not exist. Use it wherever
|
||||
`{backup-id}` appears below.
|
||||
|
||||
On exit 1, name the `skipped[]` paths before doing anything else: the plan is
|
||||
about to change files that have no backup behind them. If any skipped path is one
|
||||
the plan MODIFIES (rather than creates), stop and report — that action cannot be
|
||||
rolled back.
|
||||
|
||||
Tell the user: **"Backup created. Implementing actions..."**
|
||||
|
||||
|
|
@ -71,13 +126,15 @@ Agent(subagent_type: "config-audit:implementer-agent")
|
|||
prompt: |
|
||||
Execute action: {action-id}
|
||||
File: {file-path}, Type: {create|modify|delete}
|
||||
Mode: $RAW_FLAG (empty = humanized progress prose; "--raw" = v5.0.0 verbatim)
|
||||
Mode: {mode} ("humanized" = humanized progress prose; "--raw" = v5.0.0 verbatim)
|
||||
Details: {changes}
|
||||
Verify backup exists, make change, validate syntax.
|
||||
When logging progress, use the humanized title/userActionLanguage
|
||||
fields from the action plan (the planner already rendered them) —
|
||||
do not re-derive severity prose. Append result to:
|
||||
~/.claude/config-audit/sessions/{session-id}/implementation-log.md
|
||||
Append with Bash `>>` (heredoc) — NEVER the Write tool on this log;
|
||||
parallel agents share it and a full-file Write clobbers their entries.
|
||||
```
|
||||
|
||||
Show progress between groups using the humanized titles already present in the action plan:
|
||||
|
|
@ -100,7 +157,19 @@ Agent(subagent_type: "config-audit:verifier-agent")
|
|||
1. Modified files exist and are syntactically valid
|
||||
2. New files created correctly
|
||||
3. No new conflicts introduced
|
||||
Report to: ~/.claude/config-audit/sessions/{session-id}/implementation-log.md
|
||||
Return your findings as your final message. Do NOT write them to a file —
|
||||
this agent is read-only by design (tools: Read, Glob, Grep) and has no
|
||||
write tool; instructing it to write a report is a contract it cannot keep.
|
||||
```
|
||||
|
||||
Append the verifier's returned findings to the log yourself, with Bash `>>`
|
||||
(heredoc) — never the Write tool, for the same reason as Step 4:
|
||||
|
||||
```bash
|
||||
cat >> ~/.claude/config-audit/sessions/{session-id}/implementation-log.md <<'EOF'
|
||||
## Verification
|
||||
{verifier findings}
|
||||
EOF
|
||||
```
|
||||
|
||||
If verifier finds issues: one retry with implementer agent. If still failing: report and suggest rollback.
|
||||
|
|
@ -112,27 +181,51 @@ If verifier finds issues: one retry with implementer agent. If still failing: re
|
|||
|
||||
**{succeeded} succeeded** | {failed} failed | {skipped} skipped
|
||||
|
||||
{If score improved, run quick posture and show:}
|
||||
Score impact: {old_grade} → {new_grade} (+{delta} points)
|
||||
|
||||
{If failed > 0:}
|
||||
{failed} action(s) couldn't be completed — see log for details.
|
||||
|
||||
**Backup location:** `~/.claude/config-audit/backups/{timestamp}/`
|
||||
**Rollback:** `/config-audit rollback {timestamp}`
|
||||
**Backup location:** `~/.claude/config-audit/backups/{backup-id}/`
|
||||
**Rollback:** `/config-audit rollback {backup-id}`
|
||||
**Full log:** `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
|
||||
```
|
||||
|
||||
**On reporting a score.** Only quote a grade *change* if the pre-change grade was
|
||||
actually captured before Step 4 ran. Once the files are edited, only the new grade
|
||||
is measurable — a delta computed after the fact has no source and must not be
|
||||
invented. To offer one, measure first in Step 1 and again here:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file /tmp/config-audit-implement-posture.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Then Read `/tmp/config-audit-implement-posture.json`. Both the `--output-file` and
|
||||
the `2>/dev/null` are required by the output rules — a bare scanner call would put
|
||||
diagnostic output in front of the user. If no pre-change grade was captured, report
|
||||
the new grade alone and say nothing about a delta.
|
||||
|
||||
### Step 7: Update state
|
||||
|
||||
Update `state.yaml` with `current_phase: "implement"`, `next_phase: null`.
|
||||
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires:
|
||||
|
||||
- `current_phase: "implement"`
|
||||
- `completed_phases`: append `implement` to the existing array (read it first; never replace it)
|
||||
- `next_phase: null`
|
||||
- `updated_at`: current timestamp
|
||||
|
||||
A full-file Write that names only two of the four silently deletes the other two.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the user requests rollback at any point:
|
||||
1. Read `manifest.yaml` from backup
|
||||
2. Restore each file and verify checksums
|
||||
3. Delete newly created files
|
||||
1. Run `/config-audit rollback {backup-id}` — it drives `scanners/rollback-cli.mjs`,
|
||||
which verifies each checksum before and after writing. Do not restore by hand:
|
||||
a copy performs neither check, and the result cannot be reported as verified.
|
||||
2. Read the restore payload and report the per-file `status` it returns
|
||||
3. **Report — do not delete — the files this run created.** Rollback restores from
|
||||
backup, and no backup can exist for a file that did not exist before. Those
|
||||
paths stay on disk; `/config-audit rollback` lists them under "Left in place"
|
||||
so the user can remove them deliberately. Promising deletion here would leave a
|
||||
half-restored config that reads as a clean rollback.
|
||||
4. Update state to `rolled_back`
|
||||
|
||||
## Error Handling
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ Gather user preferences to inform the action plan.
|
|||
|
||||
## IMPORTANT: Inline Execution Only
|
||||
|
||||
This command runs AskUserQuestion **directly in the main context** — NOT via a Task subagent.
|
||||
AskUserQuestion requires synchronous terminal interaction and does not work when delegated to a Task subagent.
|
||||
This command runs AskUserQuestion **directly in the main context** — NOT via an `Agent` subagent.
|
||||
AskUserQuestion requires synchronous terminal interaction and does not work when delegated to an `Agent` subagent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -32,10 +32,29 @@ AskUserQuestion requires synchronous terminal interaction and does not work when
|
|||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
```
|
||||
|
||||
1. **Load session state**: Verify analysis phase completed, read analysis report for context
|
||||
2. **Conduct interview inline**: Use AskUserQuestion tool directly (NOT via Task). Adapt questions based on analysis findings.
|
||||
1. **Resolve the session, then load its state**:
|
||||
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
Sort by modification time — the most recently modified session wins
|
||||
```
|
||||
|
||||
Every path below substitutes that session's id for `{session-id}`. Never guess
|
||||
it: if the Glob returns nothing, say "No audit session found — run
|
||||
`/config-audit discover` first" and exit. Read the session's `state.yaml` and
|
||||
verify `completed_phases` contains `analyze`; if it doesn't, tell the user
|
||||
analysis hasn't run yet and exit. Then read the analysis report for context.
|
||||
2. **Conduct interview inline**: Use AskUserQuestion tool directly (never delegate it to a subagent via `Agent` — a subagent cannot hold the interactive turn). Adapt questions based on analysis findings.
|
||||
3. **Save interview results**: Write to `~/.claude/config-audit/sessions/{session-id}/interview.md`
|
||||
4. **Update state** (see state-management rule)
|
||||
4. **Update state** (see state-management rule), with one bound specific to this
|
||||
command: interview is optional and can be run against a session that already
|
||||
moved past it. If `completed_phases` already contains a later phase (`plan`,
|
||||
`implement`, `verify`), do **not** rewind `current_phase` and do not re-add a
|
||||
phase already in `completed_phases` — append `interview` only if it is absent,
|
||||
leave `current_phase`/`next_phase` pointing at the furthest phase reached, and
|
||||
tell the user the preferences will apply the next time `/config-audit plan`
|
||||
runs. Rewinding a finished session is how its progress gets lost. Always set
|
||||
`updated_at` to the current timestamp, whichever branch above applies.
|
||||
5. **Output summary**
|
||||
|
||||
## Interview Questions
|
||||
|
|
|
|||
152
commands/knowledge-refresh.md
Normal file
152
commands/knowledge-refresh.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
name: config-audit:knowledge-refresh
|
||||
description: Keep the best-practices register fresh — flag stale entries, poll sources for new/changed practices, with human-approved writes only
|
||||
argument-hint: "[--stale-after N] [--no-candidates]"
|
||||
allowed-tools: Read, Write, Edit, Bash, WebSearch, WebFetch
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Config-Audit: Knowledge Refresh
|
||||
|
||||
The "living" part of the living knowledge base. The optimization lens (`/config-audit
|
||||
optimize`) is only as good as the best-practices register it reads — and Claude Code moves
|
||||
fast. This command keeps `knowledge/best-practices.json` current in two ways:
|
||||
|
||||
- **Stale check (deterministic):** every CONFIRMED entry carries a `source.verified` date.
|
||||
An entry older than the threshold (default **90 days**) is flagged for re-verification —
|
||||
its source may have changed since.
|
||||
- **Candidate poll (web):** scan the CC changelog + the Anthropic "Steering Claude Code"
|
||||
docs/blog for *new* best-practices the register doesn't yet hold, or *changed* guidance
|
||||
that contradicts an existing entry.
|
||||
|
||||
**The Iron rule (Verifiseringsplikt): nothing is ever auto-written.** Every change — a
|
||||
bumped `verified` date, an updated claim, a brand-new entry — is presented to the user and
|
||||
applied only on explicit approval, and only after the live source has actually been
|
||||
re-read. No unverified claim enters the register.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Parse arguments
|
||||
|
||||
From `$ARGUMENTS`:
|
||||
- `--stale-after N` → override the staleness threshold (integer days; default 90).
|
||||
- `--no-candidates` → run the deterministic stale check only; skip the web poll.
|
||||
|
||||
Tell the user what's happening:
|
||||
|
||||
```
|
||||
## Knowledge Refresh
|
||||
|
||||
Checking the best-practices register for stale entries (sources that may need
|
||||
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 $?
|
||||
fi
|
||||
```
|
||||
|
||||
Exit code **0** = all fresh, **1** = some stale (advisory, normal), **3** = real error →
|
||||
"The refresh check couldn't run — the register file may be missing or invalid."
|
||||
|
||||
### Step 3: Read the payload + present stale entries
|
||||
|
||||
Read `~/.claude/config-audit/sessions/knowledge-refresh.json` with the Read tool. It has
|
||||
`counts {total, stale, fresh}`, a `stale[]` array (each: `id, verified, ageDays, url,
|
||||
claim`), `referenceDate`, and `staleAfterDays`.
|
||||
|
||||
Present the stale entries as a markdown table (per the UX rules — never show the raw JSON):
|
||||
|
||||
| Entry | Claim (short) | Verified | Age (days) | Source |
|
||||
|-------|---------------|----------|------------|--------|
|
||||
|
||||
If `counts.stale === 0`, say so plainly: "✓ All N register entries were re-verified within
|
||||
the last {staleAfterDays} days." Then continue to the candidate poll (unless `--no-candidates`).
|
||||
|
||||
### Step 4: Candidate + source-change poll (web — skip if `--no-candidates`)
|
||||
|
||||
Tell the user this takes a moment ("Polling the changelog + Anthropic docs, ~20-40s...").
|
||||
|
||||
1. **Re-verify each stale entry.** `WebFetch` the entry's `source.url` and check whether the
|
||||
claim it backs is **still accurate**. Three outcomes:
|
||||
- *Still holds* → propose bumping `source.verified` to today (no claim change).
|
||||
- *Changed* → propose an updated `claim`/`recommendation` quoting the new source text.
|
||||
- *Cannot verify* (page gone, paywalled, contradicts) → propose **nothing**; flag it
|
||||
"needs manual review" (Verifiseringsplikt: never bump a date you couldn't confirm).
|
||||
2. **Look for new practices.** `WebSearch` the CC changelog and the "Steering Claude Code"
|
||||
blog/docs for steering/config guidance not already represented by an entry's `lensCheck`.
|
||||
For each genuine new practice, draft a **candidate** entry (next free `BP-<TOPIC>-NNN` id,
|
||||
`confidence: "confirmed"` only if a primary source confirms it — otherwise mark `inferred`
|
||||
and do **not** present it as user-facing).
|
||||
|
||||
### Step 5: Present everything for approval — write nothing yet
|
||||
|
||||
Group the proposals and ask the user to approve per item:
|
||||
|
||||
- **Re-verify (date bump):** "{id} — source re-read, claim still holds → bump verified to {today}?"
|
||||
- **Update (claim drift):** show the old vs. new claim + the quoted source line.
|
||||
- **New candidate:** show the drafted entry (id, claim, mechanism, recommendation, source).
|
||||
- **Needs manual review:** list, with why it couldn't be auto-verified. (No write offered.)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Report exactly what changed (ids + fields), and what was deferred to manual review.
|
||||
|
||||
### Step 7: Next steps
|
||||
|
||||
- `/config-audit optimize` — the lens now reads the refreshed register; re-run it to pick up
|
||||
any new or changed mechanism-fit rules.
|
||||
- Re-run `/config-audit knowledge-refresh --no-candidates` anytime for a quick staleness scan
|
||||
without the web poll.
|
||||
- Commit the register change (`knowledge/best-practices.json` + any `.md` mirror) with a
|
||||
`chore(knowledge):` message so the provenance bump is in git history.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Deterministic core, web-driven shell.** The stale classification is byte-stable and
|
||||
unit-tested (`tests/lib/knowledge-refresh.test.mjs`, `tests/scanners/knowledge-refresh-cli.test.mjs`);
|
||||
the candidate poll + writes are web/judgment-driven and deliberately **not** byte-stable
|
||||
(mirrors `/config-audit optimize`).
|
||||
- **Read-only CLI.** `knowledge-refresh-cli.mjs` never writes the register; all writes happen
|
||||
here, in the command, after approval.
|
||||
- The `-cli` suffix keeps it out of the scan-orchestrator, so the scanner count and the
|
||||
snapshot suite are unaffected.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: config-audit:manifest
|
||||
description: Show ranked token-source manifest — every CLAUDE.md, plugin, skill, MCP server, and hook ordered DESC by estimated tokens
|
||||
description: Show ranked token-source manifest — every CLAUDE.md, rule, agent, skill, output style, MCP server, and hook ordered DESC by estimated tokens, each tagged with its load pattern (always-loaded vs on-demand vs external), plus an always-loaded subtotal
|
||||
argument-hint: "[path] [--json]"
|
||||
allowed-tools: Read, Bash
|
||||
model: sonnet
|
||||
|
|
@ -10,6 +10,14 @@ model: sonnet
|
|||
|
||||
Produce a ranked, single-table view of every token source loaded for a given repo path. Where `whats-active` shows separate tables per category, `manifest` collapses everything into one ordered list — making it easy to see what's costing the most regardless of category.
|
||||
|
||||
Every source is tagged with its **load pattern**, derived from the published Claude Code loading model:
|
||||
|
||||
- **always** — enters context every turn before you type (project/user CLAUDE.md, unscoped rules, agents, output styles, MCP tool schemas). This is the cost that matters most: it is paid on *every* request.
|
||||
- **on-demand** — loaded only when needed (skill bodies on invoke, path-scoped rules on a matching file read).
|
||||
- **external** — runs outside the context window entirely (hooks).
|
||||
|
||||
The **always-loaded subtotal** is the headline number. Sources are component-level (a plugin contributes via its skills/rules/agents/output styles/hooks/MCP, each listed once — there is no coarse "plugin" roll-up, which would double-count).
|
||||
|
||||
## UX Rules (MANDATORY — from `.claude/rules/ux-rules.md`)
|
||||
|
||||
1. **Never show raw JSON or stderr output.** Always use `--output-file` + `2>/dev/null`.
|
||||
|
|
@ -31,10 +39,9 @@ First non-flag argument is the path (default `.`). Recognized flags:
|
|||
Tell the user: **"Building token-source manifest for `<path>`..."**
|
||||
|
||||
```bash
|
||||
TMPFILE="/tmp/ca-manifest-$$.json"
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file "$TMPFILE" $RAW_FLAG 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs "<path>" --output-file /tmp/config-audit-manifest.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
**Exit code handling:**
|
||||
|
|
@ -44,28 +51,36 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/manifest.mjs <path> --output-file "$TMPFILE"
|
|||
### Step 3: If `--json` was requested, cat the file and stop
|
||||
|
||||
```bash
|
||||
cat "$TMPFILE"
|
||||
cat /tmp/config-audit-manifest.json
|
||||
```
|
||||
|
||||
Do NOT render the table in JSON mode.
|
||||
|
||||
### Step 4: Read JSON and render
|
||||
|
||||
Use the Read tool on `$TMPFILE`. Extract `meta.repoPath`, `total`, and `sources[]`. Render the top 20 sources (or fewer if the manifest is shorter):
|
||||
Use the Read tool on `/tmp/config-audit-manifest.json`. Extract `meta.repoPath`, `total`, `summary`, and `sources[]`. Lead with the **always-loaded subtotal** (the headline), then render the top 20 sources (or fewer if the manifest is shorter):
|
||||
|
||||
```markdown
|
||||
**Token-source manifest for `<repoPath>`** — ~{total} tokens at startup
|
||||
**Token-source manifest for `<repoPath>`** — ~{total} tokens total
|
||||
|
||||
| Rank | Kind | Name | Source | Tokens |
|
||||
|------|------|------|--------|--------|
|
||||
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} |
|
||||
| ... | ... | ... | ... | ... |
|
||||
- 🔴 **~{summary.always.tokens} tokens enter context every turn** before you type ({summary.always.count} always-loaded sources)
|
||||
- 🟡 ~{summary.onDemand.tokens} tokens on-demand ({summary.onDemand.count} sources — loaded only when invoked / matched)
|
||||
- ⚪ ~{summary.external.tokens} tokens external ({summary.external.count} sources — hooks, run outside context)
|
||||
|
||||
| Rank | Kind | Name | Source | Tokens | Load |
|
||||
|------|------|------|--------|--------|------|
|
||||
| 1 | {kind} | `<name>` | {source} | ~{estimated_tokens} | {loadPattern} |
|
||||
| ... | ... | ... | ... | ... | ... |
|
||||
|
||||
_Load column: **always** / **on-demand** / **external**. Append `°` when `derivationConfidence` is `inferred` (no primary-doc row pins it exactly)._
|
||||
_Agent rows carry `model` and `effort`. When either is set, append it to the name — `` `reviewer` (haiku/low) `` — using `inherit` / `default` for the unset side. Leave the suffix off entirely when both are null; a row of "inherit/default" on every agent is noise, and `/config-audit feature-gap` is where that becomes a finding._
|
||||
_Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±15%._
|
||||
```
|
||||
|
||||
If `sources.length > 20`, follow the table with: _"Showing top 20 of {N} sources. Run with `--json` to see the full list."_
|
||||
|
||||
When narrating, prioritize the always-loaded subtotal: a large **always** source is worse than an equally large **on-demand** one, because it is paid on every request. Call out any single always-loaded source that dwarfs the rest.
|
||||
|
||||
### Step 5: Suggest next steps
|
||||
|
||||
```markdown
|
||||
|
|
@ -75,7 +90,7 @@ If `sources.length > 20`, follow the table with: _"Showing top 20 of {N} sources
|
|||
- `/config-audit feature-gap` — what *could* improve here, grouped by impact
|
||||
```
|
||||
|
||||
Tone:
|
||||
- High total (>50k): empathetic — "That's a heavy startup cost; tokens bullet anything you'd otherwise spend on the actual conversation."
|
||||
- Moderate (10–50k): neutral — "Reasonable. Skim the top 5 to see if anything is unexpectedly large."
|
||||
- Low (<10k): encouraging — "Tight setup. The model has plenty of room for the actual work."
|
||||
Tone (key on the **always-loaded subtotal** — the every-turn cost — not the grand total):
|
||||
- High always-loaded (>40k): empathetic — "That's a heavy per-turn cost; it taxes every request before you've typed a word. Look at the largest always-loaded sources first."
|
||||
- Moderate (10–40k): neutral — "Reasonable. Skim the top always-loaded sources to see if anything is unexpectedly large."
|
||||
- Low (<10k): encouraging — "Tight setup. The model has plenty of room for the actual work each turn."
|
||||
|
|
|
|||
295
commands/optimize.md
Normal file
295
commands/optimize.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
---
|
||||
name: config-audit:optimize
|
||||
description: Optimization lens — config that works but would fit a better mechanism (procedure→skill, lifecycle→hook, path→rule, never→permission)
|
||||
argument-hint: "[path]"
|
||||
allowed-tools: Read, Write, Glob, Grep, Bash, Agent
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Config-Audit: Optimization Lens
|
||||
|
||||
The "is the config **optimal?**" axis (vs. the health scanners' "is it
|
||||
**correct?**"). It finds configuration that *works* but uses a mechanism a
|
||||
better one would fit — and frames every one as a *Missed opportunity*, never a
|
||||
mistake.
|
||||
|
||||
Mechanism-fit rules come from the provenance-stamped best-practices register
|
||||
(`knowledge/best-practices.json`); only CONFIRMED rules are surfaced. The motor
|
||||
is hybrid: a cheap deterministic pre-filter finds candidates, then the opus
|
||||
`optimization-lens-agent` judges each in context (precision-gated).
|
||||
|
||||
## What the user gets
|
||||
|
||||
- **Procedures → skills** (deterministic, CA-OPT-001)
|
||||
- **Lifecycle phrasing → hooks** (BP-MECH-001)
|
||||
- **Unscoped path-specific instructions → path-scoped rules** (BP-MECH-002)
|
||||
- **Absolute "never" prohibitions → permissions / hooks** (BP-MECH-004)
|
||||
- **`--subtract`:** instructions that no longer earn their always-loaded rent (BP-SUB-001)
|
||||
- **`--subtract --for-model <name>`:** of those, the ones a named model documents
|
||||
as redundant — e.g. self-verification instructions on Claude Opus 5 (BP-PROMPT-001)
|
||||
|
||||
Each finding cites its register rule + source URL. A clean CLAUDE.md returns "no
|
||||
opportunities" — that is a good result, not a failure.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Determine target
|
||||
|
||||
Split `$ARGUMENTS` into a path (first non-flag argument; default: current working
|
||||
directory) and flags. Recognized flags: `--global` (include the user `~/.claude`
|
||||
cascade in discovery), `--subtract` (add the subtraction axis, below),
|
||||
`--for-model <name>` (annotate model-scoped candidates, below) and `--apply`
|
||||
(execute approved removals — Step 7).
|
||||
|
||||
`--apply` only means anything alongside `--subtract`. If it is present without
|
||||
it, say so and continue with the ordinary lens run:
|
||||
|
||||
```
|
||||
`--apply` executes approved subtraction removals, so it needs `--subtract` too.
|
||||
Running the ordinary lens; re-run with `--subtract --apply` to remove anything.
|
||||
```
|
||||
|
||||
`--for-model <name>` has the same dependency. If it is present without
|
||||
`--subtract`, say so and continue with the ordinary lens run:
|
||||
|
||||
```
|
||||
`--for-model` annotates subtraction candidates, so it needs `--subtract` too.
|
||||
Running the ordinary lens; re-run with `--subtract --for-model <name>`.
|
||||
```
|
||||
|
||||
**`--subtract` — the inverse question.** Every other lens asks what to *add* or
|
||||
*move*; this one asks what no longer earns its always-loaded rent. It is opt-in
|
||||
because it asks something different, and because deleting is not undoable by
|
||||
reading. Pair it with `--global` to reach the user-level CLAUDE.md, where the
|
||||
always-loaded cost actually sits (it loads in every repo, every session).
|
||||
|
||||
**`--for-model <name>` — whose redundancy?** Some instructions are only dead
|
||||
weight for a *particular* model. Anthropic documents that Claude Opus 5 verifies
|
||||
its own work and over-verifies when told to double-check or to delegate
|
||||
verification to a subagent. That claim is model-scoped, so the model must be
|
||||
named: a CLAUDE.md carries no frontmatter and no target model, and the same file
|
||||
is read by whichever model the next session happens to run. The flag never adds
|
||||
candidates — it annotates ones `--subtract` already found. Example:
|
||||
`--subtract --for-model opus-5`.
|
||||
|
||||
If `--subtract` is present, say so up front:
|
||||
|
||||
```
|
||||
Also running the subtraction axis — instructions that cost tokens every turn
|
||||
without telling me anything I couldn't work out. Load-bearing local facts
|
||||
(remotes, versions, paths, policy) are excluded before anything is judged.
|
||||
```
|
||||
|
||||
Tell the user:
|
||||
|
||||
```
|
||||
## Optimization Lens
|
||||
|
||||
Looking for configuration that works but would fit a better Claude Code mechanism...
|
||||
```
|
||||
|
||||
### Step 2: Run the lens CLI
|
||||
|
||||
Generate a session ID (`YYYYMMDD_HHmmss`) if no active session exists.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/config-audit/sessions/{session-id} 2>/dev/null
|
||||
GLOBAL_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--global"; then GLOBAL_FLAG="--global"; fi
|
||||
SUBTRACT_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--subtract"; then SUBTRACT_FLAG="--subtract"; fi
|
||||
# --for-model takes a VALUE, so flag and value are two separate variables. Never
|
||||
# pack them into one ("--for-model x"): the shell here is zsh, which does not
|
||||
# word-split an unquoted expansion, so one variable would reach the CLI as a
|
||||
# single malformed argv entry and be silently ignored (M-BUG-45).
|
||||
FOR_MODEL_FLAG=""
|
||||
FOR_MODEL_VALUE=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--for-model "; then
|
||||
FOR_MODEL_FLAG="--for-model"
|
||||
FOR_MODEL_VALUE=$(echo "$ARGUMENTS" | sed -n 's/.*--for-model \([^ ]*\).*/\1/p')
|
||||
fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG $SUBTRACT_FLAG $FOR_MODEL_FLAG $FOR_MODEL_VALUE 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit code 0 is normal. Only exit code 3 is a real error → "The lens couldn't run.
|
||||
Check that the path exists and contains a CLAUDE.md."
|
||||
|
||||
### Step 3: Read the payload
|
||||
|
||||
Read `~/.claude/config-audit/sessions/{session-id}/optimize-lens.json` with the
|
||||
Read tool. It has `deterministic` (already-confirmed OPT findings), `candidates`
|
||||
(pre-filter candidates with register provenance), `register`, and `counts`.
|
||||
|
||||
Under `--subtract` it also has a `subtract` block (`candidates`, `register`) and
|
||||
`counts.subtractCandidates`. Each subtraction candidate spans `line`–`endLine`
|
||||
(a whole block). Include the whole `subtract` block when spawning the agent.
|
||||
|
||||
Under `--for-model` the `subtract` block additionally has `forModel`
|
||||
(`{ requested, recognized, matchedCount }`), and a subset of its candidates carry
|
||||
`modelScope` (`{ registerId, claim, requestedModel }`). If `recognized` is
|
||||
`false`, the register does not cover that model name — tell the user before
|
||||
showing results, so a zero is never read as "your config is already clean":
|
||||
|
||||
```
|
||||
I don't have a model-specific rule for "{requested}", so nothing was annotated
|
||||
for it. The ordinary subtraction results below are unaffected.
|
||||
```
|
||||
|
||||
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`
|
||||
(and, under `--subtract`, `counts.subtractCandidates === 0`), skip the agent and
|
||||
tell the user plainly:
|
||||
|
||||
```
|
||||
✓ No mechanism-fit opportunities found.
|
||||
|
||||
Your CLAUDE.md holds facts, not procedures/automation/prohibitions that would be
|
||||
better as skills, hooks, rules, or permissions. Nothing to change here.
|
||||
```
|
||||
|
||||
Then go to Step 5.
|
||||
|
||||
### Step 4: Spawn the precision gate
|
||||
|
||||
Tell the user what's happening and set expectations:
|
||||
|
||||
```
|
||||
Found {counts.candidates} candidate line(s) + {counts.deterministic} deterministic finding(s).
|
||||
Asking the optimization-lens agent to judge each in context (~20-40 seconds)...
|
||||
```
|
||||
|
||||
Spawn the `optimization-lens-agent` (Agent tool) with:
|
||||
- the full payload from Step 3 (deterministic + candidates + register),
|
||||
- the session directory path so it can write `optimization-lens-report.md`.
|
||||
|
||||
The agent reads the actual CLAUDE.md, drops low-confidence candidates, and keeps
|
||||
only genuine opportunities — each citing its register rule + source.
|
||||
|
||||
### Step 5: Present results
|
||||
|
||||
Read the agent's `optimization-lens-report.md` and present it formatted
|
||||
(markdown tables / grouped sections). Follow the UX rules: never show raw JSON or
|
||||
scanner progress; lead with a one-sentence summary of what was found before the
|
||||
detail. Make clear these are LOW-severity *opportunities*.
|
||||
|
||||
If the agent kept nothing from the candidates (all dropped) but there were
|
||||
deterministic findings, show those; if it kept nothing at all, show the clean
|
||||
result from Step 3.
|
||||
|
||||
### Step 7: Apply approved removals (`--subtract --apply` only)
|
||||
|
||||
Skip this step entirely unless BOTH flags are present and the agent kept at
|
||||
least one subtraction finding. Removal is the only thing this plugin does that
|
||||
takes configuration away, so nothing here happens without a named choice.
|
||||
|
||||
**7a — show what is on the table, with honest sizing.** List the kept
|
||||
subtraction findings numbered, each with its file, line span and first line of
|
||||
text. A finding carrying `modelScope` must show its condition here too — this
|
||||
is the last point a human sees it before it reaches an approval file, so the
|
||||
citation must not read as unconditional:
|
||||
|
||||
```
|
||||
{n}. {file}:{line}-{endLine} — {first line of text}
|
||||
Model-scoped: redundant if you are targeting {requestedModel}. Other
|
||||
sessions on this config may run a different model.
|
||||
```
|
||||
|
||||
Do not imply a bigger win than there is:
|
||||
|
||||
```
|
||||
Removing all of these saves roughly {n} tokens per turn — on a typical
|
||||
always-loaded CLAUDE.md that is around a fifth of the file, not most of it.
|
||||
```
|
||||
|
||||
Ask which to remove: numbers, `all`, or `none`. `none` ends the command.
|
||||
|
||||
**7b — write the approval file.** With the **Write** tool, write the operator's
|
||||
choice to `~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json`
|
||||
(absolute path — a relative one resolves against the user's CWD). Take `file`,
|
||||
`line`, `endLine` and `signalText` verbatim from the Step 3 payload; `text` must
|
||||
be the `signalText` byte-for-byte, because the engine refuses a removal whose
|
||||
text no longer matches the file:
|
||||
|
||||
```json
|
||||
{ "sessionId": "{session-id}",
|
||||
"removals": [ { "file": "...", "line": 12, "endLine": 15, "text": "..." } ] }
|
||||
```
|
||||
|
||||
**7c — dry run first.** Always. It costs one call and proves the spans still
|
||||
match before anything is written.
|
||||
|
||||
`--repo` is the **session's own root (`$PWD`), never the scanned path**. It is
|
||||
what the target is classified *against*: pass the scan target and
|
||||
`~/.claude/CLAUDE.md` classifies as `in-repo`, which drops the gate to `silent`
|
||||
on the one target that most needs it (measured — the same silent downgrade as a
|
||||
naive `.git`-upward walk, arriving through a different door).
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/subtraction-write-cli.mjs --approved ~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json --repo "$PWD" --dry-run --output-file ~/.claude/config-audit/sessions/{session-id}/subtraction-dryrun.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read the payload. Exit 3 is a real error (bad or unreadable approval file).
|
||||
Report any `refused` entry with its `reason` before going further —
|
||||
`block-mismatch` means the file changed since the scan (re-run the lens),
|
||||
`floor` means the block is load-bearing and will never be removable.
|
||||
|
||||
**7d — the scope gate.** If the dry-run payload has `requiresApproval: true`,
|
||||
show every line in `disclosures` verbatim and ask for an explicit go-ahead. This
|
||||
is the machine-wide case (`~/.claude/CLAUDE.md`): the change costs — and saves —
|
||||
in every project, on every turn, so it is not the same decision as editing the
|
||||
CLAUDE.md in front of you. Without a clear yes, stop here.
|
||||
|
||||
**7e — apply.** Same command without `--dry-run`, adding `--approve-scope` only
|
||||
if the operator gave that go-ahead in 7d:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/subtraction-write-cli.mjs --approved ~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json --repo "$PWD" --output-file ~/.claude/config-audit/sessions/{session-id}/subtraction-result.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
**7f — report.** From the result payload, tell the user: what was removed (file
|
||||
+ line span + the text, from `applied`), what was refused and why (`refused`
|
||||
with `reason`), and how to undo it:
|
||||
|
||||
```
|
||||
Backed up as {backupId} — `/config-audit rollback {backupId}` restores every
|
||||
file exactly as it was.
|
||||
```
|
||||
|
||||
Never report a run as successful when `counts.applied` is 0.
|
||||
|
||||
### Step 8: Next steps
|
||||
|
||||
End with context-sensitive next steps, explaining WHY each is useful:
|
||||
|
||||
- `/config-audit plan` — turn the kept opportunities into an action plan with
|
||||
backups before you change anything.
|
||||
- `/config-audit feature-gap` — the complementary lens: features you *don't* use
|
||||
yet (this command is about mechanisms you *do* use that could fit better).
|
||||
- Re-run `/config-audit optimize` anytime after editing CLAUDE.md.
|
||||
|
||||
## Notes
|
||||
|
||||
- This command is **agent-driven and not byte-stable** — its output is a
|
||||
human-facing report, deliberately outside the deterministic snapshot suite.
|
||||
- `--subtract` **proposes; only `--apply` writes**, and only blocks the operator
|
||||
named. Every removal is preceded by a backup whose manifest is verified to
|
||||
cover the file being written, and `/config-audit rollback` restores it.
|
||||
- **`--for-model` annotates; it never detects.** It tags a subset of the
|
||||
candidates `--subtract` already produced, and there is no auto-detection by
|
||||
design: a CLAUDE.md has no frontmatter and no resolvable target model, and the
|
||||
same file is read by whichever model the next session runs. So the citation is
|
||||
always **conditional** — it must be shown that way in the report and in the
|
||||
Step 7a approval listing, never as a settled fact about the file.
|
||||
- **Removal is not a `fix` action and not a `plan`/`implement` step**, by
|
||||
measurement rather than preference: the subtraction axis never enters the
|
||||
orchestrated envelope, so `fix`'s re-scan verification would mark every
|
||||
removal verified whether or not it happened, and the findings pipeline would
|
||||
require a finding code — which names a deterministic check, not a prose
|
||||
judgement. `subtraction-write-cli.mjs` owns the execution instead.
|
||||
- The subtraction floor is deterministic and runs *before* the agent, so a
|
||||
load-bearing block is never a candidate. It errs toward keeping: on a
|
||||
well-maintained config this axis is mostly a no-op, and that is a good result.
|
||||
- The deterministic half (CA-OPT-001) also rides in the normal orchestrated
|
||||
audit; this command adds the prose-judgment half on top.
|
||||
- Without `--apply`, no files are modified. To act on a mechanism-fit finding,
|
||||
use `/config-audit plan` → `/config-audit implement` (backup + rollback) or
|
||||
edit by hand.
|
||||
|
|
@ -22,7 +22,11 @@ Generate a prioritized action plan based on analysis results.
|
|||
|
||||
### Step 1: Verify session state
|
||||
|
||||
Find the most recent session with analysis completed using the Read tool on `~/.claude/config-audit/sessions/*/state.yaml`. If none found: "No analysis results found. Run `/config-audit` first to scan your configuration."
|
||||
Find the most recent session with analysis completed using the **Glob tool** on `~/.claude/config-audit/sessions/*/state.yaml`, then Read the newest match. The Read tool takes one literal path and does not expand `*` — pointing it at the glob makes this step report "no analysis results" even when a valid session exists.
|
||||
|
||||
If no session is found: "No analysis results found. Run `/config-audit` first to scan your configuration."
|
||||
|
||||
Then confirm the report itself exists — a session can carry a valid `state.yaml` and still be missing its report. Read `~/.claude/config-audit/sessions/{session-id}/analysis-report.md`. If it is absent: "Session {session-id} has no analysis report. Run `/config-audit analyze` to generate it." Stop — the planner agent has nothing to read.
|
||||
|
||||
### Step 2: Tell the user what's happening
|
||||
|
||||
|
|
@ -35,10 +39,10 @@ Actions are ordered by impact, with risk assessment and dependency tracking.
|
|||
|
||||
### Step 3: Parse flags and spawn planner agent
|
||||
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
```
|
||||
Check whether `$ARGUMENTS` contains `--raw`. Carry the answer yourself: the agent
|
||||
prompt below is **not** a shell, so a variable assigned in a bash block cannot be
|
||||
referenced from it. Substitute `{mode}` literally with `--raw` or with `humanized`
|
||||
when writing the prompt.
|
||||
|
||||
Tell the user: **"Generating your action plan (this takes about 30 seconds)..."**
|
||||
|
||||
|
|
@ -49,7 +53,7 @@ Agent(subagent_type: "config-audit:planner-agent")
|
|||
Generate action plan based on:
|
||||
- Analysis: ~/.claude/config-audit/sessions/{session-id}/analysis-report.md
|
||||
- Interview: ~/.claude/config-audit/sessions/{session-id}/interview.md (if exists)
|
||||
Mode: $RAW_FLAG (empty = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
|
||||
Mode: {mode} ("humanized" = humanized; "--raw" = v5.0.0 verbatim severity prefiks)
|
||||
Create a prioritized plan that consumes the humanized finding fields:
|
||||
- Group actions by userImpactCategory (e.g., "Configuration mistake",
|
||||
"Conflict", "Wasted tokens", "Missed opportunity", "Dead config")
|
||||
|
|
@ -69,7 +73,22 @@ Agent(subagent_type: "config-audit:planner-agent")
|
|||
|
||||
### Step 4: Present the plan summary
|
||||
|
||||
Read the generated plan and show a concise overview:
|
||||
Read the generated plan, then classify the files its actions target. This summary
|
||||
IS the approval surface — there is no separate confirmation step here, so a plan
|
||||
that proposes writing to machine-wide configuration has to say so where the user
|
||||
reads it. Pass one `--target` per distinct file the plan touches:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<file-1>" --target "<file-2>" --repo "$PWD" --output-file /tmp/config-audit-plan-scope.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit 0 = classified; 3 = argument error (show the stderr message). Read
|
||||
`/tmp/config-audit-plan-scope.json`. If `gate` is not `"silent"`, render each
|
||||
distinct string in `disclosures[]` verbatim directly under the action table, and
|
||||
mark the affected rows — not in a footnote further down, where a user scanning the
|
||||
table would miss it.
|
||||
|
||||
Show a concise overview:
|
||||
|
||||
```markdown
|
||||
### Action Plan Ready
|
||||
|
|
@ -94,7 +113,14 @@ You can edit the plan file to remove, reorder, or modify actions before implemen
|
|||
|
||||
### Step 5: Update state
|
||||
|
||||
Update `state.yaml` with `current_phase: "plan"`, `next_phase: "implement"`.
|
||||
Update `state.yaml` with all four fields `.claude/rules/state-management.md` requires — a partial write drops the fields that make an interrupted run resumable:
|
||||
|
||||
- `current_phase: "plan"`
|
||||
- `completed_phases`: append `plan` to the existing array (read it first; never overwrite it with a fresh list)
|
||||
- `next_phase: "implement"`
|
||||
- `updated_at`: current timestamp
|
||||
|
||||
The planner agent may already have written these. Read the file before writing and preserve whichever fields it set — a full-file Write that names only two fields silently deletes the other two.
|
||||
|
||||
## Plan Modification
|
||||
|
||||
|
|
|
|||
|
|
@ -32,15 +32,21 @@ Auditing {N} plugin(s) for structure, frontmatter quality, and cross-plugin conf
|
|||
|
||||
### Step 2: Run scanner
|
||||
|
||||
Run silently for each plugin. Default mode emits a humanized JSON envelope where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present.
|
||||
Run silently for each plugin. Default mode writes a humanized JSON payload to `--output-file` where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present, and prints the byte-stable v5.0.0 envelope on stdout instead.
|
||||
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <path> $RAW_FLAG 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs "<path>" --output-file /tmp/config-audit-plugin-health.json $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read stdout output (JSON) using the Read tool. Parse findings.
|
||||
Read `/tmp/config-audit-plugin-health.json` with the Read tool. Exit codes 0, 1 and 2 are normal; only 3 is a real error.
|
||||
|
||||
The payload carries three things the report needs:
|
||||
|
||||
- `plugins[]` — one row per plugin: `name`, `declaredName`, `commandCount`, `agentCount`, `findingCount`, `score`, `grade`. Use these for the table; never estimate a grade yourself.
|
||||
- `cross_plugin_findings[]` — the namespace-collision and shared-command-name findings, already separated from the per-plugin ones (they also carry `crossPlugin: true` in `findings`).
|
||||
- `findings[]` — every finding, humanized.
|
||||
|
||||
### Step 3: Present results
|
||||
|
||||
|
|
@ -49,7 +55,7 @@ Read stdout output (JSON) using the Read tool. Parse findings.
|
|||
|
||||
| Plugin | Grade | Commands | Agents | Status |
|
||||
|--------|-------|----------|--------|--------|
|
||||
| {name} | {grade} ({score}) | {cmd_count} | {agent_count} | {Good/Issues found} |
|
||||
| {plugins[].name} | {plugins[].grade} ({plugins[].score}) | {plugins[].commandCount} | {plugins[].agentCount} | {Good/Issues found} |
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
{If cross-plugin issues:}
|
||||
|
|
|
|||
|
|
@ -42,27 +42,35 @@ Run silently — JSON goes to a file, the humanized scorecard prints to stderr (
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file /tmp/config-audit-posture-$$.json $RAW_FLAG 2>/tmp/config-audit-posture-stderr-$$.txt; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --output-file /tmp/config-audit-posture.json $RAW_FLAG >/dev/null 2>/tmp/config-audit-posture-stderr.txt; echo $?
|
||||
```
|
||||
|
||||
Both paths are fixed literals, repeated literally in every later step: each
|
||||
```bash fence is its own process, so a `$$`-derived path could never be named
|
||||
again. `>/dev/null` is required, not cosmetic — with `--raw` the scanner writes
|
||||
the full envelope to stdout *as well as* the file (`posture.mjs:101`), which is
|
||||
255 KB on a real repo.
|
||||
|
||||
If exit code is non-zero, tell the user: "Assessment couldn't complete. Check that the path exists and contains Claude Code configuration files."
|
||||
|
||||
If `--raw` was passed, treat the captured stderr as v5.0.0-shape verbatim text and present it as-is in a code block; skip the humanized rendering steps below.
|
||||
|
||||
### Step 3: Read and interpret results
|
||||
|
||||
Read the JSON output file using the Read tool. Extract:
|
||||
Use the Read tool on `/tmp/config-audit-posture.json`. Extract:
|
||||
|
||||
- `overallGrade`, `opportunityCount`
|
||||
- `areas[]` — each with `name`, `grade`, `score`, `findingCount`
|
||||
- `scannerEnvelope.scanners[].findings[]` — when surfacing individual findings, prefer the humanizer-provided fields: `userImpactCategory` (e.g., "Configuration mistake", "Wasted tokens"), `userActionLanguage` (e.g., "Fix this now", "Fix soon", "Optional cleanup"), and `relevanceContext` ("affects-everyone", "affects-this-machine-only", "test-fixture-no-impact"). These let you group and prioritize without hardcoded severity-to-prose mappings.
|
||||
|
||||
Also Read the captured stderr file — its body is the humanized scorecard (grade headline, area-score block, opportunity hint). You can present it verbatim or interleave its lines with the JSON-driven table.
|
||||
Also use the Read tool on `/tmp/config-audit-posture-stderr.txt` — its body is the humanized scorecard (grade headline, area-score block, opportunity hint). You can present it verbatim or interleave its lines with the JSON-driven table.
|
||||
|
||||
### Step 4: Present the scorecard
|
||||
|
||||
```markdown
|
||||
**Health: {overallGrade}** | {qualityAreaCount} areas scanned
|
||||
**Health: {overallGrade}** | (area count: take it from the humanized scorecard's
|
||||
"N areas reviewed" line — do NOT use `areas.length`, which counts Feature
|
||||
Coverage; the table below excludes it, so the two would disagree)
|
||||
|
||||
{Use the headline line from the humanized stderr scorecard — it carries grade-context prose already (e.g., " Health: A (97/100) — Healthy setup, only minor polish needed"). Do not re-derive an A/B/C/D prose table here; the humanizer owns that vocabulary.}
|
||||
|
||||
|
|
@ -93,19 +101,19 @@ Avoid hardcoded grade-to-prose ladders here — the humanized scorecard headline
|
|||
|
||||
Run drift comparison silently:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <target-path> 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs "<target-path>" --output-file /tmp/config-audit-posture-drift.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read stdout output and append a "Configuration Drift" section showing what changed since the last baseline.
|
||||
Use the Read tool on `/tmp/config-audit-posture-drift.json` and append a "Configuration Drift" section showing what changed since the last baseline. Both scanners report to stderr in default mode, which `2>/dev/null` discards — the payload is the only readable output.
|
||||
|
||||
**If `--plugin-health` flag is present:**
|
||||
|
||||
Run plugin health scanner silently:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <target-path> 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs "<target-path>" --output-file /tmp/config-audit-posture-plh.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read stdout output and append a "Plugin Health" section.
|
||||
Use the Read tool on `/tmp/config-audit-posture-plh.json` and append a "Plugin Health" section, using its `plugins[]` rows for per-plugin grades.
|
||||
|
||||
**If both flags:** Use `scanners/lib/report-generator.mjs` to produce a unified markdown report.
|
||||
|
||||
|
|
@ -113,5 +121,9 @@ Read stdout output and append a "Plugin Health" section.
|
|||
|
||||
If a config-audit session exists, save results:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --json --output-file ~/.claude/config-audit/sessions/<session-id>/posture.json 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs "<target-path>" --json --output-file ~/.claude/config-audit/sessions/<session-id>/posture.json >/dev/null 2>/dev/null
|
||||
```
|
||||
|
||||
This is a second scan on purpose: the session file stores the raw v5.0.0 shape,
|
||||
while step 2 wrote the humanized one. `>/dev/null` matters most here — `--json`
|
||||
sends the same envelope to stdout regardless of `--output-file`.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ model: sonnet
|
|||
|
||||
Restore configuration files from a previous backup. Without arguments, lists available backups. With a backup ID, restores files from that backup.
|
||||
|
||||
Every step below runs `scanners/rollback-cli.mjs`, which drives the rollback
|
||||
engine: it verifies each file's checksum before AND after writing it, resolves
|
||||
backups made under the pre-v2.2.0 root, and reports the files a restore cannot
|
||||
undo. Never restore by copying files back by hand — a copy performs none of
|
||||
those checks, and the result cannot honestly be reported as verified.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS` may contain a backup ID (format: `YYYYMMDD_HHMMSS`)
|
||||
|
|
@ -19,14 +25,18 @@ Restore configuration files from a previous backup. Without arguments, lists ava
|
|||
|
||||
### List mode (no argument)
|
||||
|
||||
Parse flags and list available backups from `~/.claude/config-audit/backups/`:
|
||||
|
||||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
ls -1 ~/.claude/config-audit/backups/
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --list --output-file /tmp/config-audit-rollback-list.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit 0 = listed; 3 = the CLI could not do its job (show the stderr message).
|
||||
Read `/tmp/config-audit-rollback-list.json` and render one row per entry in
|
||||
`backups[]` — `{id}`, how many `{files}` it holds, and `{createdAt}`. An entry
|
||||
whose `{legacy}` is true was made under the pre-v2.2.0 backup root; say so, since
|
||||
its path differs from the one printed below.
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Available Backups
|
||||
|
|
@ -40,12 +50,26 @@ ls -1 ~/.claude/config-audit/backups/
|
|||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
```
|
||||
|
||||
Use the Read tool on each backup's `manifest.yaml` (the list of changes captured at backup time) to extract the file list and timestamps.
|
||||
If `{count}` is 0, say there are no backups yet and stop — do not offer a restore.
|
||||
|
||||
### Restore mode (with backup ID)
|
||||
|
||||
1. Read the list of changes from `~/.claude/config-audit/backups/{backup-id}/manifest.yaml` using the Read tool
|
||||
2. Show files that will be restored — ask for confirmation:
|
||||
1. The `backups[]` entry for that ID (from the list payload above) carries the
|
||||
`files[]` this restore would write. Classify those `{originalPath}` values
|
||||
before showing them. A restore writes to the absolute path recorded at backup
|
||||
time, which may be machine-wide even when the backup was taken from a project
|
||||
— so the file list must be rendered as the absolute originals, never shortened
|
||||
to a repo-relative-looking form that implies the write stays local:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<original-1>" --target "<original-2>" --repo "$PWD" --output-file /tmp/config-audit-rollback-scope.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit 0 = classified; 3 = argument error (show the stderr message). Read
|
||||
`/tmp/config-audit-rollback-scope.json`, render each distinct string in
|
||||
`disclosures[]` verbatim, then ask for confirmation.
|
||||
|
||||
When `requiresApproval` is false:
|
||||
```
|
||||
AskUserQuestion:
|
||||
question: "Restore 3 files from backup 20260403_163045?"
|
||||
|
|
@ -53,38 +77,83 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
|
|||
- "Yes, restore"
|
||||
- "Cancel"
|
||||
```
|
||||
3. For each file in the list of changes:
|
||||
a. Read the backup file from `~/.claude/config-audit/backups/{backup-id}/files/{safeName}`
|
||||
b. Write to the original path
|
||||
c. Verify the checksum matches the recorded value in the list of changes
|
||||
4. Show result:
|
||||
|
||||
When `requiresApproval` is true, name the scope and put the safe option first:
|
||||
```
|
||||
Restored 3 files from backup 20260403_163045
|
||||
- .claude/settings.json (checksum verified)
|
||||
- hooks/hooks.json (checksum verified)
|
||||
- .claude/rules/typescript.md (checksum verified)
|
||||
AskUserQuestion:
|
||||
question: "This restores {K} of 3 files to locations outside this project. Restore all 3?"
|
||||
options:
|
||||
- "Cancel"
|
||||
- "Yes — restore, including outside this project"
|
||||
```
|
||||
|
||||
2. Run the restore. Classifying is not approving: add `--approve-scope` only
|
||||
after the user has answered yes to the question above, and only then.
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --restore "<backup-id>" --repo "$PWD" --output-file /tmp/config-audit-rollback-restore.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Append ` --approve-scope` to that command when the user approved a restore
|
||||
that leaves this project. To preview without writing anything, append
|
||||
` --dry-run` instead — a dry run reports what would happen and touches no file.
|
||||
|
||||
| Exit | Meaning |
|
||||
|------|---------|
|
||||
| 0 | every file restored |
|
||||
| 1 | nothing was written — the restore needs approval it was not given |
|
||||
| 2 | at least one file failed; read `failed[]` before saying anything else |
|
||||
| 3 | the CLI could not run (bad ID, unreadable manifest) — show the stderr message |
|
||||
|
||||
3. Read `/tmp/config-audit-rollback-restore.json` and report what the run
|
||||
actually did. Render one line per entry in `restored[]` and `failed[]`, each
|
||||
showing its own `{status}` from the payload — never a fixed verification
|
||||
phrase, because the outcome differs per file and only the payload knows it:
|
||||
|
||||
```
|
||||
Restored 2 of 3 files from backup 20260403_163045
|
||||
- /abs/path/.claude/settings.json — {status}
|
||||
- /abs/path/hooks/hooks.json — {status}
|
||||
- /abs/path/.claude/rules/typescript.md — {status}
|
||||
```
|
||||
|
||||
When `{requiresApproval}` is true and nothing was restored, the run was
|
||||
refused: list the `refused[]` paths, say plainly that no file was changed, and
|
||||
offer to re-run with approval.
|
||||
|
||||
4. **Report what rollback cannot undo.** A backup only holds files that already
|
||||
existed, so files the implement step CREATED survive the restore. When
|
||||
`createdNotRemoved` is non-empty, list those paths and say plainly that they
|
||||
remain:
|
||||
```
|
||||
Left in place — created by implement, no backup exists:
|
||||
- .claude/rules/post-quality.md
|
||||
- guidelines/posting-rhythm.md
|
||||
Remove them manually if you want the pre-implement state exactly.
|
||||
```
|
||||
Never finish a restore without this section when the list is non-empty; a
|
||||
silently half-restored target reads as a clean rollback.
|
||||
|
||||
### Delete mode
|
||||
|
||||
If user says "delete" after listing, confirm and remove the backup directory.
|
||||
If the user says "delete" after listing, confirm, then:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --delete "<backup-id>" --output-file /tmp/config-audit-rollback-delete.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit 0 = removed (`deleted` is true in the payload); 3 = no backup with that ID
|
||||
in either root — show the stderr message rather than reporting a deletion.
|
||||
|
||||
## Implementation
|
||||
|
||||
Use the backup and rollback libraries directly:
|
||||
```javascript
|
||||
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
|
||||
import { parseManifest } from '../scanners/lib/backup.mjs';
|
||||
```
|
||||
`scanners/rollback-cli.mjs` is the only entry point. It reads
|
||||
`~/.claude/config-audit/backups` and falls back to the pre-v2.2.0
|
||||
`~/.config-audit/backups`, so a backup made before the move still resolves; the
|
||||
list payload flags those with `legacy: true`, and both roots are echoed in
|
||||
`meta` so a report can name the one it used.
|
||||
|
||||
Or via Bash:
|
||||
```bash
|
||||
# List backups
|
||||
ls -1 ~/.claude/config-audit/backups/
|
||||
|
||||
# Read manifest
|
||||
cat ~/.claude/config-audit/backups/{id}/manifest.yaml
|
||||
|
||||
# Restore (copy back)
|
||||
cp ~/.claude/config-audit/backups/{id}/files/{safeName} {originalPath}
|
||||
```
|
||||
The same CLI creates backups (`--create --target <path> …`), which is how
|
||||
`/config-audit implement` records what it is about to change. Backup and restore
|
||||
therefore share one manifest format, owned by `scanners/lib/backup.mjs` — the
|
||||
format is never written or read by hand.
|
||||
|
|
|
|||
|
|
@ -37,8 +37,13 @@ When `--raw` is in `$ARGUMENTS`, render the raw `current_phase` field value verb
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
ALL_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -qw -- "all"; then ALL_FLAG="all"; fi
|
||||
```
|
||||
|
||||
When `ALL_FLAG` is set, skip steps 2–4 and render the **List All Sessions**
|
||||
table below instead of a single session's status.
|
||||
|
||||
2. **Find active session**:
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
|
|
@ -128,11 +133,13 @@ All config-audit sessions:
|
|||
| 20250120_160000 | implement | 2025-01-20 16:00 |
|
||||
```
|
||||
|
||||
## Resume Session
|
||||
## Resuming a session
|
||||
|
||||
If multiple sessions exist:
|
||||
```
|
||||
/config-audit resume {session-id}
|
||||
```
|
||||
There is no `resume` command. Sessions are selected by recency: every
|
||||
session-aware command globs `~/.claude/config-audit/sessions/*/state.yaml` and
|
||||
takes the most recently modified one.
|
||||
|
||||
Sets that session as active and continues from last phase.
|
||||
To continue an older session, run its next phase directly — `/config-audit plan`,
|
||||
`/config-audit implement`, and so on read `next_phase` from the state file. If
|
||||
the wrong session keeps winning, delete the stale ones with
|
||||
`/config-audit cleanup`.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ Complementary to `/config-audit whats-active`:
|
|||
Split `$ARGUMENTS` into a path and flags. Path is the first non-flag argument. Default to `.` (current working directory). Recognized flags:
|
||||
|
||||
- `--global` — also include the user-level `~/.claude/` cascade
|
||||
- `--no-exclude-cache` — include stale `~/.claude/plugins/cache` versions in the ranking. **By default they are excluded** (cache-aware filtering, default ON): the cache holds superseded plugin versions that load on *zero* turns, and counting them used to crowd the top-10 with dead config. The active version of each plugin (per `installed_plugins.json`) is always kept — only stale versions are filtered. Use `--no-exclude-cache` to see the full on-disk walk.
|
||||
- `--json` — emit raw JSON instead of rendered tables (power-user mode; bypasses the humanizer for byte-stable v5.0.0 output)
|
||||
- `--raw` — pass-through to the scanner; produces v5.0.0 verbatim JSON (bypasses the humanizer). Use when piping into v5.0.0-baseline diff tooling.
|
||||
- `--with-telemetry-recipe` — include `telemetry_recipe_path` in the JSON output, pointing to `knowledge/cache-telemetry-recipe.md`. Use this when you want to verify a structural fix actually improved cache hit rate (manual jq recipe, opt-in)
|
||||
|
|
@ -39,12 +40,25 @@ Tell the user: **"Analysing token hotspots for `<path>`..."**
|
|||
Default mode (no `--json`, no `--raw`) emits a humanized JSON envelope: each finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` in addition to the v5.0.0 fields. Pass `--raw` through verbatim if the user requested it.
|
||||
|
||||
```bash
|
||||
TMPFILE="/tmp/config-audit-tokens-$$.json"
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set each to the flag itself when the user asked for it, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the CLI's arg
|
||||
# loop would take it as the TARGET PATH instead of a flag.
|
||||
GLOBAL_FLAG="" # --global
|
||||
CACHE_FLAG="" # --no-exclude-cache
|
||||
JSON_FLAG="" # --json
|
||||
TELEMETRY_FLAG="" # --with-telemetry-recipe
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs "<path>" --output-file /tmp/config-audit-tokens.json $GLOBAL_FLAG $CACHE_FLAG $JSON_FLAG $TELEMETRY_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
`--json` and `--with-telemetry-recipe` must be threaded here, not just
|
||||
documented: the CLI is what turns them on. `--json`/`--raw` make the payload
|
||||
byte-stable v5.0.0 (the humanizer is skipped, `token-hotspots-cli.mjs:127`), and
|
||||
`--with-telemetry-recipe` is what adds `telemetry_recipe_path`. `>/dev/null` is
|
||||
required because those two modes also print the payload to stdout even with
|
||||
`--output-file` set (`token-hotspots-cli.mjs:137`).
|
||||
|
||||
**Exit code handling:**
|
||||
- `0` → continue
|
||||
- `3` → tell user: "Couldn't analyse tokens. Check that the path exists and is a directory." Stop.
|
||||
|
|
@ -52,20 +66,24 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file
|
|||
### Step 3: If `--json` was requested, cat the file and stop
|
||||
|
||||
```bash
|
||||
cat "$TMPFILE"
|
||||
cat /tmp/config-audit-tokens.json
|
||||
```
|
||||
|
||||
Do NOT render tables in JSON mode.
|
||||
|
||||
### Step 4: Read JSON and render
|
||||
|
||||
Use the Read tool on `$TMPFILE`. Extract:
|
||||
Use the Read tool on `/tmp/config-audit-tokens.json`. Extract:
|
||||
|
||||
- `total_estimated_tokens` — top-line number
|
||||
- `hotspots[]` — top 10 ranked sources
|
||||
- `findings[]` — prompt-cache pattern findings (CA-TOK-001..003); each finding in default mode carries humanizer fields (`userImpactCategory`, `userActionLanguage`, `relevanceContext`) alongside the v5.0.0 fields
|
||||
- `hotspots[]` — top 10 ranked sources; each carries a **load pattern** (`loadPattern` ∈ always / on-demand / external, plus `survivesCompaction` / `derivationConfidence`)
|
||||
- `findings[]` — prompt-cache pattern findings; each finding in default mode carries humanizer fields (`userImpactCategory`, `userActionLanguage`, `relevanceContext`) alongside the v5.0.0 fields
|
||||
- `counts` — severity breakdown
|
||||
|
||||
A hotspot's **load pattern** matters as much as its size: an **always**-loaded source (CLAUDE.md, MCP tool schemas) is paid on *every* turn, an **on-demand** one (skill body, path-scoped rule) only when invoked/matched, and an **external** one (hooks, harness-config files like settings.json/.mcp.json) costs no per-turn context tokens at all. A big always-loaded hotspot is the most worth trimming.
|
||||
|
||||
**The stale plugin-cache finding is different from the rest.** All other TOK findings are about per-turn token cost. The *"Old plugin versions are sitting on disk"* finding (category `plugin-cache-hygiene`, impact **Dead config**, `--global` only) is a pure **disk-cleanup** item with **zero live-context impact** — the listed versions are never loaded. Render it as housekeeping, not a token problem: don't conflate its disk bytes with the per-turn token numbers above it.
|
||||
|
||||
Render as markdown. Group findings by `userImpactCategory` (e.g., "Wasted tokens" vs "Configuration mistake") rather than re-deriving severity prose; lead each line with `userActionLanguage` ("Fix this now", "Fix soon", "Optional cleanup", etc.) so the urgency phrasing stays consistent with the rest of the toolchain. The humanizer already replaced jargon-heavy `title`/`description`/`recommendation` strings with plain-language equivalents — render them verbatim.
|
||||
|
||||
```markdown
|
||||
|
|
@ -73,9 +91,11 @@ Render as markdown. Group findings by `userImpactCategory` (e.g., "Wasted tokens
|
|||
|
||||
### Top hotspots (ranked by estimated tokens)
|
||||
|
||||
| Rank | Source | Tokens | Recommendations |
|
||||
|------|--------|--------|-----------------|
|
||||
| {rank} | `{source}` | ~{estimated_tokens} | {recommendations joined as `· ` bullets} |
|
||||
| Rank | Source | Tokens | Load | Recommendations |
|
||||
|------|--------|--------|------|-----------------|
|
||||
| {rank} | `{source}` | ~{estimated_tokens} | {loadPattern} | {recommendations joined as `· ` bullets} |
|
||||
|
||||
_Load column: **always** (every turn) / **on-demand** (on invoke/match) / **external** (out-of-context). Append `°` when `derivationConfidence` is `inferred`._
|
||||
|
||||
### Findings, grouped by impact
|
||||
|
||||
|
|
@ -102,7 +122,7 @@ _Estimates assume ~4 chars/token (Claude ballpark). Real token count varies ±20
|
|||
### Step 5: Cleanup and next steps
|
||||
|
||||
```bash
|
||||
rm -f "$TMPFILE"
|
||||
rm -f /tmp/config-audit-tokens.json
|
||||
```
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
|
|
@ -33,10 +33,14 @@ Split `$ARGUMENTS` into a path and flags. Path is the first non-flag argument. D
|
|||
Tell the user: **"Reading active configuration for `<path>`..."**
|
||||
|
||||
```bash
|
||||
TMPFILE="/tmp/ca-whats-active-$$.json"
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPFILE" [--verbose] [--suggest-disables] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set each to the flag itself when the user asked for it, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the scanner's
|
||||
# arg loop would take it as the TARGET PATH instead of a flag.
|
||||
VERBOSE_FLAG="" # --verbose
|
||||
SUGGEST_FLAG="" # --suggest-disables
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs "<path>" --output-file /tmp/config-audit-whats-active.json $VERBOSE_FLAG $SUGGEST_FLAG $RAW_FLAG >/dev/null 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
**Exit code handling:**
|
||||
|
|
@ -46,14 +50,14 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPF
|
|||
### Step 3: If `--json` was requested, cat the file and stop
|
||||
|
||||
```bash
|
||||
cat "$TMPFILE"
|
||||
cat /tmp/config-audit-whats-active.json
|
||||
```
|
||||
|
||||
Do NOT render tables in JSON mode.
|
||||
|
||||
### Step 4: Read JSON and render
|
||||
|
||||
Use the Read tool on `$TMPFILE`. Extract:
|
||||
Use the Read tool on `/tmp/config-audit-whats-active.json`. Extract:
|
||||
|
||||
- `meta.repoPath`, `meta.durationMs`, `meta.gitRoot`, `meta.projectKey`
|
||||
- `totals.estimatedTokens.grandTotal` (and subtotals)
|
||||
|
|
@ -90,7 +94,17 @@ Render as markdown:
|
|||
|-------|--------|--------|
|
||||
| {name} | {source}{if pluginName: ` (${pluginName})`} | ~{estimatedTokens} |
|
||||
|
||||
### MCP Servers ({mcpServers.length}, ~{mcpServers subtotal} tokens)
|
||||
### Agents ({agents.length}, ~{agents subtotal} tokens)
|
||||
|
||||
| Agent | Source | Model | Effort | Tokens |
|
||||
|-------|--------|-------|--------|--------|
|
||||
| {name} | {source}{if pluginName: ` (${pluginName})`} | {model or "inherit"} | {effort or "session default"} | ~{estimatedTokens} |
|
||||
|
||||
Skip this section entirely when `agents` is empty. `model: null` is rendered as
|
||||
*inherit* and `effort: null` as *session default* — both are the documented
|
||||
defaults, so a blank cell would read as missing data rather than as the choice
|
||||
it is. If no agent pins either column, say so in one sentence: every delegated
|
||||
task then costs what the session costs.
|
||||
|
||||
| Server | Source | Status | Command |
|
||||
|--------|--------|--------|---------|
|
||||
|
|
@ -149,7 +163,7 @@ Do NOT suggest items you can't name concrete redundancy for. If you can't find 3
|
|||
### Step 7: Cleanup and next steps
|
||||
|
||||
```bash
|
||||
rm -f "$TMPFILE"
|
||||
rm -f /tmp/config-audit-whats-active.json
|
||||
```
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
400
docs/delete-and-rebuild-brief.md
Normal file
400
docs/delete-and-rebuild-brief.md
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
# Brief — Delete-and-Rebuild (config subtraction)
|
||||
|
||||
**Status:** BRIEF, not a plan. No code, no chunk breakdown, no version number committed.
|
||||
Written 2026-07-29 so a session starting Thursday evening has a durable starting point.
|
||||
STATE.md is gitignored in this repo, so this file — not STATE — is the record.
|
||||
|
||||
---
|
||||
|
||||
## 1. Trigger and its provenance
|
||||
|
||||
The operator relayed a third-party YouTube summary (Hyper Automation Labs) of a talk
|
||||
Boris Cherny reportedly gave at Y Combinator Startup School, one day after Opus 5
|
||||
shipped. Claims attributed to him in that summary:
|
||||
|
||||
- Anthropic deleted ~80 % of Claude Code's own system prompt when Opus 5 landed.
|
||||
- Advice to users: every six months, delete your CLAUDE.md, your skills, your hooks —
|
||||
see what the model does.
|
||||
- Rebuild method: delete everything, use it, add one line back only when the model
|
||||
stumbles on the same thing repeatedly.
|
||||
- The model measured *slightly more intelligent* with the built-in prompts stripped.
|
||||
|
||||
**Two levels of confidence here, and they must not be collapsed** (updated 2026-07-29
|
||||
after the operator corrected the first draft):
|
||||
|
||||
- **Attribution — confirmed.** The operator watched the recording and identifies Boris
|
||||
Cherny on stage. This is not a channel's claim about who spoke; it is direct
|
||||
observation by the operator. The talk happened and it is him.
|
||||
- **The verbatim figures — still summary-level.** "80 % of the system prompt", "the
|
||||
model measured slightly more intelligent without the prompts", the Bun numbers: these
|
||||
reach us through the channel's editing, not through a primary transcript. They are
|
||||
plausible and consistent with §2, and they are not quoted as fact anywhere below.
|
||||
|
||||
So if this becomes a `BP-*` entry in the best-practices register, the source field reads
|
||||
*"Boris Cherny, YC Startup School talk (attribution confirmed by operator); figures via
|
||||
third-party summary, not primary-verified"* — not "unverified", and not a bare citation
|
||||
either. Getting a primary transcript for the figures is a nice-to-have, never a
|
||||
prerequisite: the feature is argued from this repo's own logic (§3), and this repo has
|
||||
one scar from treating a plausible quote as load-bearing fact («Fable low ≈ Opus high»,
|
||||
fabricated, rejected 2026-07-14).
|
||||
|
||||
**What the operator has affirmed as in scope:** delete CLAUDE.md, then rebuild by adding
|
||||
back what the model needs help with — *starting with what it must have*. That last clause
|
||||
is not a detail; it is the design constraint in §6.0.
|
||||
|
||||
## 2. Verified ground truth (checked against the local CLI, 2026-07-29)
|
||||
|
||||
These *are* facts, and they are what make an empirical variant buildable:
|
||||
|
||||
| Fact | How verified |
|
||||
|---|---|
|
||||
| `claude --bare` exists — "Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets `CLAUDE_CODE_SIMPLE=1`." | `claude --help` |
|
||||
| `claude --system-prompt <prompt>` — replaces the session system prompt | `claude --help` |
|
||||
| `--append-system-prompt`, `--add-dir`, `--setting-sources <user,project,local>`, `--plugin-dir`, `--settings`, `--agents` — all present and all usable to compose an isolated config for a controlled run | `claude --help` |
|
||||
|
||||
So the `CLAUDE_CODE_SIMPLE=1` env var the video calls "undocumented" is, in this CLI
|
||||
version, a documented flag (`--bare`). That matters: an A/B ablation harness would not
|
||||
need an undocumented hook.
|
||||
|
||||
## 3. Why this fits this repo (the argument that does *not* depend on §1)
|
||||
|
||||
The plugin has three pillars — Health, Opportunities, Action. Every existing command
|
||||
answers a question on the **addition** axis:
|
||||
|
||||
- `feature-gap` — what could you add?
|
||||
- `optimize` — what would fit a better mechanism?
|
||||
- `posture` / `tokens` / `manifest` — how good/expensive is what you have?
|
||||
- `fix` / `implement` — apply changes.
|
||||
|
||||
**Nothing answers the subtraction question: what is no longer earning its rent?**
|
||||
`feature-gap` has no inverse. That is a real hole, independent of who said what on
|
||||
a stage.
|
||||
|
||||
Two further reasons this belongs *here* specifically:
|
||||
|
||||
1. **Nothing in the repo measures instruction age.** Verified by grep over `scanners/`:
|
||||
`stale` appears only for knowledge-register entry age (`lib/knowledge-refresh.mjs`)
|
||||
and for stale plugin-cache versions (`scan-orchestrator.mjs`, M-BUG-11). No scanner
|
||||
touches `git blame`, `mtime`, or the vintage of a CLAUDE.md block. An instruction
|
||||
written for Sonnet 3.5 and an instruction written last week are indistinguishable to
|
||||
every current scanner.
|
||||
2. **Deleting bravely is only sane if you can undo it.** That is already pillar three:
|
||||
`lib/backup.mjs`, `rollback-engine.mjs`, `auto-backup-config.mjs` (PreToolUse), and
|
||||
`drift`'s `saveBaseline` / `loadBaseline` / `diffEnvelopes`. The safety net exists;
|
||||
the feature that would use it does not. This is arguably the strongest framing:
|
||||
*config-audit is already the infrastructure that makes "delete it and see" a
|
||||
measurement rather than a gamble.*
|
||||
|
||||
## 4. Reusable machinery (do not rebuild these)
|
||||
|
||||
| Need | Already exists |
|
||||
|---|---|
|
||||
| Snapshot config before deleting | `scanners/lib/baseline.mjs` (`saveBaseline`), used by `drift` |
|
||||
| Diff before/after | `diffEnvelopes` in the same module |
|
||||
| Backup + restore individual files w/ sha256 manifest | `scanners/lib/backup.mjs`, `scanners/rollback-engine.mjs` |
|
||||
| Cost of each source, always-loaded subtotal | `scanners/manifest.mjs`, `token-hotspots.mjs` |
|
||||
| Human-approved-writes command pattern | `knowledge-refresh`, `campaign` (both non-byte-stable by design) |
|
||||
|
||||
## 5. Candidate shapes (sketches — pick on Thursday, do not pre-commit)
|
||||
|
||||
All three inherit the floor constraint in §6.0: whatever the shape, load-bearing local
|
||||
facts are never deletion candidates, and a rebuild restores them first. A shape that
|
||||
cannot express that distinction is disqualified regardless of how cheap it is.
|
||||
|
||||
**A. Deterministic vintage scanner (`CA-VIN-*`).** Per instruction block in CLAUDE.md /
|
||||
rules / skills / hooks: age from git history, plus a compensatory-phrasing signal
|
||||
(blocks that exist to correct model behaviour — "ALWAYS", "never forget", "read the
|
||||
whole file first", "don't guess"). Output: ranked deletion candidates with age + token
|
||||
cost + why it looks compensatory. Cheapest, most testable, fits the existing scanner
|
||||
architecture, and composes with `tokens`/`manifest` for the payoff figure.
|
||||
|
||||
> **🔴 The age half of this shape is DEAD — measured 2026-07-31 (økt #40), see §8's
|
||||
> updated checklist.** Per-line `git blame` over four real instruction files gives
|
||||
> single-date shares of 88 % / 55 % / 100 % / 58 %: instruction blocks trace back to bulk
|
||||
> commits, so per-block age carries almost no information. Worse, `blame` reports
|
||||
> *last touch*, not vintage — a reformatting commit (e.g. this repo's own `96e32df`,
|
||||
> "trim project CLAUDE.md to invariants") makes old instructions look young, which biases
|
||||
> the signal rather than merely thinning it. That also kills the `mtime` fallback.
|
||||
> **§8 pre-registered this exact outcome and its consequence: shape A's primary signal
|
||||
> collapses to the compensatory-phrasing heuristic alone, and §7.2 is answered — the
|
||||
> classification needs the agent layer.** Do not resurrect age as a "weak secondary
|
||||
> signal"; that is the drift the pre-registration existed to prevent. What survives of
|
||||
> shape A is the *deterministic phrasing + token-cost pre-filter*, feeding a
|
||||
> precision-gated judge (the `optimize` architecture).
|
||||
|
||||
**B. Protocol command (`/config-audit rebuild`).** The delete → live with it → earned
|
||||
re-add ledger loop, spanning sessions: archive current config, record what was archived,
|
||||
and maintain a ledger where a line only returns when the operator records that the model
|
||||
actually stumbled on it. Highest fidelity to the source idea; needs cross-session state
|
||||
(the `sessions/` machinery already exists) and is inherently not byte-stable.
|
||||
|
||||
**C. Measured ablation harness.** Use `--bare` + `--system-prompt`/`--add-dir` to run the
|
||||
same prompt with and without a block and compare. Closest to a real verifier, most
|
||||
expensive in tokens, weakest determinism. Probably a later `--experimental` sub-mode of
|
||||
A or B rather than its own command.
|
||||
|
||||
Likely landing: **A first** (deterministic, testable, immediately useful), with B as the
|
||||
workflow that consumes A's output. C stays a documented idea until A+B exist.
|
||||
|
||||
> **DECIDED 2026-07-31 (#40), superseding the sketch above — see §7 q2/q3.** Shape A does
|
||||
> not ship as a standalone `CA-VIN-*` scanner: its age signal is dead, and what remains of
|
||||
> it (a phrasing + token-cost pre-filter) is precisely the front half of `optimize`'s
|
||||
> existing hybrid motor. **The landing is `/config-audit optimize --subtract`** — a fourth
|
||||
> `lensCheck` class emitting `CA-OPT-*` findings, with a deterministic **floor-exclusion**
|
||||
> step ahead of the judge so a load-bearing block is never a candidate. Shape B (the
|
||||
> earned-re-add ledger) stays out of v1 precisely because it is what would demand
|
||||
> cross-session state and therefore a command of its own. Shape C unchanged: documented,
|
||||
> not built.
|
||||
|
||||
## 6. Hard constraints (carry these into any implementation)
|
||||
|
||||
### 6.0 The floor: compensatory vs load-bearing instructions
|
||||
|
||||
**This is the invariant all three shapes in §5 must respect, and it is what makes the
|
||||
feature safe to ship at all.** Not every line in a CLAUDE.md is the same kind of thing:
|
||||
|
||||
| Class | What it is | Test | Disposition |
|
||||
|---|---|---|---|
|
||||
| **Compensatory** | An instruction correcting model *behaviour* — "read the whole file first", "don't guess", "ALWAYS verify", "think before you code" | A smarter model would do this unprompted | **Deletion candidate.** Returns only when earned. |
|
||||
| **Load-bearing** | A *local fact* the model cannot derive at any capability level — "never GitHub, only Forgejo at git.fromaitochitta.com", "system bash is 3.2, no `declare -A`", "test with `node --test 'tests/**/*.test.mjs'`", "`~/.claude` is not git-tracked" | No amount of intelligence produces this from the codebase alone | **Floor. Never a deletion candidate.** |
|
||||
|
||||
Model capability erodes the first class and does nothing to the second. That is the whole
|
||||
mechanism behind the source idea — and it means "delete your CLAUDE.md" is only correct
|
||||
for one of the two classes. A tool that treats them alike would delete the operator's
|
||||
Forgejo constraint because Opus 5 "is smart enough now", which is a category error: the
|
||||
model isn't failing at intelligence there, it simply cannot know.
|
||||
|
||||
**Consequence for the rebuild ordering.** A rebuild is not one undifferentiated
|
||||
add-back-on-stumble loop. It is three tiers:
|
||||
|
||||
1. **Floor — goes back immediately, no trial period.** Load-bearing facts. The config is
|
||||
never in a state where these are absent; a "delete everything" that drops them is a
|
||||
broken experiment, not a brave one.
|
||||
2. **Earned — out, returns only on repeated observed stumbling.** Compensatory
|
||||
instructions that turn out to still be needed by *this* model.
|
||||
3. **Dead — out and never missed.** The payoff, measurable in tokens via `manifest`.
|
||||
|
||||
A third class sits deliberately outside the axis: **policy prohibitions** ("never commit
|
||||
secrets"). These may well be things the model would honour unprompted, but their cost of
|
||||
being wrong is asymmetric and they are cheap. They stay in the floor by decision, not by
|
||||
classification. Do not let a "the model knows this now" argument reach them.
|
||||
|
||||
The hard part is tier 1 vs tier 2, and that classification — not the deletion mechanics —
|
||||
is the real engineering problem in this brief. Precision is asymmetric: a missed dead line
|
||||
costs a few tokens per turn; a deleted load-bearing line costs a wrong remote, a broken
|
||||
bash script, or a lost afternoon.
|
||||
|
||||
### 6.1 Existing repo constraints
|
||||
|
||||
- **`~/.claude` IS git-tracked as of 2026-07-26 — but archive by `mv` into `_archive/`
|
||||
anyway, never `rm`.** Premise corrected 2026-07-31 (økt #40); the rule it was used to
|
||||
justify is unchanged. Ground truth: `/Users/ktg/.claude` is a git repo, 7 commits,
|
||||
initial commit `13cd708` 2026-07-26, remote `backup
|
||||
/Volumes/DharmaBackup/claude-config.git` (external volume — not Forgejo, not GitHub),
|
||||
47 files tracked (`hooks/` 19, `commands/` 14, `scripts/` 9, `CLAUDE.md`,
|
||||
`settings.json`, `learnings/`, `docs/`). The rule survives on different grounds: only
|
||||
those 47 files are recoverable, the history is days old, and the remote lives on a
|
||||
volume that may not be mounted. Two consequences for this feature — (a) `~/.claude`
|
||||
age evidence is bounded by the repo's own birth date and is therefore worthless, and
|
||||
(b) any untracked file there (`memory/`, `coord/`, session state) is still
|
||||
unrecoverable after `rm`.
|
||||
- **`settings.json` is pathguard-protected** — no Edit/Write. Use `jq` + temp file +
|
||||
atomic `mv`, with operator OK ([[settings-json-pathguard-write]]).
|
||||
- **New scanner ⇒ the 7-step byte-stability checklist** ([[adding-scanner-byte-stability]]):
|
||||
scanner + orchestrator + scoring area-map + strip-added-scanner + humanizer count +
|
||||
SC-5 + humanizer wiring (`SCANNER_TO_CATEGORY` entry and `TRANSLATIONS.static` per RAW
|
||||
title — M-16/M-17). Frozen `tests/snapshots/v5.0.0/` must stay untouched.
|
||||
- **TDD is inviolable** — red tests before implementation, including for .md contracts.
|
||||
- **`feat:` commits require non-trivial diffs in both README.md and CLAUDE.md**
|
||||
([[docs-gate-feat-requires-readme-claudemd]]).
|
||||
|
||||
## 7. Open questions for Thursday
|
||||
|
||||
> **STATUS 2026-07-31 (#40): q1, q2, q3 are CLOSED below — decided on measured evidence
|
||||
> (dead age signal) plus the hand-built fasit (`docs/subtraction-fasit.local.md`), under
|
||||
> the operator's standing delegation of technical/design calls. q4 was already closed
|
||||
> 2026-07-29. Do not re-litigate without new evidence.**
|
||||
>
|
||||
> **The landing: `/config-audit optimize --subtract` — a fourth lens class in the existing
|
||||
> hybrid motor, not a new command and not a new scanner.** Full rationale in q3.
|
||||
|
||||
1. Scope of the deletion candidate set: user-level `~/.claude` only, project only, or both?
|
||||
(Age evidence is much stronger for project-level, per §5A.)
|
||||
|
||||
**CLOSED — both, and user-level is mandatory in v1.** The argument that pointed at
|
||||
project-level was git-age evidence, and that evidence no longer exists (§5A box), so
|
||||
scope now follows *payoff and testability* instead. Two things force user-level in:
|
||||
§8's blocking floor test is defined over the operator's global CLAUDE.md, and that file
|
||||
is where the always-loaded cost actually sits (~4 300 tok every turn, every repo,
|
||||
every session — vs a project CLAUDE.md that loads only in its own repo). Project-level
|
||||
comes along because the same motor reads it and because two of §8's four floor anchors
|
||||
turned out to live there.
|
||||
*Implementation note, not a reopening:* `optimize` today takes a repo `target`. Reading
|
||||
`~/.claude/CLAUDE.md` is a target-resolution change, and per §6.1 the `~/.claude` write
|
||||
rules apply — but this mode proposes, it never writes.
|
||||
2. **The core question, given §6.0:** can compensatory-vs-load-bearing be classified
|
||||
deterministically with acceptable precision, or does it need the agent layer (like
|
||||
`optimize`'s precision-gated `optimization-lens-agent`, which stays silent when
|
||||
unsure)? Note the two signals are independent — a load-bearing fact can be old, and a
|
||||
compensatory instruction can be new — so age alone can never carry this call. Prior:
|
||||
a deterministic pre-filter feeding a precision-gated judge, which is exactly the
|
||||
`optimize` architecture already in the repo.
|
||||
**Design the §8 fasit around the ambiguous middle, not the poles.** The four named
|
||||
must-survive items are clear-cut and any mechanism will get them right; the gate is
|
||||
really decided by blocks like "Conventional Commits: `type(scope): beskrivelse`"
|
||||
(local convention, or a nag the model would follow anyway?), "commit ofte med
|
||||
beskrivende meldinger" (pure behaviour correction?), or the model-routing rubric
|
||||
(a table of local policy that reads like advice). Include 3–5 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
|
||||
~850–1 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-001–004, 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.
|
||||
|
|
@ -21,7 +21,7 @@ User-impact category (added to each finding as `userImpactCategory`, derived fro
|
|||
|
||||
| Label | Scanners |
|
||||
|-------|----------|
|
||||
| Configuration mistake | CML, SET, HKV, RUL, MCP, IMP, PLH |
|
||||
| Configuration mistake | CML, SET, HKV, RUL, MCP, IMP, PLH, OST |
|
||||
| Conflict | CNF, COL |
|
||||
| Wasted tokens | TOK, CPS |
|
||||
| Dead config | DIS |
|
||||
|
|
|
|||
244
docs/q-audit-prose-invariants.md
Normal file
244
docs/q-audit-prose-invariants.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# Q_AUDIT — the prose-invariant sweep (v6 quality plan §3)
|
||||
|
||||
**Session #68, 2026-08-12, Fable 5/xhigh (no advisor — every number below is
|
||||
command-produced; the commands are in the appendix).** Q1 closed the write gate
|
||||
in code, Q2 closed the argv contract in code. This sweep asked what ELSE is
|
||||
enforced only by prose across the three surfaces the plan names: **21** command
|
||||
templates, **7** agent prompts, **4** `.claude/rules/` files (4,950 lines), against
|
||||
the **17** guard files that already exist under `tests/commands/` + `tests/agents/`.
|
||||
|
||||
Output is a rated list, not code. Nothing here was fixed in this session.
|
||||
|
||||
## 1. The taxonomy — what counts as a prose invariant
|
||||
|
||||
The open decision this session owned. A statement counts when all three hold:
|
||||
|
||||
1. **Something else relies on it** — another component (code, hook, agent,
|
||||
downstream command) behaves correctly only while the statement is true.
|
||||
2. **Its violation is silent** — nothing fails loudly when it stops holding.
|
||||
3. **No code or test detects the violation.**
|
||||
|
||||
What deliberately does NOT count (the negative list matters — Q1's own lesson is
|
||||
that a gate firing on legitimate writes gets switched off):
|
||||
|
||||
- **Judgment rubrics** given to agents (analyzer's 100-point CLAUDE.md rubric,
|
||||
planner's risk formula) — prose is the *medium* of a judgment task, not a bug.
|
||||
- **Narration/UX rules** (ux-rules.md) — degraded output, self-correcting.
|
||||
- **Output budgets** ("MUST NOT exceed 300 lines") — worst case is a long report.
|
||||
- **Harness facts** ("the Write tool requires a prior Read") — enforced upstream.
|
||||
|
||||
Marker-grep is a non-detector here: only **18** MUST/NEVER/ALWAYS-class markers
|
||||
exist across all 28 command+agent files. The invariants are procedural steps
|
||||
whose omission is silent, not shouted rules. They are found by reading, which is
|
||||
why this was a session, not a script.
|
||||
|
||||
## 2. The class finding — the THIRD instance
|
||||
|
||||
Q1 was *gate-in-prose* (policy paraphrased in five templates). Q2 was
|
||||
*argv-in-prose* (caller contract unchecked, 54 pairs). The third instance this
|
||||
sweep asked for is:
|
||||
|
||||
**Class 3 — data-contract-in-prose: prose WRITES what code READS.**
|
||||
|
||||
Q2's mirror image. Templates instruct the model to hand-build files —
|
||||
`manifest.yaml`, `state.yaml`, `scope.yaml`, register edits — that engines,
|
||||
hooks, and later commands then parse. The writer side is a prose schema; the
|
||||
reader side either trusts it or has quietly learned one measured variant of it.
|
||||
The class has already bitten once: `parseManifest` grew its second format branch
|
||||
*after* implement-produced backups made `restoreBackup` "a success-shaped no-op"
|
||||
(comment in `scanners/lib/backup.mjs:172`).
|
||||
|
||||
Two further classes surfaced (the sweep found things it wasn't looking for,
|
||||
again): **Class 4 — contracts an agent cannot honor** (instructions colliding
|
||||
with harness behavior or the agent's own declared tools), and **Class 5 —
|
||||
knowledge tables duplicated between prose and code**.
|
||||
|
||||
## 3. The rated list
|
||||
|
||||
Rated by what breaks if the invariant silently stops holding. R1/R2 outrank
|
||||
everything because they sit on the *recovery* path — the surface that runs
|
||||
exactly when the user is already in trouble, and the least-exercised one.
|
||||
|
||||
### R1 — the restore flow is model-executed prose; the code engine has no CLI entry
|
||||
**CLOSED in #74** — `scanners/rollback-cli.mjs`. The measurement below is kept as written.
|
||||
**Where:** `commands/rollback.md` §Implementation; `scanners/rollback-engine.mjs`.
|
||||
**Measured:** 16 scanner CLIs carry a `process.argv` entry — `rollback-engine.mjs`
|
||||
is not one of them. The template's "Implementation" shows an ESM `import` block a
|
||||
command template cannot execute, then offers ad-hoc `cp` as the runnable
|
||||
alternative. The engine's `restoreBackup()` verifies checksums before AND after
|
||||
each write and returns `createdNotRemoved` — none of it reachable from the
|
||||
command without `node -e`. The success template pre-renders "`(checksum
|
||||
verified)`" — a claim the runnable path never establishes.
|
||||
**What breaks:** the recovery path for every other write the plugin makes. A
|
||||
half-restore or a stale-backup restore lands on user config at the worst
|
||||
possible moment, reported as verified.
|
||||
**Also unguardable as-is:** `command-cli-contract.test.mjs` probes CLIs; with no
|
||||
CLI here, the whole Q2 guard class is structurally blind to this command.
|
||||
**Guard shape:** a thin `rollback-cli.mjs` over `listBackups`/`restoreBackup`/
|
||||
`deleteBackup`; template calls it like every other command; the contract test
|
||||
then covers it for free. The "(checksum verified)" line becomes payload-driven.
|
||||
|
||||
### R2 — implement's backup manifest is hand-built prose; the parser knows one frozen sample of it
|
||||
**CLOSED in #74** — the real fix, not the minimum: `implement.md` Step 3 calls `rollback-cli.mjs --create`, so the prose format has no author left. The measurement below is kept as written.
|
||||
**Where:** `commands/implement.md` Step 3 (mkdir/cp + hand-written
|
||||
`manifest.yaml` with sha256 lines); `scanners/lib/backup.mjs` `parseManifest`;
|
||||
`tests/scanners/rollback-paths.test.mjs:157-186`.
|
||||
**Measured:** the fix pipeline's backups go through code (`fix-engine.mjs:10`
|
||||
imports `createBackup`); the implement pipeline's backup is template prose —
|
||||
two copies of the backup policy, one per pipeline. `parseManifest`'s
|
||||
implement-format branch exists because the seam already failed silently once.
|
||||
The test fixture pinning that format is **hand-written**, not derived from the
|
||||
template's own example block — the exact #63 defect shape ("a hand-typed call is
|
||||
a path no user takes").
|
||||
**What breaks:** implement.md's example drifts (a key renamed, quoting added) →
|
||||
`parseManifest` finds 0 files → every implement backup is unrestorable while
|
||||
`rollback` reports success. Second occurrence of a failure that already happened.
|
||||
**Guard shape:** minimum — derive the parser fixture from `implement.md`'s own
|
||||
fenced YAML (the Q2 extractor trick pointed at an example block). Real fix —
|
||||
implement's Step 3 calls the same `createBackup` code path fix already uses
|
||||
(via the R1 CLI), and the prose format dies entirely.
|
||||
|
||||
### R3 — optimize + feature-gap still instruct agents to write reports the harness blocks
|
||||
**Where:** `commands/optimize.md:121-128`, `commands/feature-gap.md:123`;
|
||||
agents `optimization-lens-agent.md` §Output, `feature-gap-agent.md` §Output.
|
||||
**Measured:** both templates tell the agent to write `*-report.md` to the
|
||||
session dir, then Read that file. The harness note measured on `analyze`
|
||||
(M-BUG-18) blocks agent writes of exactly the report/findings file class;
|
||||
`analyze` was converted to orchestrator-writes, these two arms were left open
|
||||
(tracked in STATE as the open M-BUG-18 class — this rating is its severity call).
|
||||
**What breaks:** the Read step fails or the model improvises a rescue; the
|
||||
command's documented artifact (`optimization-lens-report.md`) may never exist.
|
||||
User-visible flow breakage, no data corruption.
|
||||
**Guard shape:** the `analyze` pattern, already proven: agent returns inline,
|
||||
command persists; `analyze-report-persistence.test.mjs` is the template to copy.
|
||||
|
||||
### R4 — verifier-agent contradicts itself, and its "Read-Only Guarantee" is unenforced
|
||||
**Where:** `agents/verifier-agent.md` — §Output Format says "Append to:
|
||||
implementation-log.md"; §Read-Only Guarantee says "only uses Read, Glob, Grep /
|
||||
never modifies any files"; frontmatter `tools:` lists no write tool.
|
||||
**Measured:** `implement.md` Step 5 was already fixed to return-inline and
|
||||
append orchestrator-side — so the agent's system prompt and the spawn prompt now
|
||||
give OPPOSITE instructions to the same agent. Harness enforcement of `tools:`
|
||||
is measured absent (memory: verifier/implement-log writes went through live).
|
||||
**What breaks:** which instruction wins is nondeterministic; if the agent
|
||||
improvises a write to satisfy its own §Output Format, a full-file Write on the
|
||||
shared log clobbers parallel implementer entries — the precise defect
|
||||
`implement-log-append.test.mjs` exists to prevent, entering through the file
|
||||
that test does not read.
|
||||
**Guard shape:** rewrite verifier-agent's Output section to return-inline (one
|
||||
file), and extend `implement-log-append`/`agent-prompt-shape` to assert no agent
|
||||
prompt instructs appending to the shared log. Cheap.
|
||||
|
||||
### R5 — session state (`state.yaml`, `scope.yaml`) is a model-written machine contract with no schema anywhere
|
||||
**Where:** every phase template ("Write scope.yaml and state.yaml", "append —
|
||||
never replace — completed_phases"), `.claude/rules/state-management.md`,
|
||||
readers in `hooks/scripts/session-start.mjs` + `stop-session-reminder.mjs` +
|
||||
every session-aware command.
|
||||
**Measured:** the phase vocabulary (`discover`…`verify`) appears as a shared
|
||||
constant in **zero** code files — it lives only in prose copies (status.md's
|
||||
table, state-management.md, each template). Hooks parse with a line-grep
|
||||
(`parseYamlValue`) and print whatever they find. The existing guard
|
||||
(`command-shell-state-shape`: "phase commands name all four fields") checks the
|
||||
template *text*, not the written *file*.
|
||||
**What breaks:** resume-by-recency picks wrong sessions, status misnarrates,
|
||||
session-start reminders go quiet — degradation, not corruption, but it erodes
|
||||
exactly the "can resume if interrupted" promise the rule exists for.
|
||||
**Guard shape:** either a state-write CLI (heavy) or a defensive reader: a lib
|
||||
that validates phase tokens + required fields and *flags* malformed state, used
|
||||
by hooks and dogfooded in a test. The plugin flags drift in everyone else's
|
||||
config; its own session state deserves the same reader.
|
||||
|
||||
### R6 — both "Required Frontmatter" contracts are unguarded (currently compliant)
|
||||
**Where:** `.claude/rules/agent-development.md`, `.claude/rules/command-development.md`.
|
||||
**Measured:** no test outside fixtures matches `allowed-tools`;
|
||||
`agent-prompt-shape` asserts only `name:` on **3 of 7** agents. Measured today:
|
||||
7/7 agent frontmatters match the CLAUDE.md table; duplicate colors: **0**. So —
|
||||
compliant, unwatched. Every MUST in those two rules is enforced by nothing.
|
||||
**What breaks:** a new agent/command ships with missing `allowed-tools` or a
|
||||
duplicate color; nothing fails; the rules files become fiction one file at a
|
||||
time (the exemption-table lesson from Q1: what nothing declares, everyone
|
||||
re-answers by reading).
|
||||
**Guard shape:** near-free shape test walking `agents/*.md` + `commands/*.md`
|
||||
asserting the two rules' required keys, name conventions, color uniqueness.
|
||||
Note the irony budget: `plugin-health-scanner` already audits *other* plugins'
|
||||
frontmatter — pointing it at its own repo in a test is the dogfood version.
|
||||
|
||||
### R7 — secret detection exists only as agent prose, in a domain STATE has parked elsewhere
|
||||
**Where:** `agents/scanner-agent.md` §Secret Detection Patterns (xoxb/sk-/ghp_
|
||||
regexes); `agents/verifier-agent.md` Check 7 ("Secrets Scan ✓").
|
||||
**Measured:** `xoxb`/`ghp_` appear in **zero** files under `scanners/`;
|
||||
`mcp-config-validator.mjs` contains the string "secret" **zero** times. The
|
||||
deterministic pipeline has no secret scanning at all; the agent path claims it
|
||||
in prose, and the verifier's report template renders "Secrets Scan ✓ Pass" as a
|
||||
table row regardless. STATE parks secrets as the `llm-security` plugin's domain.
|
||||
**What breaks:** a user reads "Secrets Scan ✓" as an executed check. The lie is
|
||||
in the reporting, not in a missing feature — the feature is deliberately owned
|
||||
elsewhere.
|
||||
**Guard shape:** this is a *removal* candidate, not a gate (measurement can
|
||||
decline the feature): strip the prose secret patterns + the verifier's Check 7,
|
||||
say "secrets: out of scope, see llm-security" where the row used to be. If the
|
||||
capability is ever wanted deterministically, it starts life as a scanner with a
|
||||
finding code, not as agent prose.
|
||||
|
||||
### R8 — knowledge tables duplicated between prose and code
|
||||
**Where/measured:** managed-path table — **three** copies (scanner-agent prose +
|
||||
`file-discovery.mjs` + `active-config-reader.mjs`). Precedence — analyzer prose
|
||||
("global beats managed (user preference)") vs `conflict-detector.mjs:138`
|
||||
("local > project > user"): different vocabularies for the same claim, no link.
|
||||
Optimization-lens agent's mechanism table restates register entries
|
||||
(BP-MECH-001/002/004, BP-SUB-001) that live as data in
|
||||
`knowledge/best-practices.json`.
|
||||
**What breaks:** slow divergence — an agent narrates precedence or hierarchy the
|
||||
deterministic scanners no longer implement. Confusing, not corrupting.
|
||||
**Guard shape:** two-copies rule applies but *measure first* (#67): the two code
|
||||
copies may legitimately differ; the prose copies should cite the code as owner
|
||||
("hierarchy per `file-discovery.mjs`") rather than restate values.
|
||||
|
||||
### R9 — knowledge-refresh applies approved writes by model edit, validated only afterwards
|
||||
**Where:** `commands/knowledge-refresh.md` Step 6 (model `Edit` of
|
||||
`best-practices.json`, then run the schema test and revert on failure).
|
||||
**Measured:** already tracked open in STATE ("knowledge-refresh skrive-CLI");
|
||||
path anchoring is guarded (`knowledge-refresh-write-target.test.mjs`), the write
|
||||
itself is not. The post-hoc validation step is real mitigation — this ranks
|
||||
last *because* the failure is loud (a failing schema test in the same flow).
|
||||
**Guard shape:** the campaign pattern, which this command's own sibling already
|
||||
implements: every mutation a subcommand of a write-CLI. `campaign.md` is the
|
||||
in-repo proof that "human-approved writes" and "CLI-executed writes" compose.
|
||||
|
||||
## 4. What this changes in the plan
|
||||
|
||||
- **Q3 (severity axis)** should carry R1/R2 as its first two rows — recovery-path
|
||||
items never live in a backlog paragraph (plan §2 property 2).
|
||||
- **Q4 (v6.0.0 release)** is NOT blocked by this list (the release gate is Q1 +
|
||||
green suite); but R1+R2 are the strongest candidates for the first post-v6
|
||||
chunk, as one chunk: a rollback/backup CLI closes both, and converts R2's
|
||||
guard from "pin the prose format" to "delete the prose format".
|
||||
- **R4 + R6** are lunch-sized; they can ride along with any adjacent session the
|
||||
way M-BUG fixes have.
|
||||
- **R7** is an operator decision (removing a claimed capability): propose, don't do.
|
||||
|
||||
## Appendix — measurement log
|
||||
|
||||
Every number above, and the command that produced it (run at `30c78ae`):
|
||||
|
||||
| # | Number | Command |
|
||||
|---|--------|---------|
|
||||
| 1 | 21 / 7 / 4 files | `ls commands/*.md \| wc -l` etc. |
|
||||
| 2 | 4,950 lines | `wc -l commands/*.md agents/*.md .claude/rules/*.md` |
|
||||
| 3 | 17 guard files | `find tests/commands tests/agents -name '*.test.mjs' \| wc -l` |
|
||||
| 4 | 18 markers | `grep -cE '\b(MUST\|NEVER\|ALWAYS\|DO NOT\|…)\b' commands/*.md agents/*.md` |
|
||||
| 5 | 16 CLIs, rollback-engine absent | `grep -ln "process.argv" scanners/*.mjs` |
|
||||
| 6 | parseManifest dual-format + its cause | `sed -n '140,220p' scanners/lib/backup.mjs` (comment at :172) |
|
||||
| 7 | hand-written fixture | `grep -n "sha256" tests/scanners/rollback-paths.test.mjs` (:159-186) |
|
||||
| 8 | fix uses code backup | `grep -n "backup" scanners/fix-engine.mjs` (:10) |
|
||||
| 9 | 0 secret patterns in code | `grep -rln "xoxb\|ghp_" scanners/` (empty); `grep -n "secret" scanners/mcp-config-validator.mjs` (empty) |
|
||||
| 10 | 3 copies managed-path table | `grep -rln "Library/Application Support" scanners/` (2) + scanner-agent prose (1) |
|
||||
| 11 | 0 phase-vocabulary constants | `grep -rn "'discover'" scanners/lib/*.mjs hooks/scripts/*.mjs` (empty) |
|
||||
| 12 | 3/7 agents in shape guard | `AGENT_FILES` array read in `tests/agents/agent-prompt-shape.test.mjs` |
|
||||
| 13 | 0 frontmatter guards | `grep -rln "allowed-tools" tests/` (fixtures + yaml-parser only) |
|
||||
| 14 | 0 duplicate colors | `grep -h "^color:" agents/*.md \| sort \| uniq -d \| wc -l` |
|
||||
| 15 | verifier self-contradiction | Read `agents/verifier-agent.md` (:143 append vs :242 read-only) |
|
||||
|
||||
Not covered by this sweep (deliberate): `knowledge/*.md` content freshness
|
||||
(knowledge-refresh's domain), README/plugin.json surface (repo-standard's
|
||||
domain), the deterministic scanners themselves (Q1/Q2 territory, already coded).
|
||||
|
|
@ -12,17 +12,19 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
|||
|---------|--------|---------|
|
||||
| `claude-md-linter.mjs` | CML | Structure, length, sections, @imports, duplicates, TODOs |
|
||||
| `settings-validator.mjs` | SET | Schema, unknown/deprecated keys, type mismatches, permissions |
|
||||
| `hook-validator.mjs` | HKV | Format, script existence, event validity, timeouts |
|
||||
| `hook-validator.mjs` | HKV | Format, script existence, event validity, timeouts, verbose-stdout (low), unfiltered `additionalContext` injection (info advisory, v5.10 B5) |
|
||||
| `rules-validator.mjs` | RUL | Glob matching, orphan rules, deprecated fields, unscoped rules |
|
||||
| `mcp-config-validator.mjs` | MCP | Server types, env vars, unknown fields |
|
||||
| `import-resolver.mjs` | IMP | Broken @imports, circular refs, deep chains, tilde paths |
|
||||
| `conflict-detector.mjs` | CNF | Settings conflicts, permission contradictions, hook duplicates |
|
||||
| `feature-gap-scanner.mjs` | GAP | 25 feature checks across 4 tiers — shown as opportunities, not grades |
|
||||
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascade, bloated SKILL.md descriptions, MCP tool-schema budget (prompt-cache patterns) |
|
||||
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 of CLAUDE.md cascade (beyond Pattern A's top-30 window) |
|
||||
| `feature-gap-scanner.mjs` | GAP | 24 feature checks across 4 tiers — shown as opportunities, not grades |
|
||||
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascade, bloated SKILL.md descriptions, MCP tool-schema budget, MCP tool-schema deferral (CA-TOK-006), stale plugin-cache disk-cleanup (prompt-cache patterns) |
|
||||
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 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` |
|
||||
| `collision-scanner.mjs` | COL | Cross-plugin skill name collisions (low); user-vs-plugin overlaps (medium); `details.namespaces` payload |
|
||||
| `skill-listing-scanner.mjs` | SKL | (1) `CA-SKL-001` (medium): active skill descriptions over the verified 1,536-char listing cap (CC 2.1.105) → silently truncated in the model's skill listing. (2) `CA-SKL-002` (low): sum of active descriptions (each counted up to the cap) over the listing budget (~2% of context, CC 2.1.32), anchored on a conservative 200k window with a calibration note that the budget scales 5× on 1M-context models — leads with the measured sum, an estimate not telemetry. HOME-scoped (all user + plugin skills). Remediation surfaces `disableBundledSkills` / `skillOverrides` / trim. Distinct lens from TOK pattern F (project-local 500-char bloat heuristic) |
|
||||
| `output-style-scanner.mjs` | OST | (1) `CA-OST-001` (medium): a user/project custom output style not setting `keep-coding-instructions: true` (defaults false) → silently strips Claude Code's built-in software-engineering instructions when active (V10). (2) `CA-OST-002` (low): a **plugin** style with `force-for-plugin: true` auto-applies and overrides the user's `outputStyle` (V11; plugin-styles-only per docs). (3) `CA-OST-003` (medium): a settings `outputStyle` matching no built-in (Default/Explanatory/Learning/Proactive, case-insensitive) nor discovered custom style → dead config (CC falls back to default). Reads each style's frontmatter via `parseFrontmatter`; fixture-gated (silent with no output styles). New scanner family in v5.6 C (count 13→14) |
|
||||
| `optimization-lens-scanner.mjs` | OPT | `CA-OPT-001` (low, *Missed opportunity*): a CLAUDE.md procedure (≥6 consecutive numbered steps) that belongs in a skill (mechanism-fit, `BP-MECH-003`). Reads the machine-readable best-practices register (`best-practices-register.mjs`) for recommendation + provenance. Conservative — negative corpus proves null false-positive; prose-judgment cases (lifecycle→hook, "never"→permission) deferred to the Chunk 2b opus analyzer. Scoring area `CLAUDE.md` (existing → byte-stable). New scanner family in v5.7 Fase 1 Chunk 2a (count 14→15) |
|
||||
|
||||
## Scanner Lib (`scanners/lib/`)
|
||||
|
||||
|
|
@ -42,7 +44,9 @@ 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. |
|
||||
| `humanizer-data.mjs` | TRANSLATIONS table for 14 scanner prefixes (CML/SET/HKV/RUL/MCP/IMP/CNF/COL/TOK/CPS/DIS/GAP/PLH/SKL). Three-step lookup: exact title → regex pattern → `_default` → fall through to original |
|
||||
| `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/`)
|
||||
|
||||
|
|
@ -53,8 +57,8 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
|||
| `fix-cli.mjs` | CLI: `node fix-cli.mjs <path> [--apply] [--json] [--global]` |
|
||||
| `drift-cli.mjs` | CLI: `node drift-cli.mjs <path> [--save] [--baseline name] [--json]` |
|
||||
| `whats-active.mjs` | CLI: `node whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` — read-only active-config inventory |
|
||||
| `token-hotspots-cli.mjs` | CLI: `node token-hotspots-cli.mjs <path> [--json] [--global] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` — prompt-cache token hotspots ranking with optional API calibration |
|
||||
| `manifest.mjs` | CLI: `node manifest.mjs <path> [--json]` — ranked system-prompt token-source table (v5 N2) |
|
||||
| `token-hotspots-cli.mjs` | CLI: `node token-hotspots-cli.mjs <path> [--json] [--global] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` — prompt-cache token hotspots ranking (each hotspot tagged with its load pattern, v5.6 B2) with optional API calibration |
|
||||
| `manifest.mjs` | CLI: `node manifest.mjs <path> [--json]` — ranked component-level token-source table, each source tagged with its load pattern + an always-loaded subtotal (v5 N2; load-pattern accounting v5.6 B) |
|
||||
|
||||
## Standalone Scanner
|
||||
|
||||
|
|
@ -75,3 +79,797 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
|
|||
| `gap-closure-templates.md` | Config-specific templates for closing gaps |
|
||||
| `prompt-cache-patterns.md` | Token-cost dynamics (prompt-cache patterns) — patterns powering the TOK scanner |
|
||||
| `cache-telemetry-recipe.md` | Manual `jq` recipe for verifying prompt-cache hit rate from session transcripts (v5 M7) |
|
||||
|
||||
## Implementation notes (per scanner / build block)
|
||||
|
||||
Detailed design rationale, primary-source verification, and byte-stability lessons for each scanner family and v5.6/v5.7 build block. Moved out of `CLAUDE.md` (kept lean per the "invariants only" rule); each note records why a change is correct and which frozen baselines it touched. Read on demand when working on the named scanner/block.
|
||||
|
||||
### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation)
|
||||
|
||||
`scanners/lib/active-config-reader.mjs` now enumerates the three source kinds it previously
|
||||
missed — **rules** (`enumerateRules`), **agents** (`enumerateAgents`), and **output styles**
|
||||
(`enumerateOutputStyles`) — alongside the existing CLAUDE.md/plugins/skills/hooks/MCP enumerators.
|
||||
Each new item, plus a pure `deriveLoadPattern(kind, {scoped})` helper, carries a
|
||||
`loadPattern ∈ {always, on-demand, external}`, `survivesCompaction ∈ {yes, no, n/a}`, and
|
||||
`derivationConfidence ∈ {confirmed, inferred}` derived from the published Claude Code loading
|
||||
model (the V-rows in `docs/v5.5-steering-model-plan.md`). `readActiveConfig` exposes `rules`/
|
||||
`agents`/`outputStyles` arrays + `totals` counts/subtotals (folded into `grandTotal`). This is
|
||||
**internal plumbing** for v5.6 B (manifest/tokens rendering) — no command output changes yet, so
|
||||
`--json`/`--raw`/SC-5 stay byte-stable. Output-style discovery is done directly (mirroring
|
||||
`enumerateSkills`), **not** via a new `file-discovery` type, to keep the discovery surface stable.
|
||||
|
||||
The frontmatter parser (`scanners/lib/yaml-parser.mjs`) now also reads **YAML block sequences**
|
||||
(`paths:\n - a\n - b`), not just inline `paths: "a, b"`. This resolves a pre-existing RUL
|
||||
false-positive (a block-sequence-scoped rule was misread as unscoped). An empty-valued key with
|
||||
no following `- ` items still resolves to `null` (backwards-compatible); only a real `- ` item
|
||||
list becomes an array.
|
||||
|
||||
### manifest — load-pattern accounting (v5.6 B)
|
||||
|
||||
`buildManifest` (`scanners/manifest.mjs`) now consumes the Foundation enumeration. Two changes:
|
||||
|
||||
1. **Component-level sources (plugin roll-up dropped).** The coarse `kind:'plugin'` aggregate is
|
||||
gone. A plugin contributes via its skills/rules/agents/output-styles/hooks/MCP — each already
|
||||
enumerated **once** by `readActiveConfig` — so the old roll-up double-counted them (the plugin
|
||||
aggregate's `estimatedTokens` already summed its components). Source kinds are now
|
||||
`claude-md`/`skill`/`rule`/`agent`/`output-style`/`mcp-server`/`hook`.
|
||||
2. **Load-pattern triple on every record + a `summary`.** Each source carries
|
||||
`loadPattern`/`survivesCompaction`/`derivationConfidence`. Rules/agents/output-styles
|
||||
**propagate** the foundation-derived values (rules vary by `scoped`); CLAUDE.md maps `scope`→
|
||||
kind via `CLAUDE_MD_SCOPE_KIND` (all cascade files walk **up**, so all are always-loaded);
|
||||
skills are tagged **on-demand** via `deriveLoadPattern('skill-body')` — the measured tokens are
|
||||
the skill **body** (paid on invoke), not the tiny always-loaded name+desc listing (tracked by
|
||||
`skill-listing-budget`/posture), so tagging the body always would inflate the headline. The new
|
||||
`summary` buckets sources into `always`/`onDemand`/`external`/`unknown` `{tokens,count}`; the
|
||||
**always-loaded subtotal** ("≈X tokens enter context every turn before you type") is the headline.
|
||||
|
||||
**Byte-stability.** manifest is an **environment-aware CLI** → SC-6/SC-7 verify it by
|
||||
**mode-equivalence** (`--json == --raw`), not byte-equal against a frozen snapshot, and it is not in
|
||||
SC-5 default-output. Adding fields in place therefore keeps all snapshots green with **no regen**
|
||||
(verified). `total` changes (de-duped, component-level) — that is the intended correctness fix.
|
||||
|
||||
### token-hotspots — load-pattern column (v5.6 B2)
|
||||
|
||||
TOK now annotates every ranked hotspot with the same load-pattern triple (`hotspotLoadPattern`
|
||||
maps each discovery `type`→a `deriveLoadPattern` kind; rules reuse `activeConfig.rules` for precise
|
||||
`scoped` handling; `claude-md` maps by scope). Two new `deriveLoadPattern` kinds back this:
|
||||
**`command`** (on-demand — body loads on `/invoke`) and **`harness-config`** (external — settings/
|
||||
keybindings/`.mcp.json`/hooks.json/plugin.json configure the CLI, **not** the model context, so they
|
||||
cost no per-turn context tokens). Note the honest split: the `.mcp.json` **file** is `external`,
|
||||
while the MCP **server**'s tool schemas are a separate `always` hotspot.
|
||||
|
||||
**Byte-stability — the opposite of manifest.** token-hotspots **is** a byte-equal SC-6/SC-7 CLI,
|
||||
**and** its hotspots ride inside the scan-orchestrator + posture payloads, so the change broke
|
||||
**six** frozen-v5.0.0 comparisons across five test files (json/raw-backcompat + the three Step 5/6/7
|
||||
humanizer tests). Resolved by **preserving the frozen v5.0.0 baselines**: a shared
|
||||
`tests/helpers/strip-hotspot-load-pattern.mjs` strips the additive triple before each byte-equal
|
||||
compare (proves the original schema is byte-identical), and the **SC-5 default-output** snapshots
|
||||
(scan-orchestrator + token-hotspots) were **regenerated** (`UPDATE_SNAPSHOT=1`) since their job is to
|
||||
track current output — diff reviewed as additive-only. **Lesson for any future hotspot/scanner-output
|
||||
field:** grep every frozen-v5.0.0 comparator (it is 5 files, not 2) before assuming the blast radius.
|
||||
|
||||
### token-hotspots — MCP tool-schema deferral (v5.10 B4, CA-TOK-006)
|
||||
|
||||
By default Claude Code **defers** MCP tool schemas: only tool *names* enter the always-loaded prefix
|
||||
(~120 tokens total) and full schemas load on demand via tool search. Several signals force the FULL
|
||||
schemas into the prefix every turn instead. CA-TOK-006 detects them from **config files only**, so the
|
||||
finding is deterministic and hermetic-safe (mirrors Pattern G's project-local scoping):
|
||||
|
||||
| Signal | Source | Confidence |
|
||||
|--------|--------|------------|
|
||||
| `ENABLE_TOOL_SEARCH: "false"` | merged project+local settings.json `env` block | high |
|
||||
| `"ToolSearch"` in `permissions.deny` | settings.json | high |
|
||||
| configured `model` matches `/haiku/` | settings.json (Haiku lacks `tool_reference` support) | medium |
|
||||
| per-server `alwaysLoad: true` | project `.mcp.json` (CC v2.1.121+) | high |
|
||||
| `ENABLE_TOOL_SEARCH: "auto[:N]"` | settings `env` | threshold mode — **info, not a trigger** |
|
||||
|
||||
The engine (`lib/mcp-deferral.mjs`) splits a **pure** `assessMcpDeferral({settings, mcpServers})`
|
||||
(fully unit-tested, no IO) from a thin IO wrapper `assessMcpDeferralForRepo(repoPath, {mcpServers})`
|
||||
shared by TOK and GAP. Severity scales with the aggregate forced-upfront token cost
|
||||
(`severityForForcedSchemas`: ≥5000→high, ≥1500→medium; **medium-confidence reasons cap at medium**).
|
||||
|
||||
**Honest scoping decision (Verifiseringsplikt).** The detector deliberately does **NOT** read
|
||||
`process.env` shell vars. Tool search is also disabled on **Vertex AI**, with a custom
|
||||
**`ANTHROPIC_BASE_URL`** (non-first-party host), or after a runtime **`/model`** switch to Haiku — but
|
||||
those are launch/runtime state, not config files, so triggering on them would make the finding
|
||||
machine-dependent (the marketplace-medium snapshot has MCP servers; an ambient `ANTHROPIC_BASE_URL`
|
||||
would flap it). They are **disclosed** in every finding (`DEFERRAL_DISCLOSURE`), never triggered.
|
||||
Tool-level `anthropic/alwaysLoad` (set server-side in the `tools/list` `_meta`) and claude.ai
|
||||
connectors are likewise invisible to a static scan and disclosed. Mechanism verified 2026-06-23
|
||||
against `code.claude.com/docs`: `context-window.md` (MCP deferred, ~120 tok),
|
||||
`mcp.md#configure-tool-search` + `#exempt-a-server-from-deferral`, `costs.md`. The prefix-cache
|
||||
connect/disconnect-invalidation claim from the raw research was **`[NOT CONFIRMED]`** in docs and is
|
||||
NOT asserted by this finding.
|
||||
|
||||
**feature-gap companion.** `cliOverMcpLeverFinding` (GAP) fires **only** when CA-TOK-006's assessment
|
||||
shows schemas forced upfront — recommends preferring CLI (`gh`/`aws`/`gcloud`) over MCP for common
|
||||
operations (CLI adds zero context tokens until invoked). Deferred MCP is effectively free, so the
|
||||
lever stays silent in the default case (opportunity, not noise — mirrors the bundledSkills lever).
|
||||
`alwaysLoad` was added to CA-MCP's `VALID_SERVER_FIELDS` so it is never flagged as an unknown field.
|
||||
|
||||
### hook-validator — unfiltered additionalContext advisory (v5.10 B5)
|
||||
|
||||
A hook that emits `hookSpecificOutput.additionalContext` has that payload injected into Claude's
|
||||
context **every time it fires** — plain stdout on exit 0 does NOT (it goes to the debug log only). A
|
||||
hook that dumps large, un-grepped command output into `additionalContext` is therefore a recurring,
|
||||
compaction-sensitive per-turn token cost. HKV flags it as an **`info` advisory** (weight 0 — never
|
||||
severity-bearing, excluded from the self-audit `nonInfo` set), paired with a feature-gap lever.
|
||||
|
||||
The heuristic lives in `lib/hook-additional-context.mjs` as a **pure** `assessHookAdditionalContext({scriptContent})`
|
||||
(unit-tested, no IO) plus a thin IO wrapper `assessHookContextForRepo(discovery)` (walk hooks → scripts
|
||||
→ assess) used by GAP; HKV calls the pure function inline on scripts it already reads. The signal:
|
||||
|
||||
| Condition | Detected by | Effect |
|
||||
|-----------|-------------|--------|
|
||||
| references `additionalContext` | `/additionalContext/` | gate (else not applicable) |
|
||||
| captures verbose-prone output | `cat`/`find`/`ls`/`git log\|diff\|status\|show`/`npm`/`pytest`/`jest`/`curl`/`execSync`/`readFileSync`… | `hasVerboseCapture` |
|
||||
| applies any truncating filter | `grep`/`head`/`tail`/`sed`/`awk`/`jq`/`cut`/`wc`/`uniq`/`sort` or `.slice`/`.substring` | suppresses (assumed bounded) |
|
||||
|
||||
`flagged = buildsAdditionalContext && hasVerboseCapture && !hasFilter`. A filtered capture (e.g.
|
||||
`cat … | grep ERROR`) or a cheap-only capture (`$(date)`) is not flagged.
|
||||
|
||||
**Why `info`, not a hard finding (Verifiseringsplikt).** This is deliberately **low precision** — a
|
||||
static scan cannot run the hook or measure the real payload, and a filter we don't recognise would be
|
||||
a false positive. So it ships as an advisory with the precision caveat in its own description, never a
|
||||
graded/severity-bearing finding. Mechanism verified 2026-06-23 against `code.claude.com/docs`:
|
||||
`context-window.md` — *"A PostToolUse hook … reports back via `hookSpecificOutput.additionalContext`.
|
||||
That field enters Claude's context. Plain stdout on exit 0 does not."* + the tip to keep output concise
|
||||
(it enters context without truncation).
|
||||
|
||||
**feature-gap companion.** `filterHookLeverFinding` (GAP) fires **only** when `assessHookContextForRepo`
|
||||
returns ≥1 chatty hook — surfaces the documented **filter-before-Claude-reads** lever (`filter-test-output.sh`:
|
||||
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
|
||||
arbitrary files into context with `@import` directives, and those targets are usually *not* `claude-md`
|
||||
in discovery (e.g. `@shared/conventions.md`) — so their content was inlined into the cached prefix yet
|
||||
never inspected. Neither TOK Pattern A (top-30 of cascade files) nor the in-file CPS scan reaches past
|
||||
the importing file, so volatility in an imported file was invisible.
|
||||
|
||||
B6 closes the gap: for each `@import` whose **import site** sits within the cached-prefix window
|
||||
(`imp.line ≤ CACHED_PREFIX_LINES`), CPS resolves the path (`resolveImportPath`, mirroring
|
||||
import-resolver/token-hotspots semantics), reads the target, and runs `findVolatileLines` over its first
|
||||
150 lines. A hit emits a distinct medium finding — *"Volatile content in @imported file breaks cached
|
||||
prefix"* — keyed on the resolved file (so the fix points at the right place), with evidence naming the
|
||||
importer (`imported by <file> (@<path> at line N)`).
|
||||
|
||||
**Scope boundaries (deliberate):**
|
||||
- **One hop only.** Imports-of-imports are not followed — IMP owns deep-chain analysis. The verified win
|
||||
is the direct import; transitive resolution adds cycle/depth complexity for marginal coverage.
|
||||
- **No lines-1–30 skip for imported content.** That exclusion exists only to avoid duplicating TOK
|
||||
Pattern A's territory in the *root* file; Pattern A never reads imported files, so all of the imported
|
||||
prefix counts.
|
||||
- **No double-reporting.** An import resolving to a file that is itself a discovered `claude-md` is
|
||||
skipped (it gets its own in-file iteration); a `reportedImports` set dedupes a target imported by
|
||||
several CLAUDE.md files.
|
||||
|
||||
**Byte-stability.** The in-file finding is emitted under exactly the same condition and with byte-identical
|
||||
evidence/description as before (the `continue`-skip was refactored to an `if`-emit — behaviour-preserving).
|
||||
New findings fire only when a discovered CLAUDE.md imports a volatile file, which no frozen v5.0.0 fixture
|
||||
does — snapshots and SC-5 verified untouched by the full suite.
|
||||
|
||||
**Dropped from B6 (per plan `verdict`).** Confident behavioral cache-buster detection (opusplan /
|
||||
model-switch is a *runtime* behaviour, not static config a scanner can reliably flag) and jq-transcript
|
||||
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
|
||||
Claude Code's own startup warning — *"Large CLAUDE.md will impact performance
|
||||
(X chars > 40.0k)"* — as a `char`-based finding:
|
||||
|
||||
- **Char budget** — flags a CLAUDE.md over **~40.0k chars** (CC's startup-warning
|
||||
figure at a 200k-context model). CC 2.1.169 scales that threshold with the model's
|
||||
context window, so the finding anchors on the conservative 200k window (we cannot
|
||||
observe the user's window; the anchor fires earliest) and discloses the relaxed
|
||||
~200,000-char figure at 1M context. Severity MEDIUM (token cost, not an adherence
|
||||
cliff). New `CA-CML` finding.
|
||||
|
||||
It keys on chars, not lines, so it is complementary to the line checks: a file can be
|
||||
long by lines yet under budget (short lines), or short by lines yet over it (long lines).
|
||||
The 200k/1M window constants live in the shared `scanners/lib/context-window.mjs`
|
||||
(single source of truth, also re-exported by `skill-listing-budget.mjs`). The 40.0k
|
||||
figure and context-window scaling are verified against the CC changelog (2.1.169) and
|
||||
the live startup-warning text.
|
||||
|
||||
### DIS scanner — permission-rule hygiene
|
||||
|
||||
Beyond deny/allow overlap, the DIS scanner now also flags:
|
||||
|
||||
- **Ineffective allow wildcards** — unanchored tool-name globs in `permissions.allow`
|
||||
(`*`, `B*`, `mcp__*`) that Claude Code silently skips (auto-approve nothing). Valid
|
||||
only as a glob-free `mcp__<server>__*`. New `CA-DIS` finding, severity low.
|
||||
- **`Tool(*)` deny-all glob** — treated as equivalent to a bare deny (`Bash(*)` ≡ `Bash`),
|
||||
so a bare allow killed by it is correctly reported as dead config.
|
||||
- **Forbidden-param rules** — `Tool(param:value)` whose key is the tool's own canonicalizing
|
||||
field (`command` for Bash/PowerShell, `file_path` for Read/Edit/Write, `path` for
|
||||
Grep/Glob, `notebook_path` for NotebookEdit, `url` for WebFetch). CC ignores these and
|
||||
emits a startup warning. Severity follows intent: **deny/ask = false security (medium)** —
|
||||
the block never applies; **allow = dead config (low)** — `param:value` matching is
|
||||
deny/ask-only. Valid forms (`Bash(npm:*)`, `WebFetch(domain:host)`, `Agent(model:opus)`)
|
||||
are never flagged. Predicate `forbiddenParamRule` in `permission-rules.mjs`.
|
||||
|
||||
These predicates live in `scanners/lib/permission-rules.mjs` (shared with the CNF
|
||||
conflict-detector). Behavior verified against `code.claude.com/docs/en/permissions`.
|
||||
|
||||
### PLH scanner — plugin namespace collision
|
||||
|
||||
The standalone PLH scanner (cross-plugin checks in `scan()`) flags **plugin namespace
|
||||
collisions**: two or more discovered plugins that declare the **same `name`** in
|
||||
`plugin.json`. The search-first finding that shaped this check: Claude Code namespaces
|
||||
every plugin component by the declared `name` — `/name:command`, `name:skill`, agent
|
||||
`name` (verified against `code.claude.com/docs/en/plugins`, and observable in any session's
|
||||
namespaced skill listing). A plugin component therefore can **never** shadow a user- or
|
||||
project-level one; the only shadow that loses components is a same-`name` collision, where
|
||||
the namespaces collapse into one and CC must pick a winner. Resolution between two installed
|
||||
same-name plugins is **undocumented**, so the loser's commands/skills/agents go silently
|
||||
unreachable — hence severity **MEDIUM** (dead config), `category: 'plugin-hygiene'`, with a
|
||||
COL-shaped `details.namespaces` payload (`{ source: 'plugin:<dir>', name, path }`).
|
||||
|
||||
Two design notes: (1) the check keys on the declared `name` field, **not** `basename(dir)` —
|
||||
the folder name is irrelevant to the namespace; `scanSinglePlugin` now returns `declaredName`
|
||||
for this. (2) Name-less plugins are excluded from the collision map (they are flagged by the
|
||||
missing-field check and must never group on an `undefined` key).
|
||||
|
||||
The sibling cross-plugin **command-name** check was corrected to match the same model. Because
|
||||
commands are namespaced (`/name:command`), a command name shared by two **differently-named**
|
||||
plugins is ambiguity — not a hard conflict — so it now mirrors COL's plugin-vs-plugin skill
|
||||
finding: severity **LOW**, `category: 'plugin-hygiene'`, COL-shaped `details.namespaces`, and a
|
||||
group-first shape (one finding per command name listing every namespace, not pairwise). It keys
|
||||
on the declared namespace and fires only when a name spans **2+ distinct** namespaces; when two
|
||||
plugins share the same declared name, the namespace-collision finding above is the right (more
|
||||
severe) signal, so the command check stays silent there to avoid a redundant `"dup, dup"` report.
|
||||
The earlier HIGH `Cross-plugin command name conflict` finding (basename-keyed, "only one wins")
|
||||
is gone, along with its now-inaccurate humanizer entry.
|
||||
|
||||
### PLH scanner — plugin-folder shadowing (`CA-PLH-015`)
|
||||
|
||||
Per-plugin check (in `scanSinglePlugin`, right after the required-field loop): a `plugin.json`
|
||||
component-path key that **replaces** its default folder while that folder still exists on disk →
|
||||
the folder is silently ignored (dead config). Severity **MEDIUM**, `category: 'plugin-hygiene'`,
|
||||
`details: { field, ignoredDir, customPaths }`. Mirrors Claude Code's own warning in `/doctor`,
|
||||
`claude plugin list`, and the `/plugin` detail view (v2.1.140+).
|
||||
|
||||
The field set is **primary-source-pinned** to the *replaces* category only —
|
||||
`SHADOWING_PATH_FIELDS` = `commands`/`agents`/`outputStyles` (defaults `commands/`, `agents/`,
|
||||
`output-styles/`). Deliberately excluded: **`skills`** (per
|
||||
`code.claude.com/docs/.../path-behavior-rules` it *adds to* the default `skills/` scan — both
|
||||
load, never a shadow), and **`hooks`/`mcpServers`/`lspServers`** (own merge rules, not a
|
||||
folder-shadow). Experimental `themes`/`monitors` are omitted because the docs warn their manifest
|
||||
schema may change between releases. The check also honors the doc's explicit-address exception: a
|
||||
custom path that resolves *into* the default folder (`"commands": ["./commands/x.md"]`) is not
|
||||
flagged, because Claude Code keeps scanning the folder in that case (`addressesDefaultDir`
|
||||
predicate). The v5.4.0 plan originally listed `commands/agents/skills/hooks`; that set was
|
||||
corrected here against the live docs (Verifiseringsplikt).
|
||||
|
||||
### PLH scanner — skills:-array validation (`CA-PLH-016`)
|
||||
|
||||
Per-plugin check (in `scanSinglePlugin`, after the shadow check): when `plugin.json` has a
|
||||
`skills` field (string or array), each entry must resolve to an **existing directory inside the
|
||||
plugin root**. The value is normalized `Array.isArray(v) ? v : [v]`, so a single string is one
|
||||
entry — and a non-string top-level value (e.g. `42`) is naturally caught as a single non-string
|
||||
entry (no separate top-level check needed). One finding per bad entry, severity **MEDIUM**,
|
||||
`category: 'plugin-hygiene'`, `details: { field: 'skills', entry, problem }` where `problem` is
|
||||
one of `non-string` / `escapes-root` / `not-found` / `not-a-directory`. Mirrors
|
||||
`claude plugin validate` (~2.1.145).
|
||||
|
||||
Escape detection uses `skillsEntryEscapesRoot` (resolve + `startsWith(pluginDir + sep)`
|
||||
containment — robust against a literal `..foo` dir name), backed by the docs' path-traversal rule
|
||||
(*"Installed plugins cannot reference files outside their directory … such as `../shared-utils`"*).
|
||||
`statOrNull` distinguishes missing from file-vs-dir. **Verifiseringsplikt note:** the v5.4.0 plan
|
||||
claimed CC "suggests the parent directory when an entry points at a file"; that exact error text is
|
||||
**not** in the primary docs, so it was dropped — the finding asserts only the four
|
||||
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
|
||||
validation). Two sub-checks, both primary-source-verified against
|
||||
`code.claude.com/docs/en/auto-mode-config`:
|
||||
|
||||
1. **Structure** (severity **MEDIUM**): `autoMode`, if present, must be an object whose only keys
|
||||
are `environment`/`allow`/`soft_deny`/`hard_deny` (`AUTO_MODE_SUBKEYS`), each a **string
|
||||
array** (the literal `"$defaults"` is a valid entry, so it passes the string check for free).
|
||||
`problem` ∈ `not-an-object` / `unknown-subkey` / `not-string-array` in `details`.
|
||||
2. **Dead-config** (severity **LOW**): Claude Code does **not** read `autoMode` from *shared*
|
||||
project settings — verbatim: *"The classifier does not read `autoMode` from shared project
|
||||
settings in `.claude/settings.json`, so a checked-in repo cannot inject its own allow rules."*
|
||||
The check keys on **`file.scope === 'project'`** (file-discovery's `classifyScope` returns
|
||||
`'project'` for a committed `.claude/settings.json`; `'local'`/`'user'`/`'managed'` are read and
|
||||
not flagged). `problem: 'shared-project-scope'`. This is why the plan's "test per-file scope
|
||||
first" gate passed — `ConfigFile` already carries `scope`.
|
||||
|
||||
The two sub-checks are independent (a malformed autoMode in shared scope yields both). SET is in the
|
||||
orchestrator, so SC-5 was re-checked after this change — byte-equal (the snapshot fixture has no
|
||||
`autoMode`, so the block never fires there).
|
||||
|
||||
### OST scanner — output-style validation (`CA-OST`, v5.6 C, count 13→14)
|
||||
|
||||
New orchestrated scanner `output-style-scanner.mjs` — the first new scanner family since SKL
|
||||
(v5.2.0). It reads the active config (`readActiveConfig`) and each output-style file's frontmatter
|
||||
(via `parseFrontmatter`, keys hyphen→underscore-normalized, so it reads `keep_coding_instructions` /
|
||||
`force_for_plugin`). Three findings, every claim pinned to a CONFIRMED row of
|
||||
`docs/v5.5-steering-model-plan.md` (V9/V10/V11/V12), re-verified against
|
||||
`code.claude.com/docs/en/output-styles` + `.../plugins-reference`:
|
||||
|
||||
- **`CA-OST-001`** (medium) — a **user/project** custom style not setting `keep-coding-instructions:
|
||||
true`. The flag defaults to **false**, so the style silently **removes** Claude Code's built-in
|
||||
software-engineering instructions when active (V10). Scoped to user/project (the styles the user
|
||||
authors); a plugin author's choice is out of scope.
|
||||
- **`CA-OST-002`** (low) — a **plugin** style with `force-for-plugin: true`, which auto-applies and
|
||||
**overrides** the user's selected `outputStyle` (V11). **Verifiseringsplikt correction:** the v5.5+
|
||||
plan's CA-OST-002 bullet said "in a project/user style," but `force-for-plugin` is
|
||||
**plugin-styles-only** per the docs (its own cited V11 + `output-styles.md`), so the check keys on
|
||||
`source === 'plugin'` — a user/project style with the flag is simply ignored, not an override.
|
||||
- **`CA-OST-003`** (medium) — a settings `outputStyle` value resolving to **no** built-in
|
||||
(`Default`/`Explanatory`/`Learning`/`Proactive`, matched case-insensitively) and **no** discovered
|
||||
custom style → dead config (CC falls back to default; the configured behavior never applies).
|
||||
|
||||
**Byte-stability — a scanner addition, NOT a field addition.** Adding the 14th scanner grows
|
||||
`envelope.scanners` by one entry and bumps `aggregate.scanners_ok` 12→13 on the deterministic
|
||||
fixture **regardless of findings** — a field-strip helper cannot paper this over. The SKL precedent
|
||||
(`7bb2547`) re-seeded the frozen v5.0.0 snapshots, but that predates B2's strip-preservation regime;
|
||||
re-seeding now would **bake in** B2's hotspot triple + `claudeMdEstimatedTokens` drift (verified by
|
||||
inspecting the seed diff). So, consistent with the B2 lesson ("preserve frozen via strip-helper;
|
||||
regen ONLY SC-5"), C **preserves** the frozen v5.0.0 snapshots and **strips the OST entry at compare
|
||||
time**: shared `tests/helpers/strip-added-scanner.mjs` (`stripAddedScanners` removes OST entries +
|
||||
decrements `scanners_ok`; `stripAddedScannerStderr` drops the `[OST]` progress line) is wired into
|
||||
json/raw-backcompat + the Step 5/6 humanizer wiring tests (cli-humanizer did **not** break — its
|
||||
v5.0.0 compares don't grow a scanners array). Only **SC-5 default-output** (scan-orchestrator +
|
||||
posture) is regenerated (additive OST entry only — diff reviewed). OST is fixture-gated: the
|
||||
`marketplace-medium` fixture and the hermetic HOME have no output styles, so it emits nothing there.
|
||||
|
||||
Wiring: orchestrator import + `SCANNERS` entry; `humanizer.mjs` `SCANNER_TO_CATEGORY`
|
||||
(`OST: 'Configuration mistake'`); `humanizer-data.mjs` OST family (title-coupled to the three exact
|
||||
finding titles); `scoring.mjs` `SCANNER_AREA_MAP` (`OST: 'Settings'` — keeps the 10 quality areas,
|
||||
byte-stable on zero-finding projects). Count badges: self-audit scanner count 13→14; humanizer-data
|
||||
TRANSLATIONS families 14→15 (PLH is a translation family but not orchestrated).
|
||||
|
||||
### best-practices register — machine-readable knowledge layer (v5.7 Fase 1 Chunk 1)
|
||||
|
||||
`knowledge/best-practices.json`: provenance-stamped, schema-validated register (entry =
|
||||
`id`/`claim`/`confidence`/`source` + optional `mechanism`/`lensCheck`/…). First runtime-consumed
|
||||
file in `knowledge/` (the `*.md` stay human-only); source of truth for the v5.7 optimization lens
|
||||
(`CA-OPT`); seeded from the v5.5 V-rows + the Anthropic "Steering Claude Code" blog. Only
|
||||
**confirmed** entries are user-facing (Verifiseringsplikt). Loaded/validated by
|
||||
`scanners/lib/best-practices-register.mjs` (`loadRegister`/`validateRegister`/`getEntry`; zero-dep
|
||||
JSON, **not** YAML — `yaml-parser.mjs` can't do arrays-of-objects). Byte-stable until a scanner
|
||||
consumes it (Chunk 2). Full design: `docs/v5.7-optimization-lens-plan.md`.
|
||||
|
||||
### OPT scanner — optimization lens / mechanism-fit (`CA-OPT`, v5.7 Fase 1 Chunk 2a, count 14→15)
|
||||
|
||||
First detector of the «optimal?» axis (vs «correct?»). `optimization-lens-scanner.mjs` reads the
|
||||
best-practices register and flags config that works but fits a better mechanism. **`CA-OPT-001`**
|
||||
(low, *Missed opportunity*): a CLAUDE.md procedure (≥6 consecutive numbered steps) that belongs in a
|
||||
skill — recommendation/provenance from register `BP-MECH-003`. Conservative (negative corpus = null
|
||||
false-positive); prose-judgment cases (lifecycle→hook, unscoped path→rule, «never»→permission) are
|
||||
handled by the Chunk 2b opus analyzer (below). Wiring mirrors OST: orchestrator entry, humanizer
|
||||
`OPT:'Missed opportunity'` + family, scoring `OPT:'CLAUDE.md'` (existing area → no new posture row →
|
||||
byte-stable), strip-helper `OPT`, SC-5 regenerated (additive).
|
||||
|
||||
### Optimization lens Chunk 2b — opus analyzer (prose-judgment half, `/config-audit optimize`)
|
||||
|
||||
The hybrid motor's recall + precision halves for the three cases the deterministic OPT scanner skips.
|
||||
**Pre-filter** (`scanners/lib/lens-prefilter.mjs`, pure + tested): cheap, recall-oriented line scan
|
||||
of CLAUDE.md body for lifecycle phrasing (`BP-MECH-001`→hook), unscoped path-specific instructions
|
||||
(`BP-MECH-002`→rule), and absolute «never» prohibitions (`BP-MECH-004`→permission); skips fenced
|
||||
code, gates the path class on an instruction verb. Detector names = the register `lensCheck` fields.
|
||||
**CLI** (`optimize-lens-cli.mjs`, `-cli` → not a scanner): runs discovery + OPT scanner + pre-filter,
|
||||
attaches the **confirmed** register entry to each candidate (unverifiable → dropped, Verifiseringsplikt),
|
||||
emits `{deterministic, candidates, register, counts}`. **Agent** (`optimization-lens-agent`, opus,
|
||||
orange — the 7th agent, **precision gate**): reads the real CLAUDE.md, drops low-confidence candidates,
|
||||
keeps only genuine opportunities, cites register id + source. **Command** `/config-audit optimize`
|
||||
orchestrates pre-filter→agent→report. **Agent-driven → deliberately NOT byte-stable** (own command,
|
||||
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 ~850–1400 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.
|
||||
|
||||
### knowledge-refresh — the "living" half of the register (v5.7 Fase 1 Chunk 3, commands 19→20)
|
||||
|
||||
Keeps `knowledge/best-practices.json` current so the optimization lens never reads stale rules.
|
||||
Same hybrid split as Chunk 2b — a deterministic, byte-stable, unit-tested core + a web/judgment shell:
|
||||
|
||||
- **Deterministic core** (`scanners/lib/knowledge-refresh.mjs`, pure, 15 tests): `assessFreshness(register,
|
||||
{referenceDate, staleAfterDays})` classifies each entry `fresh`/`stale` by the age of its
|
||||
`source.verified` stamp. `referenceDate` is **injected** (not read from the clock) so the function is
|
||||
fully deterministic; default threshold `STALE_AFTER_DAYS_DEFAULT = 90` (quarterly re-verify cadence).
|
||||
An unparseable/missing `verified` → stale with `ageDays: null` (defensive; the schema-validated bundle
|
||||
never hits this, but the command's hand-built candidates might). «Source changed» detection is a **web
|
||||
responsibility** (command layer), **not** in this core.
|
||||
- **CLI** (`scanners/knowledge-refresh-cli.mjs`, `-cli` → **NOT** an orchestrated scanner → scanner count
|
||||
stays 15, suite byte-stable; 8 tests): read-only — it NEVER writes the register and NEVER hits the
|
||||
network. `--reference-date` (defaults to today; the **only** place the clock is read) makes it
|
||||
deterministically testable against the bundled register. `--stale-after N`, `--dry-run` (implicit + only
|
||||
mode, echoed as `requestedDryRun`). Exit **0** = all fresh, **1** = some stale (advisory), **3** = error.
|
||||
- **Command** (`commands/knowledge-refresh.md`, opus): orchestrates CLI stale-report → re-verify each stale
|
||||
entry by re-reading its `source.url` (WebFetch) → poll CC changelog + Anthropic blog for new/changed
|
||||
practices (WebSearch) → present everything → **apply ONLY human-approved writes**, then re-run the
|
||||
register schema test before declaring done. **No unverified claim is ever auto-written** (Verifiseringsplikt).
|
||||
Web/judgment-driven → **deliberately NOT byte-stable** (own command, outside the snapshot suite), exactly
|
||||
like `/config-audit optimize`. **No new agent** (web poll runs in the command's own context), **no new
|
||||
orchestrated scanner**. suite 1068→1091.
|
||||
|
||||
### campaign-ledger — durable machine-wide campaign core (v5.7 Fase 2, Block 3a THIN)
|
||||
|
||||
`scanners/lib/campaign-ledger.mjs`: the durable ledger that sits ABOVE individual sessions for a
|
||||
machine-wide audit campaign — repo list + per-repo lifecycle (`STATUSES` = pending→audited→planned
|
||||
→implemented) + a machine-wide `rollUp` (counts by status + severity aggregated across repos). It
|
||||
persists to a single JSON file **outside** the plugin dir (`~/.claude/config-audit/campaign-ledger
|
||||
.json`, next to `sessions/`) so it survives uninstall/reinstall/upgrade. Same hybrid split as
|
||||
knowledge-refresh: PURE transforms (`createLedger`/`addRepo`/`setRepoStatus`/`rollUp`) with `now`
|
||||
**injected** (YYYY-MM-DD, never the clock) + soft `validateLedger` (returns `{valid,errors}`, never
|
||||
throws) + a thin IO shell (`defaultLedgerPath`/`loadLedger`→null-on-ENOENT/`saveLedger`). Transforms
|
||||
throw on programmer error (invalid status, unknown path); `schemaVersion` stamped from the start so a
|
||||
Block 4 migration is cheap. **THIN**: ledger + roll-up + persistence only — NO execution, CLI, or
|
||||
command surface (Blocks 3b/3c/4). **Internal plumbing, byte-stable until consumed**: no `export async
|
||||
function scan` + lives in `lib/` → scanner count stays 15, no orchestrator wiring, SC-5 unchanged.
|
||||
28 tests, suite 1091→1119.
|
||||
|
||||
### campaign-cli — read-only ledger reporter (v5.7 Fase 2, Block 3b)
|
||||
|
||||
`scanners/campaign-cli.mjs` (`-cli` → NOT an orchestrated scanner → scanner count stays 15, suite
|
||||
byte-stable; 8 tests): the DETERMINISTIC, READ-ONLY half of the campaign motor, mirroring
|
||||
`knowledge-refresh-cli`. It `loadLedger`s the durable ledger, `validateLedger`s it, and emits
|
||||
`{status, initialized, ledgerPath, schemaVersion, createdDate, updatedDate, repos, rollUp}` as JSON.
|
||||
It NEVER writes — a missing ledger is reported gracefully (`initialized:false`, all-zero roll-up),
|
||||
**never created**; init + every status transition belong to the Block 3c command layer (human-approved
|
||||
writes, Verifiseringsplikt). `--ledger-file` overrides the default path (deterministic testing);
|
||||
`--output-file` mirrors the sibling. Exit codes: **0** = initialized & valid, **1** = not initialized
|
||||
yet (advisory), **3** = error (parse/corrupt/invalid). suite 1119→1127.
|
||||
|
||||
### campaign-write-cli + `/config-audit campaign` — the WRITE half (v5.7 Fase 2, Block 3c, commands 20→21)
|
||||
|
||||
The human-approved mutation half of the campaign motor, completing the THIN campaign surface
|
||||
(ledger + roll-up + status). Two pieces:
|
||||
|
||||
- **`scanners/campaign-write-cli.mjs`** (`-cli` → NOT an orchestrated scanner → scanner count stays
|
||||
15, suite byte-stable; 11 tests): the sibling of `campaign-cli` that *mutates*. Subcommands
|
||||
`init` / `add <path>...` / `set-status <path> <status>`, each a thin wrapper over the
|
||||
invariant-enforcing lib transforms (`createLedger`/`addRepo`/`setRepoStatus`) + `saveLedger` — so
|
||||
path-normalization/dedup, idempotent add, the status-lifecycle guard, and the `updatedDate` bump
|
||||
are **never re-implemented by hand**. `init` refuses to clobber an existing (or corrupt) ledger
|
||||
(**exit 1** advisory, file untouched); `add` **auto-inits** when no ledger exists and reports
|
||||
`added` vs `skipped`; `set-status` accepts `--findings '<json>'` + `--session <id>`. Determinism
|
||||
mirrors the lib + `knowledge-refresh-cli`: `--reference-date` is the **only** place the clock is
|
||||
read (defaults to today), passed to the transforms as the injected `now`. Exit: **0** = write
|
||||
performed, **1** = advisory no-op (init-clobber), **3** = error (unknown subcommand, bad args,
|
||||
invalid status, untracked repo, no/corrupt ledger).
|
||||
- **`commands/campaign.md`** (opus, `allowed-tools: Read/Write/Edit/Bash/Glob` — **no Web**,
|
||||
judgment-free): a thin orchestrator. It always **reports** first (read-only `campaign-cli`), then
|
||||
for `init`/`add`/`set-status` it proposes the change and, **only on explicit human approval**,
|
||||
invokes one write-CLI subcommand (Verifiseringsplikt — it never hand-edits the ledger JSON).
|
||||
`add --discover <root>` finds git repos under a root and lets the user pick. When marking a repo
|
||||
`audited` it attaches findings-by-severity from the repo's session (or user-provided counts) —
|
||||
**never invented**.
|
||||
|
||||
**Not a new scanner, not byte-stable.** Both CLIs carry the `-cli` suffix (out of the
|
||||
scan-orchestrator → scanner count stays **15**, snapshot suite untouched); the command's
|
||||
orchestration is judgment-driven and deliberately outside the snapshot suite, exactly like
|
||||
`/config-audit optimize` + `knowledge-refresh`. **No new agent** (web/judgment-free, runs in the
|
||||
command's own context). suite 1127→1138.
|
||||
|
||||
### campaign backlog — cross-repo prioritized pick-list (v5.7 Fase 2, Block 4b)
|
||||
|
||||
The first half of Block 4 ("one cross-repo prioritized backlog the user picks from"). A pure
|
||||
lib transform + a read-only CLI-payload field — **no schema change, no new scanner, byte-stable**.
|
||||
|
||||
- **`buildBacklog(ledger)`** (`scanners/lib/campaign-ledger.mjs`, pure, mirrors `rollUp`): the
|
||||
single machine-wide prioritized work list. The actionable unit is a **repo** (the ledger tracks
|
||||
per-repo severity *counts*, not individual findings — it tracks state, it does not re-run
|
||||
audits), so each item is one repo: `{path, name, status, sessionId, findingsBySeverity
|
||||
(normalized), totalFindings, weightedScore, rank}`. **Inclusion:** `status !== 'implemented'`
|
||||
AND `totalFindings > 0` (implemented = done; pending / zero-finding repos have nothing known to
|
||||
fix — they still surface in `rollUp.byStatus`). **Order:** DESC by `weightedScore` (exported
|
||||
`SEVERITY_WEIGHTS = {critical:1000, high:100, medium:10, low:1}`), tie-broken lexicographically
|
||||
by critical→high→medium→low count, then ascending `name` — fully deterministic, and the
|
||||
tie-break keeps "criticals always win" even on a weighted-score collision (1 critical vs 10 high).
|
||||
`rank` is 1-based after the sort.
|
||||
- **`campaign-cli`** now emits `backlog: buildBacklog(ledger)` in both branches (uninitialized →
|
||||
`[]`). Purely additive + read-only → fits the Block 3b read-only contract; the existing CLI
|
||||
tests use targeted asserts (not full `deepEqual`), so the new field doesn't break them.
|
||||
- **`commands/campaign.md`** renders the backlog as a "Prioritized backlog" pick-list and points
|
||||
the user at the top item (still a pick-list, NOT an executor — execution is the later 4c block).
|
||||
|
||||
**Byte-stability.** `-cli`/lib/command only → scanner count stays **15**, snapshot/backcompat
|
||||
suite untouched. suite 1138→1150 (lib +9, campaign-cli +3). **Deferred to 4c:** per-repo plan
|
||||
export to each repo's `docs/` + reuse of backup/rollback for execution. **Deferred until the first
|
||||
breaking schema change:** `migrateLedger` (4a) — backlog needs no schema bump, so building
|
||||
migration now would be speculative (`schemaVersion` is already stamped for when it's needed).
|
||||
|
||||
### campaign plan-export + execution-by-reuse (v5.7 Fase 2, Block 4c — the rest of Block 4)
|
||||
|
||||
The second half of Block 4. **Asymmetric:** plan export is the new testable code; execution is
|
||||
*pure reuse* (no new machinery), per the plan's "reuse existing backup/rollback".
|
||||
|
||||
- **Plan export** (`scanners/lib/campaign-export.mjs`, pure, 8 tests): `planExportPath(repoPath,
|
||||
sessionId)` → `<repo>/docs/config-audit-plan-<sessionId>.md` (keyed on the timestamp-unique
|
||||
sessionId, not the date, so same-day re-audits don't collide); `buildPlanExportDocument({...,now})`
|
||||
→ provenance header (repo/session/how-to-execute-and-undo) + the verbatim session plan. `now`
|
||||
injected → deterministic. **CLI** `scanners/campaign-export-cli.mjs` (`-cli`, read-only by
|
||||
default, 10 tests): `--repo <path>` resolves the repo's linked session, reads its
|
||||
`action-plan.md`, assembles the doc, emits `{exportable, problems, targetPath, document, ...}`.
|
||||
Two gates → exit 1 advisory: `no-session-linked` (repo has no `sessionId`), `no-action-plan`
|
||||
(linked session has no plan yet). Writes the file **only** under opt-in `--write` — the CLI does
|
||||
the byte-faithful copy so a 200-line plan is never re-typed/mutated by the LLM. `--sessions-dir`
|
||||
override for hermetic tests; exit 0/1/3 mirror the sibling CLIs.
|
||||
- **Execution = reuse.** No campaign-side execution code. The exported `docs/` file is the repo's
|
||||
durable record; `/config-audit implement` still reads the canonical plan from the session
|
||||
(backup + apply + verify), `/config-audit rollback` undoes, then `set-status <path> implemented`
|
||||
records it. The command (`commands/campaign.md`, new `export <path>` mode) previews → asks → on
|
||||
approval invokes `--write` → routes the user to that existing machinery.
|
||||
- **Byte-stable.** lib + `-cli` + command-doc only → scanner count stays **15**, agents **7**,
|
||||
commands **21** (export is a *mode*, not a new command), snapshot/backcompat suite untouched.
|
||||
suite 1150→1168 (lib +8, export-cli +10). **Block 4a (`migrateLedger`) still deferred** to the
|
||||
first breaking schema change (export needs no schema bump).
|
||||
|
||||
### arg-sluk — the CLI argument class, measured across all fourteen CLIs (v5.14, #57)
|
||||
|
||||
`scanners/lib/cli-args.mjs`. Every CLI in `scanners/` parses argv with a chain of
|
||||
`if (a === '--x') … else if …`, and two things fell through that chain in silence.
|
||||
|
||||
**Arm 1 — the unknown flag.** With no `else` branch, `--zzz` left no trace: exit 0, full
|
||||
payload, a confident answer to a question the caller did not ask. First costed in #51, when
|
||||
`knowledge-refresh`'s only knob reached the CLI malformed and the command reported "all 14
|
||||
register entries were re-verified within the last 90 days" — about a threshold the user had
|
||||
just overridden.
|
||||
|
||||
**Arm 2 — the value that was really a flag.** `a === '--output-file' && args[i + 1]` asks
|
||||
only whether a next token *exists*, never whether it is a value. So `--output-file --json`
|
||||
took `--json` as the filename. Measured: `manifest`, `campaign-cli` and
|
||||
`knowledge-refresh-cli` each **wrote a file literally named `--json`** into the caller's
|
||||
working directory, exit 0, with `--json` mode silently dropped. A wrong answer is bad; an
|
||||
unintended file on disk is worse.
|
||||
|
||||
**Width — the deferral list was a prediction, not a measurement.** `KNOWN_OPEN` in
|
||||
`tests/scanners/cli-unknown-flag-rejection.test.mjs` named **two** CLIs. Measuring all
|
||||
fourteen found **7** open on arm 1 and **10** on arm 2 — including `campaign-cli` and
|
||||
`knowledge-refresh-cli`, which were already in `GUARDED` and *passing* the arm-1 test while
|
||||
arm 2 stood open a few lines away. Three CLIs (`drift-cli`, `fix-cli`,
|
||||
`plugin-health-scanner`) were already correct on both arms because they use a different
|
||||
parse form; they were moved into `GUARDED` rather than left unguarded.
|
||||
|
||||
**Why a gate and not a rewrite.** The module runs *before* each CLI's existing loop and does
|
||||
not replace it. Valid argv therefore reaches the existing parser byte-for-byte unchanged, so
|
||||
no frozen snapshot can move — the byte-stability argument is structural, not empirical. The
|
||||
three CLIs that carried a bespoke `else if (a.startsWith('--')) fail(…)` branch had it removed
|
||||
once the gate made it unreachable, along with the now-redundant `&& args[i + 1]` guards.
|
||||
|
||||
Exit code is **3** by the exit-code contract: a malformed argument means the scanner never got
|
||||
to do its job, which is categorically different from 0/1/2 — verdicts about a configuration
|
||||
that *was* examined.
|
||||
|
||||
---
|
||||
|
||||
### rollback-cli — the recovery path gets a runnable entry (R1+R2, #74)
|
||||
|
||||
`scanners/rollback-cli.mjs` is the entry to `rollback-engine.mjs` and, via
|
||||
`--create`, to `lib/backup.mjs`. It closes the two KRITISK rows of the Q3
|
||||
severity table as one chunk, because neither half holds alone.
|
||||
|
||||
**R1 — the restore was model prose over a code engine.** Measured at the head of
|
||||
the chunk: 16 files under `scanners/` carried a `process.argv` entry;
|
||||
`rollback-engine.mjs` was not one of them, though it had verified every file's
|
||||
checksum *before and after* each write since M-BUG-22 and reported
|
||||
`createdNotRemoved` since M-BUG-25. `commands/rollback.md` §Implementation
|
||||
showed an ESM `import` block a command template cannot execute and offered
|
||||
ad-hoc `cp` underneath as the runnable alternative, then pre-rendered
|
||||
"`(checksum verified)`" three times in the success output. `cp` establishes no
|
||||
checksum, so the verification was a property of the template, not of the run —
|
||||
on the one surface that runs when the user is already in trouble.
|
||||
|
||||
**R2 — the backup format had two authors, one of them prose.** The fix pipeline
|
||||
backed up through `createBackup`; the implement pipeline hand-built its own —
|
||||
`mkdir`, `cp`, a `date +%Y%m%d_%H%M%S` id, and a manifest typed out in the
|
||||
template. `parseManifest` grew a second branch for that format because the seam
|
||||
had already failed silently once, and the fixture pinning it was **hand-written**
|
||||
rather than derived from the template's own text: the #63 shape on the data
|
||||
side. Rename a key in the template and `parseManifest` returns zero files while
|
||||
`rollback` reports success.
|
||||
|
||||
**Why one chunk.** Fix only R1 and the new CLI still parses a prose format. Fix
|
||||
only R2 and the format is clean with no runnable entry behind it.
|
||||
|
||||
**Exit contract.** 0 done · 1 outstanding (a restore the scope gate will not
|
||||
perform without `--approve-scope`, nothing written; or a backup that covered
|
||||
fewer targets than it was given) · 2 at least one file failed · 3 the CLI could
|
||||
not do its job. A gated restore is **1, not 3**: "this write leaves your project"
|
||||
is a verdict about a write that *was* examined, and it rides in the payload,
|
||||
where a command running under `2>/dev/null` can act on it. That is F3's class,
|
||||
avoided rather than repeated.
|
||||
|
||||
**`--created` records, it does not copy.** No backup can hold a file that does
|
||||
not exist yet. Those paths go into the manifest so `rollback` can list what it is
|
||||
leaving in place. `serializeManifest` emits the bare `created:` key; the pre-R2
|
||||
implement format used `created: <timestamp>` with a VALUE, meaning the backup id,
|
||||
and `parseManifest` tells them apart on exactly that — which is why the two never
|
||||
collide in one file.
|
||||
|
||||
**The implement-format branch stays.** Nothing writes that shape any more, but
|
||||
every backup implement made before this chunk is on disk in it and must remain
|
||||
restorable — the same reasoning that keeps `getLegacyBackupDir()` readable. The
|
||||
hand-written fixture in `tests/scanners/rollback-paths.test.mjs` therefore
|
||||
changed meaning rather than becoming obsolete: it is now a golden sample of
|
||||
historical bytes, which is a legitimate thing to write by hand, instead of a
|
||||
stand-in for a template's own text, which is not.
|
||||
|
||||
**What replaced "(checksum verified)".** `tests/commands/backup-restore-contract.test.mjs`
|
||||
runs the CLI and checks every field `rollback.md` renders against the real
|
||||
payload. A renamed payload key now fails a test instead of turning into a
|
||||
confident sentence. Seen red against its own defect by renaming `{status}`.
|
||||
|
||||
**A guard hole found by mutation.** The first version of the implement-side
|
||||
assertion matched `--create` as a substring, and the same invocation carries
|
||||
`--created` — so replacing the `--create` call with `--list` left the guard
|
||||
green. It matches `--create(?![a-z])` now. Separately, mutating
|
||||
`if (!requireValidArgs(...)) return;` into a bare call showed that
|
||||
`requireValidArgs` sets exit 3 *by itself*: a CLI can report "I could not parse
|
||||
my arguments" and still run the restore underneath. `rollback-cli.test.mjs`
|
||||
asserts on the bytes for that case, not on the exit code.
|
||||
|
|
|
|||
228
docs/v5.13-model-routing-effort-deadref-plan.md
Normal file
228
docs/v5.13-model-routing-effort-deadref-plan.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
# v5.14 Plan — Doctor Overlap, Model Routing, Effort Awareness, Dead References
|
||||
|
||||
> **Filename note:** kept as `v5.13-…` so existing references resolve; the target has been
|
||||
> v5.14 since the `5.13.0` slot was consumed by the pipeline-hardening batch. Rewritten
|
||||
> 2026-08-03 (session #53) to merge the `/doctor`-overlap decision (Oppgave A) and the
|
||||
> context-engineering follow-ups (B1–B4) from `docs/v5.14-doctor-overlap-brief.md` into the
|
||||
> pre-existing chunks. One plan, top-to-bottom, no relitigation.
|
||||
|
||||
## Inputs and their status
|
||||
|
||||
| Input | Status |
|
||||
|---|---|
|
||||
| `/doctor`-overlap measurement (session #53, fasit-first) | **DONE** — decisions binding, recorded in `docs/doctor-overlap-results.local.md` |
|
||||
| B1 register freshness defect (evidence-age / supersededBy / sources[]) | **DONE** — landed as `d66035e` (TDD, 1477/1477 green) |
|
||||
| Video-derived model/effort chunks (verified 2026-07-14) | Open — carried below unchanged |
|
||||
| Open posts from dogfood sessions #45–#51 | Open — detail lives in `STATE.md`; referenced here by ID only |
|
||||
|
||||
## A. The `/doctor` verdict (measured 2026-08-03, CC 2.1.220 — binding)
|
||||
|
||||
`/doctor` (alias `/checkup`, v2.1.205+) is an **agent-driven, usage-data-backed, quota-priced,
|
||||
non-reproducible** checkup: 10 checks incl. unused skills/plugins/MCP vs. context cost,
|
||||
CLAUDE.md dedup/contradiction judgment, derivable-content trim (checked-in files only),
|
||||
lazy-loading migration, hook latency, and a chars÷4 context manifest. It overlaps our
|
||||
**judgment lenses and alarms — not the deterministic validators.** Full per-scanner table in
|
||||
`docs/doctor-overlap-results.local.md`.
|
||||
|
||||
Binding outcomes (0 whole scanners removed; 2 measured function-duplicates removed;
|
||||
5 surfaces repositioned):
|
||||
|
||||
| ID | Chunk | What |
|
||||
|---|---|---|
|
||||
| **D1** | GAP autoMode removal | Delete the «No autoMode classifier» gap dimension (`feature-gap-scanner.mjs:485`) — `/doctor` Sjekk 8 checks AND fixes it. Byte-stability: existing-scanner finding-removal variant of [[adding-scanner-byte-stability]]; humanizer entries for the removed title go too. |
|
||||
| **D2** | SKL alarm re-scope | Remove CA-SKL-002's aggregate-over-budget **alarm** role (native startup warning v2.1.105/2.1.181 + `/doctor` Sjekk 6 both do it better-placed); re-position CA-SKL-001 as per-description **attribution** (author lens, repo scope); CA-SKL-003 untouched. Exact form (delete vs. re-scope 002) decided inside the chunk against the frozen-baseline cost. |
|
||||
| **D3** | Positioning rewrite | **DONE in #53** — README section «config-audit vs. the built-in /doctor» (division-of-labor table + per-area split for SET/HKV/CNF/OPT/tokens/manifest, citing `/doctor`'s own referral to `optimize --subtract`) + CLAUDE.md invariant line. Remaining refinement (per-command copy in `commands/*.md`, if wanted) rides along with D1/D2. |
|
||||
|
||||
**Strategic line (constrains all copy):** our defensible identity = determinism/byte-stability,
|
||||
all scopes incl. LOCAL, cross-repo campaign breadth, zero quota. `/doctor`'s unmatchable edge =
|
||||
usage telemetry. Long-term (v5.15+ candidate, NOT this release): transcript/usage telemetry as a
|
||||
scanner *input*, so the axes compose instead of competing.
|
||||
|
||||
## B. Context-engineering follow-ups (article 2026-07-24, brief §2)
|
||||
|
||||
- **B1 — DONE** (`d66035e`): `sources[]` + `published` + `supersededBy` + evidence-age rule
|
||||
(green-but-outdated is now expressible and flagged; re-verifying the old source no longer
|
||||
clears it). BP-SUB-001 carries the article as verified corroborating source.
|
||||
**Deviation from brief, verified 2026-08-03:** the article contains NO mechanism-choice or
|
||||
size-limit content → it was NOT added to BP-MECH-*/BP-SIZE-001 (that would be false
|
||||
provenance). If a future read finds real coverage, add it then.
|
||||
- **B2 — DONE, and the detector was DECLINED by measurement.** `BP-JUDG-001` ships as
|
||||
register knowledge with `lensCheck: null`; **no `CA-OPT-002`** (OPT next-free stays 2).
|
||||
Measured over **409 real CLAUDE.md files** (38 488 lines, 8 689 prose blocks): the caging
|
||||
class fires **7 times, all 7 false positives**, confirmed along an independent grep path in
|
||||
both word orders (5 lines / 1 line, none an instruction). Where the shape *does* occur — 45
|
||||
lines across 4 755 skill/agent/command files — it is the author's **editorial policy** (emoji,
|
||||
sentence length, slide titles), and nothing in the text separates that from a vendor's
|
||||
over-tight guardrail: the article's reasoning does not transfer, because the model is not the
|
||||
author of a user's config. Precision-first ⇒ silence. Two premises the chunk falsified: the
|
||||
brief's «these blocks are inside the floor» (the article's own example carries **no** floor
|
||||
marker; the corpus tendency is 76 %, which is not a mechanism), and the §4 form-noun
|
||||
vocabulary (`name`/`format` alone were 97 % of fires). Full record:
|
||||
`docs/b2-judgment-lens-fasit.local.md` §9.
|
||||
- **B3 — two-layer duplication/contradiction (CNF extension):** article rule 4 + `/doctor`'s
|
||||
measured Sjekk 2 catch (global CLAUDE.md model-policy ↔ agent frontmatter) prove the class
|
||||
exists and is catchable. Extend `conflict-detector` with cross-layer checks where the pair is
|
||||
structurable (CLAUDE.md/rule, rule/skill-description, CLAUDE.md-keyword ↔ frontmatter field).
|
||||
This **supersedes the old "Explicitly rejected #5"** below — it now has evidence.
|
||||
- **B4 — thinner gaps (backlog, after everything above):** rule 2 (skills/agents leaning on
|
||||
examples where a parameter enum would do) and rule 6 (reference *form*: prefer in-code files;
|
||||
`import-resolver` is the natural owner). Park until D/B2/B3 land.
|
||||
- **Rules 3 and 5 need nothing** (verified in brief §B4): progressive disclosure = BP-LOAD-001..006
|
||||
+ BP-MECH-003 + `token-hotspots`/`manifest`; router pattern + auto-memory covered.
|
||||
|
||||
## C. Carried chunks (verified 2026-07-14 — unchanged specs)
|
||||
|
||||
Source-verification table, rejected-claims list, and full chunk specs below are carried
|
||||
verbatim from the 2026-07-14 revision; only numbering context changed.
|
||||
|
||||
### C1 — Register entries: model routing + effort (dogfoods `knowledge-refresh`)
|
||||
|
||||
- **BP-MODEL-001** (`model-fit`): mechanical/read-only subagents can pin a cheaper model via
|
||||
`model:` frontmatter; orchestrator keeps the strong model. Source: code.claude.com/docs/en/sub-agents
|
||||
→ `confirmed`.
|
||||
- **BP-MODEL-002** (`model-fit`): reasoning effort tunable at five levels in five places;
|
||||
default `high`; higher is not universally better. Source: code.claude.com/docs/en/model-config
|
||||
→ `confirmed`.
|
||||
- Schema per `scanners/lib/best-practices-register.mjs:42-102`. New sources SHOULD carry
|
||||
`published` so the B1 evidence-age rule has teeth.
|
||||
|
||||
### C2 — fix-engine effort hygiene (tiny, TDD)
|
||||
|
||||
`fix-engine.mjs:26` `VALID_EFFORT_LEVELS` missing `xhigh` → nearest-match "fix" for `xhig`
|
||||
corrects to `high`. Red test first: `findNearestEffortLevel('xhig') === 'xhigh'`. Align with
|
||||
`settings-validator.mjs:75`.
|
||||
|
||||
### C3 — CA-CML dead prose references (new deterministic check)
|
||||
|
||||
Flag backtick-quoted relative file paths in CLAUDE.md prose that do not exist on disk (today
|
||||
only `@import` targets are checked). Conservative v1: skip URLs, globs, placeholders,
|
||||
absolute/`~/` paths. Severity low. New CA-CML-NNN (verify next free NNN at implementation).
|
||||
Byte-stability per [[adding-scanner-byte-stability]] incl. humanizer step 7.
|
||||
|
||||
### C4 — feature-gap + inventory: model/effort awareness
|
||||
|
||||
New T3 opportunity check: authored agents where NO agent sets `model:`/`effort:` → routing
|
||||
opportunity citing BP-MODEL-001/002. Fires only when authored agents exist; opportunity
|
||||
framing, suppressable. `whats-active`/`manifest` surface `model`/`effort` per agent.
|
||||
Humanizer step 7; verify via direct `scan()` ([[agent-commands-need-scanner-scoping]]).
|
||||
Known tension with the operator's own Opus-for-everything policy stands as written 2026-07-14:
|
||||
the check serves general users; on this machine it gets suppressed.
|
||||
|
||||
### C5 — planner-agent adversarial gate (AFTER DEL B 3.2 dogfood)
|
||||
|
||||
Required "Failure modes" section in `agents/planner-agent.md`'s action-plan contract.
|
||||
Sequencing: only after the DEL B fasit pass that judges planner-agent, or the fasit target
|
||||
moves mid-evaluation.
|
||||
|
||||
## D. Open posts from dogfooding (detail in STATE.md — not restated here)
|
||||
|
||||
C-SKL1 (#37) · M-BUG-26 · **M-BUG-28** (suppression-ID positional instability — ID-semantics
|
||||
change touching all scanners + frozen snapshots, own chunk) · **M-BUG-41** (no scope-gate from
|
||||
scan to write, two arms — design change, own chunk) · **arg-sluk CLI arm**
|
||||
(`optimize-lens-cli` + `token-hotspots-cli`, `KNOWN_OPEN` in
|
||||
`tests/scanners/cli-unknown-flag-rejection.test.mjs`) · **P6/M-BUG-44** (scanner-side stdout
|
||||
with `--output-file`) · knowledge-refresh write-CLI · cleanup-invisible session files ·
|
||||
web-poll candidates (nothing written; primary sources unread).
|
||||
|
||||
## Priority order for v5.14
|
||||
|
||||
Cheap-and-loud first, judgment-heavy later; D-chunks early because they DELETE code the rest
|
||||
must not build on. **Re-ordered 2026-08-10 (operator decision, session #61):** the operator wants
|
||||
to *use* the subtraction axis, so its write half — and the scope-gate it depends on — move ahead
|
||||
of the remaining additive work. Everything below step 4 is unchanged in content, only in position.
|
||||
|
||||
1. ~~**C2**~~ ✅ · 2. ~~**Arg-sluk CLI arm**~~ ✅ · 3. ~~**D1 + D2**~~ ✅ · 4. **D3** (rest dropped,
|
||||
`stop-at-meaningful-value`) · 5. ~~**M-BUG-28**~~ ✅ · 6. ~~**C1**~~ ✅ · 7a. ~~**C4**~~ ✅
|
||||
8. **M-BUG-41** (scope-gate design) — **promoted from 10.** Prerequisite for anything that writes
|
||||
outside the repo the session stands in, which subtraction-write does by definition
|
||||
(`~/.claude/CLAUDE.md`).
|
||||
9. **SUB-WRITE** (new) — the write half of `optimize --subtract`; see §C6 below.
|
||||
10. **C3** (CA-CML dead prose references) — was 7b.
|
||||
11. **B2** (new lens axis)
|
||||
12. **B3** (CNF two-layer extension)
|
||||
13. **P6/M-BUG-44**, knowledge-refresh write-CLI, cleanup glob (small batch)
|
||||
14. **C5** (after DEL B 3.2), **B4** backlog last
|
||||
15. Release-cut via `release-plugin.mjs` when the batch is coherent. **Level is MAJOR — v6.0.0:**
|
||||
M-BUG-28 shipped as `fix(scanners)!` with a `BREAKING CHANGE:` footer, which outranks the
|
||||
minor the D1/D2 removals plus C3/C4 additions would have implied. `release-plugin.mjs` does
|
||||
not derive the level — pass `--version` explicitly.
|
||||
|
||||
### C6 — SUB-WRITE: the write half of `optimize --subtract`
|
||||
|
||||
`--subtract` proposes and never writes (`commands/optimize.md`), which is correct for a v1 whose
|
||||
judge is an agent. The operator now wants the removal executed. Scope: apply an approved
|
||||
subtraction candidate to the CLAUDE.md it came from, with backup and rollback.
|
||||
|
||||
Non-negotiable frames, all inherited rather than invented here:
|
||||
|
||||
- **The floor is not the judge's decision.** `floor-exclusion.mjs` runs deterministically before
|
||||
anything is proposed, and that ordering must not migrate into the write path either.
|
||||
- **`~/.claude` is git-tracked with a `.gitignore` of `*`** — archive by `mv` into `_archive/`,
|
||||
never `rm`. Machine-side config writes need operator approval.
|
||||
- **User level is mandatory in v1**: that is where the cost is (~4 300 tokens every turn in every
|
||||
repo). Project level follows.
|
||||
- **Honest sizing:** the #40 fasit measured deletable ≈1 400 always-loaded tokens, realistically
|
||||
≈850 after tier-2 earn-backs, against a ≈4 300-token file — **≈20 %, not 80 %.** Do not let the
|
||||
command's copy imply more.
|
||||
- Verify the backup covers the file the write actually touched, not merely that a backup exists
|
||||
(M-BUG-31's shape).
|
||||
|
||||
Open decision, to be settled in the chunk's fasit before code: whether removal is a `fix`-engine
|
||||
action, a `plan`/`implement` step, or its own flag — decided against M-BUG-41's gate, not before it.
|
||||
|
||||
**Rejected 2026-08-10, do not revive:** a sibling `/repo-reinit` skill that rewrites a CLAUDE.md
|
||||
from scratch. It would be a third implementation of one judgement (this axis, plus `/doctor`
|
||||
Check 3) and fails the binding `/doctor` positioning; and regenerating destroys exactly the floor
|
||||
— local facts, gotchas, policy invariants — that a mature repo's CLAUDE.md is most valuable for.
|
||||
`repo-init` already owns the fresh-repo case.
|
||||
|
||||
## Source verification (done 2026-07-14 — carried)
|
||||
|
||||
| Claim from video | Verdict | Source |
|
||||
|---|---|---|
|
||||
| Orchestrator + cheaper worker models is supported/recommended | VERIFIED | code.claude.com/docs/en/sub-agents, /workflows |
|
||||
| Effort tunable per settings/session/launch/agent-frontmatter/SDK; `low..max` | VERIFIED | code.claude.com/docs/en/model-config#adjust-effort-level |
|
||||
| Leaked Fable 5 system-prompt principles | VERIFIED near-verbatim, provenance unconfirmed | github.com/asgeirtj/system_prompts_leaks |
|
||||
| "Fable low ≈ Opus high" chart | **CONTRADICTED** | anthropic.com/news/claude-fable-5-mythos-5 |
|
||||
|
||||
## Explicitly rejected (unchanged unless noted)
|
||||
|
||||
1. "Fable low ≈ Opus high" framing — never encode.
|
||||
2. Tool-call-count effort scaling as register entry — unconfirmed leak, not carried.
|
||||
3. Cost/intelligence/"taste" routing-table generator — subjective, doesn't fit provenance-gated design.
|
||||
4. "Fable mode" skill — out of plugin scope.
|
||||
5. ~~CLAUDE.md prose contradiction detection~~ — **superseded by B3** (2026-08-03: article
|
||||
rule 4 + `/doctor` Sjekk 2 measurement supplied the evidence the 2026-07-14 rejection lacked).
|
||||
|
||||
## Verification (per chunk, unchanged discipline)
|
||||
|
||||
- Full suite green (`node --test 'tests/**/*.test.mjs'`; baseline 2026-08-03: 1477/0), frozen
|
||||
`tests/snapshots/v5.0.0/` untouched (`git status --porcelain` empty), red test before every
|
||||
production change.
|
||||
- **D1:** GAP fixture with autoMode absent → no finding; humanizer has no orphaned entries
|
||||
(M-16/M-17 checks reversed for removal); frozen baselines untouched or consciously re-seeded
|
||||
per [[adding-scanner-byte-stability]].
|
||||
- **D2:** over-budget fixture → no 002-alarm (or re-scoped payload per in-chunk decision);
|
||||
001 fires per oversized description with attribution copy; 003 unchanged.
|
||||
- **D3:** README/CLAUDE.md name `/doctor` explicitly; `self-audit --check-readme` PASS.
|
||||
- **C1–C5:** criteria as specified 2026-07-14 (C2 red-first nearest-match; C3 fixture
|
||||
missing-path fires / URL-glob-placeholder silent; C4 authored-agent matrix; C5 failure-modes
|
||||
section present).
|
||||
- **B2 (superseded by the measurement above):** the criterion was written for a detector that
|
||||
measurement declined. What was verified instead: `BP-JUDG-001` present, `confirmed`, primary
|
||||
source dated `2026-07-24`, `lensCheck` **absent** — plus a guard that every `lensCheck` in the
|
||||
register is backed by a real detector, so no later session can "complete" the entry by wiring
|
||||
one. Both assertions seen RED against their own defect before landing.
|
||||
- **B3:** fixture with same instruction in CLAUDE.md + rule → CNF finding; single-layer only →
|
||||
silent ([[guard-can-be-green-on-its-own-defect]]: assert the blanket invariant).
|
||||
|
||||
## Key assumptions (test at implementation)
|
||||
|
||||
1. Per-agent `effort` frontmatter still official — re-fetch sub-agents + model-config pages.
|
||||
2. Finding-type REMOVAL in an existing scanner leaves frozen v5.0.0 snapshots untouched only if
|
||||
no frozen fixture carries the type — verify per D1/D2 before committing; re-seed consciously
|
||||
if not.
|
||||
3. Next free CA-CML/CA-GAP/CA-OPT NNN — grep tests + snapshots before assigning.
|
||||
4. `/doctor`'s check set is version-fluid (2.1.205→220 changed it materially) — re-run the
|
||||
overlap measurement cheaply (CLI + one in-session run) before executing D1–D3 if CC has
|
||||
moved significantly past 2.1.220.
|
||||
136
docs/v5.14-doctor-overlap-brief.md
Normal file
136
docs/v5.14-doctor-overlap-brief.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Brief — `/doctor`-overlapp og ny kontekst-doktrine (v5.14-inngang)
|
||||
|
||||
**Skrevet:** 2026-08-03, økt #52 (Opus 5/high)
|
||||
**Skrevet for:** neste økt — **Fable 5 / high** (operatørens modellvalg; rubrikken ga `Opus 5/high`, `rule=path=partial`)
|
||||
**Status ved overlevering:** ingenting implementert. Denne økten leverte kun analyse + denne briefen.
|
||||
|
||||
---
|
||||
|
||||
## 0. Hva denne økten gjorde (så du slipper å gjenta det)
|
||||
|
||||
Fant og leste originalartikkelen bak videoen operatøren limte inn:
|
||||
|
||||
> **«The new rules of context engineering for Claude 5 generation models»**
|
||||
> Thariq Shihipar, Member of Technical Staff, Anthropic — **24. juli 2026**
|
||||
> https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models
|
||||
|
||||
Verifisert direkte mot artikkelen (`WebFetch`, to pass). Tre ting videoen tok feil på — ikke gjenta dem:
|
||||
|
||||
1. Forfatteren heter **Thariq Shihipar**, ikke «Tariq».
|
||||
2. **Artikkelen sier ingenting om tokens, kostnad, caching eller måling.** Videoens token-budsjett-argument er skaperens påbygg. Det er riktig, men det er ikke Anthropics påstand — og det betyr at vi **ikke kan sitere artikkelen som hjemmel** for token-argumenter i registeret (Verifiseringsplikt).
|
||||
3. **Videoens `/doctor`-liste (fem ting) står ikke i artikkelen.** Artikkelen har én setning: *«We rolled out a new command called `claude doctor,` which will help you do this automatically.»* Alt annet i videoen er skaperens observasjon fra sin egen kjøring. **Behandle den som uverifisert.**
|
||||
|
||||
Målt ground truth (CC **2.1.220**, denne maskinen):
|
||||
|
||||
```
|
||||
$ claude doctor --help
|
||||
Check the health of your Claude Code installation. Reads settings files in the
|
||||
current directory without a trust prompt. For a full checkup that can also fix
|
||||
issues, run /doctor in a session.
|
||||
```
|
||||
|
||||
⇒ **CLI-en `claude doctor` = kun install-helse. Det er `/doctor` i sesjon som overlapper oss** («a full checkup that can also fix issues»). Ikke bland dem.
|
||||
|
||||
---
|
||||
|
||||
## 1. OPPGAVE A — mål `/doctor`, og fjern det vi dupliserer
|
||||
|
||||
**Dette er økten sin hovedoppgave, og den har en beslutning i seg som skal tas, ikke utsettes.**
|
||||
|
||||
### Premisset som skal falsifiseres
|
||||
|
||||
Anthropic leverer nå en innebygd, gratis kommando som gjør noe av det config-audit finnes for. Vi vet ikke hvor mye. Vi har aldri målt det. **Hver funksjon i config-audit som `/doctor` gjør like godt eller bedre, skal ut** — ikke omdøpes, ikke «beholdes for kompletthet», ikke pakkes inn i en flagg. Ut.
|
||||
|
||||
Begrunnelsen er pluginens egen doktrine, snudd mot oss selv: `BP-SUB-001` sier at en blokk som ikke lenger tjener sin plass skal vurderes fjernet. En scanner som duplikerer en innebygd kommando tjener ikke sin plass — den koster vedlikehold, tester, byte-stabile baselines og operatørens oppmerksomhet, for et svar hen kan få gratis.
|
||||
|
||||
### Metode (samme mal som de ti dogfood-chunkene — fasit FØR kjøring)
|
||||
|
||||
1. **Skriv `docs/doctor-overlap-fasit.local.md` FØR du kjører noe.** Nummererte prediksjoner: for hver av våre 16 scannere, forutsi om `/doctor` dekker den (JA / DELVIS / NEI) og hvorfor. Eksplisitte avkreftelser: hvilke scannere du er *sikker* på at `/doctor` ikke rører. Forpliktende breddetall: «jeg forventer at N av 16 overlapper». **Aldri rediger fasiten for å matche utfallet** — [[judge-the-judge-build-fasit-first]].
|
||||
2. **Kjør `/doctor` i en sesjon** og fang hele outputen. Merk: `/doctor` kan *fikse* ting — kjør den der en utilsiktet fiks er billig, og les hva den foreslår før du godtar noe.
|
||||
3. **Kjør `claude doctor` (CLI) også**, separat, så de to ikke smelter sammen i notatene.
|
||||
4. **Sammenlign mot vår faktiske inventar** — ikke mot README-ens beskrivelse av den. Kildene er `scanners/*.mjs` (16) og CA-ID-rommet.
|
||||
5. **Premiss-verifiser fasiten mot utfallet**, og rapporter avviket før du handler.
|
||||
|
||||
### Beslutningen som skal tas i denne økten
|
||||
|
||||
For hver scanner/kommando, ett av tre utfall — skrevet ned med begrunnelse:
|
||||
|
||||
| Utfall | Betyr | Handling |
|
||||
|---|---|---|
|
||||
| **FJERNES** | `/doctor` gjør dette like godt eller bedre | Egen v5.14-chunk: slett scanner + tester + CA-ID + README/CLAUDE.md-rader. Frosne baselines må re-seedes bevisst. |
|
||||
| **BEHOLDES, SKJERPES** | Vi gjør noe `/doctor` ikke gjør, men README selger det ikke slik | Omskriv posisjoneringen så forskjellen er eksplisitt |
|
||||
| **BEHOLDES** | Ingen overlapp | Ingen handling |
|
||||
|
||||
### Hypotesen min (skriv din egen fasit først — les denne etterpå)
|
||||
|
||||
Der jeg tror vi faktisk skiller oss, som *bør* overleve målingen:
|
||||
determinisme + byte-stabile baselines, `drift` mot lagret baseline, suppressions med revisjonsspor, backup/rollback, `campaign` på tvers av repo, `plugin-health` + polyrepo-katalog, og det provenansstemplede registeret med `confidence`-nivåer. Der jeg tror overlappet er reelt: deler av `posture`, og deler av `feature-gap`s t1-nivå.
|
||||
|
||||
**Men dette er en hypotese fra en økt som ikke har kjørt `/doctor`. Den er ikke fasit, og den skal ikke få lov til å ankre din.**
|
||||
|
||||
### Utfall som ikke er lov
|
||||
|
||||
- «Vi beholder alt, men dokumenterer forskjellen bedre.» Det er svaret man gir når man ikke vil måle.
|
||||
- Å utsette fjerningen til «senere». Beslutningen tas her; *utførelsen* er en egen chunk (byte-stabilitet + frosne baselines gjør sletting til flerfilsarbeid).
|
||||
|
||||
---
|
||||
|
||||
## 2. OPPGAVE B — oppdater de videre planene
|
||||
|
||||
Når oppgave A har landet en beslutning, skal planverket reflektere den. **Ikke før** — rekkefølgen er poenget, ellers planlegger vi rundt et overlapp vi ikke har målt.
|
||||
|
||||
### B1. Registeret (`knowledge/best-practices.json`) — høyest verdi, lavest risiko
|
||||
|
||||
To ting, og de er forskjellige:
|
||||
|
||||
**(a) Provenans.** Alle `BP-MECH-*`, `BP-SIZE-001` og `BP-SUB-001` siterer «Steering Claude Code»-bloggen. Den nye artikkelen bekrefter og forsterker dem — legg den til som kilde. Særlig `BP-SUB-001`, som nå har nesten ordrett dekning: *«briefly describe what your repo is for, but spend most of the tokens on gotchas inside of the codebase»* og *«Avoid stating 'the obvious' things Claude should know by looking at your file system or your repo»*.
|
||||
|
||||
**(b) En defekt i vår egen ferskhetsgaranti — dette er det viktigste funnet.**
|
||||
`BP-SUB-001` er stemplet `verified: 2026-07-31`. Artikkelen kom **24. juli**. Vi re-verifiserte altså den *gamle* kilden en uke etter at den nye lå ute, og fanget den ikke.
|
||||
|
||||
Årsaken er strukturell: `knowledge-refresh-cli.mjs` gjør kun `assessFreshness` — den **aldrer eksisterende oppføringer etter dato**. Den har ingen måte å uttrykke «en ny kilde har supersedert en gammel». **En oppføring kan derfor være grønn og substansielt foreldet samtidig.** Det er en garanti vi gir som ikke holder.
|
||||
|
||||
Fiksen er en datamodell-endring (`sources[]` og/eller `supersededBy`) + en freshness-regel som ser på kildens alder, ikke bare oppføringens. Dette er den eneste posten her som er nær-ren TDD og har sterk verifikasjon.
|
||||
|
||||
### B2. Ny lens-akse for artikkelens **regel 1** (skjønn over regler)
|
||||
|
||||
`BP-SUB-001` fanger blokker som *gjentar generell ingeniør-atferd*. Artikkelens regel 1 er en **annen defektklasse**: instruksjoner som er lokale og spesifikke, men som **overspesifiserer og burer skjønnet**. Anthropics eget eksempel var ikke redundant — «never write multi-paragraph docstrings, one short line max» er presis og lokal. Den ble slettet fordi den *begrenset* en modell som nå dømmer bedre selv.
|
||||
|
||||
**Viktig, og lett å gjøre feil:** dette kan ikke bli en utvidelse av `--subtract`. `scanners/lib/floor-exclusion.mjs` beskytter «policy invariants» og «local facts» fra å bli slettekandidater — nettopp kategorien artikkelen sier ofte er for stram. Gulvet gjør riktig jobb for `--subtract`; regel 1 trenger sin **egen akse**: *behold innholdet, løsne formuleringen*, ikke *fjern blokka*. Å blande dem er ÅS#5-defektklassen om igjen (to akser presset inn i ett vokabular).
|
||||
|
||||
Foreslått: `BP-JUDG-001` + `CA-OPT-002`, med samme presisjonsgate som `optimization-lens-agent` allerede har (siter regel + kilde, ti stille når usikker).
|
||||
|
||||
### B3. Regel 4 — vi måler feil akse
|
||||
|
||||
Vi har duplikatdeteksjon (`claude-md-linter` 3+ repetisjon, `conflict-detector` hook-duplikater, `CA-TOK-002` permissions). **Alt er innenfor ett lag.** Artikkelens regel 4 handler om samme instruksjon i **to lag** — hos oss: CLAUDE.md *og* en rule, rule *og* en skill-beskrivelse, CLAUDE.md *og* en agent-prompt. Sannsynligvis en utvidelse av `conflict-detector` (den kjenner allerede flere lag), ikke en ny scanner.
|
||||
|
||||
### B4. Regel 2 og 6 — ekte hull, men tynnere
|
||||
|
||||
- **Regel 2** (eksempler → grensesnitt): ingen scanner ser på om en skill/agent lener seg på eksempler der en parameter-enum ville gjort jobben. `skill-listing-scanner` teller tegn, ikke form.
|
||||
- **Regel 6** (rike referanser): *«prefer files that are in code as it provides clear, high-fidelity instructions»*, *«a HTML mockup of a design will generally produce better results than a description or screenshot»*. Vi har ingen oppfatning om referanse-*form*. Som eier av `@`-referanser (`import-resolver.mjs`) er vi det naturlige stedet.
|
||||
|
||||
**Regel 3 og 5 krever ingenting.** Progressiv avdekking *er* `BP-LOAD-001..006` + `BP-MECH-003` + `token-hotspots` + `manifest`; router-mønsteret dekkes av `t2_2`/`t2_3`; auto-memory av `t2_4`. Bekreftet, ikke endret.
|
||||
|
||||
### B5. Skriv om `docs/v5.13-model-routing-effort-deadref-plan.md` (v5.14-planen)
|
||||
|
||||
Den bærer i dag: C-SKL1 (#37), M-BUG-26, M-BUG-28, M-BUG-41 (to armer), arg-sluk-klassens CLI-arm (`optimize-lens-cli` + `token-hotspots-cli`), P6/M-BUG-44 scanner-siden. **Alt dette står fortsatt.** Oppgave A og B1–B4 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 + B1–B4 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.
|
||||
245
docs/v5.5-steering-model-plan.md
Normal file
245
docs/v5.5-steering-model-plan.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# v5.5+ Plan — Steering-Model Coverage
|
||||
|
||||
_Status: PLAN (awaiting GO per feature). Created 2026-06-20. Plans live next to the work (continuity rule)._
|
||||
|
||||
## Why this plan exists
|
||||
|
||||
A "seven ways to steer Claude Code" framing (CLAUDE.md / rules / skills / sub-agents /
|
||||
hooks / output styles / mechanism-fit) was used as a **check-against** reference — not a
|
||||
spec. Checking it against the live docs (`code.claude.com/docs`, map updated 2026-06-19)
|
||||
both corrected the video **and** surfaced gaps in config-audit's own coverage.
|
||||
|
||||
The unifying insight: every steering mechanism has a different **loading model** and
|
||||
**compaction-survival** profile, and the docs now publish both explicitly. config-audit
|
||||
already audits *structure* well; it does **not** yet model *when a thing is loaded* or
|
||||
*whether it survives compaction* — which is exactly the "wrong mechanism = wasted tokens /
|
||||
unloaded instruction" axis. This plan closes that.
|
||||
|
||||
The small correctness items the check surfaced (HKV hook-events, RUL globs wording) are
|
||||
**already landed** on `main` (`b6a62d7`) and bound for the pending **v5.4.1** patch. This
|
||||
document covers only the **new functionality** (v5.5+), greenlit by the operator: A, B, C,
|
||||
D, E.
|
||||
|
||||
---
|
||||
|
||||
## Verification log (Verifiseringsplikt)
|
||||
|
||||
Every claim this plan builds on, with source and status. `CONFIRMED` = stated in primary
|
||||
docs; `REFUTED` = docs contradict; `UNVERIFIED` = not found in primary docs (do not assert).
|
||||
|
||||
| # | Claim | Status | Source |
|
||||
|---|-------|--------|--------|
|
||||
| V1 | Project-root CLAUDE.md + unscoped rules are **re-injected from disk after compaction** | CONFIRMED | `context-window.md#what-survives-compaction` |
|
||||
| V2 | Rules with `paths:` frontmatter are **lost after compaction** until a matching file is read again | CONFIRMED | `context-window.md#what-survives-compaction` |
|
||||
| V3 | Nested (subdir) CLAUDE.md is **lost after compaction** until a file in that dir is read again | CONFIRMED | `context-window.md`, `memory.md` |
|
||||
| V4 | Path-scoped rules trigger on **Read** of a matching file (not every tool use) | CONFIRMED | `memory.md#path-specific-rules` |
|
||||
| V5 | The only documented rule-scoping frontmatter field is **`paths`** (not `globs`) | CONFIRMED | `memory.md#path-specific-rules` |
|
||||
| V6 | `.claude/rules/` is an **official** feature; all `.md` auto-discovered recursively; unscoped = always-on | CONFIRMED | `memory.md#organize-rules-with-claude/rules/` |
|
||||
| V7 | Skill **name+description load every turn**; body loads on invoke | CONFIRMED | `skills.md`, `features-overview.md` |
|
||||
| V8 | Skill listing description cap = **1,536 chars** default of `maxSkillDescriptionChars` (configurable, v2.1.105+) | CONFIRMED | `skills.md`, `settings.md` |
|
||||
| V9 | Output styles **still exist** (not deprecated); only the standalone `/output-style` command was removed (v2.1.91) → use `/config` | CONFIRMED | `output-styles.md`, changelog |
|
||||
| V10 | Output styles **modify the system prompt** (add to end); a **custom** style without `keep-coding-instructions: true` **removes built-in software-engineering instructions** | CONFIRMED | `output-styles.md` |
|
||||
| V11 | `force-for-plugin: true` auto-applies a plugin's style, **overriding the user's `outputStyle`** | CONFIRMED | `output-styles.md` |
|
||||
| V12 | CLAUDE.md is "**a user message after the system prompt**", whereas output styles are a system-prompt mechanism | CONFIRMED | `output-styles.md` (comparison table) |
|
||||
| V13 | Output styles are "the most expensive" steering mechanism | **UNVERIFIED** — docs say cost rises but prompt-cache mitigates; no ranking | `output-styles.md` |
|
||||
| V14 | Sub-agent runs in isolated context; only a summary returns | CONFIRMED | `sub-agents.md` |
|
||||
| V15 | For **plugin** subagents, `hooks` / `mcpServers` / `permissionMode` frontmatter are **silently ignored** | CONFIRMED | `sub-agents.md` |
|
||||
| V16 | Agent `skills:` frontmatter **preloads full skill content at startup** | CONFIRMED | `sub-agents.md` |
|
||||
| V17 | Required agent frontmatter = **only `name` + `description`** | CONFIRMED | `sub-agents.md` |
|
||||
| V18 | Hooks: **~30 events** (not "five"); hook scripts run outside context, but injected `additionalContext` IS saved to transcript (subject to compaction) | CONFIRMED | `hooks.md` |
|
||||
| V19 | An official **mechanism-fit comparison table** exists (output style vs CLAUDE.md vs `--append-system-prompt` vs agents vs skills) | CONFIRMED | `output-styles.md` |
|
||||
| U1 | hook event `post-session` (kebab) | **RESOLVED → REFUTED** (2026-06-20). The 2.1.169 changelog `post-session` is a **self-hosted-runner** workspace-lifecycle hook, NOT a settings.json hook event; absent from `hooks.md` (all 30 events PascalCase). **Removed** from HKV `VALID_EVENTS` in v5.4.1. | `hooks.md`, changelog 2.1.169 |
|
||||
| U2 | A plugin.json `outputStyles` path-override key (PLH `SHADOWING_PATH_FIELDS`) | **RESOLVED → CONFIRMED** (2026-06-20). `outputStyles` (camelCase) is a documented plugin.json key in the **replaces** category (default `output-styles/` ignored when set). PLH is correct — no change. See `[[plugin-json-path-behavior]]`. | `plugins-reference.md` |
|
||||
|
||||
---
|
||||
|
||||
## The five features
|
||||
|
||||
Design constraints throughout: deterministic where possible; additive findings that do
|
||||
**not** fire on the SC-5 snapshot fixture stay byte-stable (the v5.3/v5.4 pattern); every
|
||||
new finding gets a stable title (fix-engine + humanizer couple on title — see `b6a62d7`).
|
||||
|
||||
### Foundation (prerequisite for B/A/C/E): enumeration
|
||||
|
||||
`active-config-reader.mjs` today enumerates CLAUDE.md cascade, hooks, MCP, skills, plugins —
|
||||
but **not** rules, agents, or output styles (confirmed by the coverage scan). B, and parts
|
||||
of A/C/E, need these enumerated with a `loadPattern` tag. This is the first build step.
|
||||
|
||||
- Add enumeration for: `.claude/rules/*.md` (+ `~/.claude/rules/`), agents (`.claude/agents/`,
|
||||
`~/.claude/agents/`, plugin agents), output styles (`.claude/output-styles/`,
|
||||
`~/.claude/output-styles/`).
|
||||
- Tag each source kind with `loadPattern ∈ { always, on-demand, external }` and
|
||||
`survivesCompaction ∈ { yes, no, n/a }` derived from V1–V3, V6, V7, V18.
|
||||
- **Test:** fixture with root+subdir CLAUDE.md, scoped+unscoped rule, project agent, output
|
||||
style → assert each is enumerated with the correct `loadPattern`/`survivesCompaction`.
|
||||
|
||||
### A — Durability / compaction-survival findings
|
||||
|
||||
**Problem.** A must-always-hold instruction placed where it does **not** survive compaction
|
||||
(nested CLAUDE.md per V3, path-scoped rule per V2) silently disappears mid-session after a
|
||||
`/compact`. The docs publish exactly which mechanisms survive (V1–V3) — so this is
|
||||
doc-grounded, not a guess.
|
||||
|
||||
**Shape (additive, no new scanner — count stays 13).**
|
||||
- **RUL** new finding `CA-RUL-NNN` — a **large** path-scoped rule (e.g. > 50 lines, reuse the
|
||||
existing unscoped-size heuristic) carries an informational note: its content is **lost after
|
||||
compaction** until a matching file is re-read (V2/V4). Severity **low** (awareness, not a bug).
|
||||
- **CML** new finding `CA-CML-NNN` — a **nested** (non-root) CLAUDE.md of meaningful size
|
||||
notes it is **not re-injected after compaction** (V3). Severity **low**.
|
||||
- Keep it strictly **structural** (size + location), never semantic ("is this instruction
|
||||
critical?") — that stays deterministic.
|
||||
|
||||
**Key assumption + test.** Discovery distinguishes root vs nested CLAUDE.md and scoped vs
|
||||
unscoped rules. → Test with a fixture asserting the finding fires for nested/scoped+large and
|
||||
**not** for root/unscoped.
|
||||
|
||||
**Byte-stability.** Additive; snapshot fixture has no nested CLAUDE.md / large scoped rule →
|
||||
SC-5 byte-stable (re-verify).
|
||||
|
||||
### B — Load-pattern accounting in `tokens` + `manifest`
|
||||
|
||||
**Problem.** `manifest.mjs` and TOK rank all sources uniformly by `estimated_tokens`. A skill
|
||||
listing entry (paid **every turn**, V7) ranks identically to a skill body (paid **on invoke**).
|
||||
The docs give a precise per-mechanism loading + survival model (V1–V3, V7, V18) that we can
|
||||
surface. This is the **core** of the "wrong mechanism = wasted tokens" thesis.
|
||||
|
||||
**Shape.**
|
||||
- Add a `loadPattern` (and `survivesCompaction`) column to `manifest` output and to TOK
|
||||
source records, derived from the foundation enumeration.
|
||||
- Add an **always-loaded subtotal** to the manifest summary: "≈X tokens enter context every
|
||||
turn before you type" (project-root CLAUDE.md + unscoped rules + skill/agent listing +
|
||||
output style + MCP schemas), vs an on-demand subtotal.
|
||||
- Optional follow-up: cost-benefit hint (a large `always` source dwarfing the on-demand pool).
|
||||
|
||||
**Key assumption + test.** Source-kind → `loadPattern` is a deterministic mapping. → Test:
|
||||
manifest on a mixed fixture yields the correct always-subtotal; `--json` includes the new field.
|
||||
|
||||
**Byte-stability — RISK.** B changes the **manifest/tokens output format** → **not**
|
||||
byte-stable. Requires snapshot regen (SC-5) and a decision on `--json` back-compat (add field
|
||||
vs version the schema). This is the highest-format-risk item; sequence it deliberately.
|
||||
|
||||
### C — Output-style scanner (new `CA-OST`, count 13 → 14)
|
||||
|
||||
**Problem.** Output styles are live (V9) and the **most surprising** surface: a custom style
|
||||
silently strips built-in software-engineering instructions (V10), and `force-for-plugin`
|
||||
overrides the user's choice (V11). config-audit does not scan them at all today (GAP/SET/PLH
|
||||
only touch adoption / a settings key / plugin folder-shadow).
|
||||
|
||||
**Shape (new scanner — output styles are a genuinely new file surface, so a new scanner is
|
||||
warranted, unlike the additive v5.3/v5.4 work).**
|
||||
- `CA-OST-001` — **custom** output style missing `keep-coding-instructions: true` → flags that
|
||||
built-in SWE instructions are removed (V10). Severity **medium** (silently changes coding
|
||||
behavior). The headline footgun.
|
||||
- `CA-OST-002` — `force-for-plugin: true` in a project/user style → it overrides the user's
|
||||
`outputStyle` (V11). Severity **low**.
|
||||
- `CA-OST-003` — settings `outputStyle` value resolving to a non-existent style → dead config.
|
||||
Severity **low/medium**. (Could live in SET instead; decide during build.)
|
||||
- Active output-style token cost → feed B's `always` accounting (V10: system-prompt, every turn).
|
||||
- **Not** asserting V13 ("most expensive") anywhere — unverified.
|
||||
|
||||
**Key assumption + test.** Discovery can find `.claude/output-styles/*.md` and
|
||||
`~/.claude/output-styles/*.md` (new discovery type). → Test: fixture with a custom style
|
||||
missing the flag fires `CA-OST-001`; a built-in style reference does not.
|
||||
|
||||
**Byte-stability.** New scanner; fixture-gated → SC-5 byte-stable if it does not fire on the
|
||||
snapshot project (re-verify). Self-audit scanner-count badge moves 13 → 14 (update README +
|
||||
CLAUDE.md inventory + the "count stays 13" lore).
|
||||
|
||||
### D — Mechanism-fit detector (heuristic — lowest precision, sequence last)
|
||||
|
||||
**Problem.** The docs publish a mechanism-fit table (V19) and the architectural distinction
|
||||
that CLAUDE.md is a per-turn user message while output styles are system-prompt (V12). Common
|
||||
mismatch: an "every time / before each" instruction in CLAUDE.md that should be a **hook**; a
|
||||
path-specific instruction in root CLAUDE.md that should be a **path-scoped rule** (V4/V6).
|
||||
|
||||
**Shape (heuristic; additive to CML or a small new check).**
|
||||
- `CA-???-001` — imperative lifecycle phrasing in CLAUDE.md (`every time`, `before each`,
|
||||
`always run`, `after you …`, `whenever you …`) describing a tool/lifecycle action → suggest
|
||||
a hook. Framed as **Missed opportunity**, severity **low**, never "Fix this now".
|
||||
- `CA-???-002` — path-specific phrasing in **root** CLAUDE.md (`in src/**`, `for *.ts files`)
|
||||
→ suggest a path-scoped rule.
|
||||
|
||||
**Key assumption + test — THE RISK.** Phrase heuristics must have low false-positive rate. →
|
||||
Test with a positive corpus (clear mismatches → flagged) **and** a negative corpus (normal
|
||||
project conventions → silent). Must be suppressible (`.config-audit-ignore`). If precision is
|
||||
poor in testing, **ship behind a flag or defer** — do not ship a noisy heuristic.
|
||||
|
||||
**Byte-stability.** Additive; risk it fires on the snapshot's CLAUDE.md → check carefully,
|
||||
may need snapshot regen.
|
||||
|
||||
### E — Agent-listing cost + plugin-agent dead-config (additive to PLH; feeds B)
|
||||
|
||||
**Problem.** Agents' name+description load for delegation (every turn), like skills — but
|
||||
unlike skills they're unmetered. And `hooks`/`mcpServers`/`permissionMode` in a **plugin**
|
||||
agent's frontmatter are silently ignored (V15) — dead config, and a false sense of
|
||||
isolation/security if `permissionMode` is among them.
|
||||
|
||||
**Shape (additive to PLH).**
|
||||
- `CA-PLH-NNN` — plugin agent declaring `hooks` / `mcpServers` / `permissionMode` → dead config
|
||||
(V15). Severity **low** generally; **medium** for `permissionMode` (false security).
|
||||
- Agent description cost → feed B's `always` accounting. **No hard cap claimed** — research
|
||||
found no documented agent-description limit (unlike skills' 1,536, V8). Treat as cost signal,
|
||||
not a limit.
|
||||
- `skills:` preload in agent frontmatter → informational note: full skill content injected at
|
||||
startup (V16).
|
||||
|
||||
**Key assumption + test.** PLH can tell a **plugin** agent from a user/project agent (V15
|
||||
only applies to plugin subagents). → Test: plugin-agent fixture with `permissionMode` fires
|
||||
medium dead-config; identical user-level agent does **not**.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & phased rollout
|
||||
|
||||
```
|
||||
Foundation (enumeration: rules, agents, output styles + loadPattern/survivesCompaction)
|
||||
├── A (durability) additive RUL+CML doc-grounded, low risk
|
||||
├── E (plugin-agent dead) additive PLH doc-grounded, low risk
|
||||
├── C (output-style) new CA-OST (13→14) doc-grounded, new surface
|
||||
└── B (load-pattern) manifest/tokens format doc-grounded, FORMAT risk
|
||||
D (mechanism-fit) heuristic independent, precision risk
|
||||
```
|
||||
|
||||
**Recommended phasing** (chunk-work rule — session-sized, checkpoint STATE between):
|
||||
|
||||
- **v5.5.0 "steering-model I"** — A + E only. **DONE on main** (E `f75ed56`, A `f3aadb5`,
|
||||
2026-06-20). Additive to RUL/CML/PLH, doc-grounded, byte-stable, count stays 13. **Foundation
|
||||
was dropped from v5.5.0**: on inspection A/E are additive to scanners that already read the
|
||||
files and do NOT consume the `active-config-reader` enumeration — that serves B, so it moves to
|
||||
v5.6. (Release-cut of v5.5.0 is a separate GO step.)
|
||||
- **v5.6.0 "steering-model II"** — **Foundation** (active-config-reader enumeration +
|
||||
`loadPattern`/`survivesCompaction`) + B (load-pattern accounting, manifest format change +
|
||||
snapshot regen + `--json` back-compat decision) + C (new `CA-OST`, count → 14). Also fix the
|
||||
frontmatter-parser block-sequence limitation (inline `paths:` only today) as part of Foundation.
|
||||
- **v5.7.0 (optional)** — D, only if the negative-corpus test shows acceptable precision;
|
||||
otherwise behind a flag or dropped.
|
||||
|
||||
(Operator may prefer a single larger v5.5 — but B's format change and D's precision risk argue
|
||||
for separating them from the low-risk additive batch.)
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria (whole programme)
|
||||
|
||||
- [ ] Every new finding's primary claim traces to a `CONFIRMED` row above (no `UNVERIFIED`
|
||||
assertions in user-facing text — V13 and U1/U2 must be resolved or omitted).
|
||||
- [ ] `node --test 'tests/**/*.test.mjs'` green; README test badge == suite count
|
||||
(`self-audit --check-readme` `passed: true`).
|
||||
- [ ] `self-audit` stays **A / A**, no critical/high on the plugin itself.
|
||||
- [ ] Each new finding has: a positive fixture (fires) **and** a negative fixture (silent).
|
||||
- [ ] fix-engine + humanizer-data entries added/updated for any new finding title (title
|
||||
coupling — see `b6a62d7`).
|
||||
- [ ] SC-5 snapshot: byte-stable for additive findings (A/E); regenerated + reviewed for B
|
||||
(and D if it touches the snapshot project).
|
||||
- [ ] Scanner-count lore updated everywhere if C lands (README badge, CLAUDE.md inventory,
|
||||
`docs/scanner-internals.md`, the "count stays 13" notes).
|
||||
- [x] U1 (`post-session`) and U2 (plugin `outputStyles` key) resolved (2026-06-20): U1 refuted
|
||||
→ removed from HKV in v5.4.1; U2 confirmed → PLH unchanged.
|
||||
|
||||
## Open decisions for the operator
|
||||
|
||||
1. **Phasing**: three releases (recommended) vs one v5.5 with everything?
|
||||
2. **C placement**: new `CA-OST` scanner (count → 14) vs folding output-style checks into
|
||||
SET+a file check (keeps 13)? (Recommend new scanner — distinct surface.)
|
||||
3. **D**: build now (heuristic, precision-gated) vs defer until A/B/C/E prove the theme?
|
||||
4. **B `--json`**: add `loadPattern` field in place (mild back-compat risk) vs schema version bump?
|
||||
126
docs/v5.7-optimization-lens-plan.md
Normal file
126
docs/v5.7-optimization-lens-plan.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# v5.7 — Optimization Lens + Living Knowledge Base (Plan)
|
||||
|
||||
> Outcome of the 2026-06-20 vision discussion. This is the **first concrete realization**
|
||||
> of the operator's "F1-tuning" north star (see auto-memory `config-audit-vision`). The
|
||||
> shift is from *"is the config **correct**?"* (today's health scanners) to *"is the config
|
||||
> **optimal / best-practice-tuned**?"*.
|
||||
>
|
||||
> **Status: GO-ready design. Implementation is a SEPARATE GO, chunk by chunk.** The
|
||||
> discussion session deliberately stopped before code (per STATE.md). This doc mirrors the
|
||||
> verification-protocol format of `docs/v5.5-steering-model-plan.md`.
|
||||
|
||||
## The four building blocks (full vision)
|
||||
|
||||
The vision decomposes into four blocks. v5.7 builds **Fase 1** (blocks 1+2 lite). Blocks
|
||||
3+4 are **Fase 2** (deferred, own GO).
|
||||
|
||||
| # | Block | Phase | One-liner |
|
||||
|---|-------|-------|-----------|
|
||||
| 1 | **Optimization lens** | **Fase 1** | New finding family: "you USE mechanism X, but Y fits this content better" (mechanism-fit). |
|
||||
| 2 | **Living knowledge base** | **Fase 1** | Structured, provenance-stamped register the lens reads + semi-auto refresh. |
|
||||
| 3 | Machine-wide campaign | Fase 2 | A durable ledger above sessions: audit N repos over many sessions, resumable, machine-wide roll-up. |
|
||||
| 4 | Durable backlog + execution | Fase 2 | One cross-repo prioritized backlog (critical/high/med/low) the user picks from; per-repo plans exported to each repo's `docs/`. |
|
||||
|
||||
**Why Fase 1 first (operator-confirmed):** the value of a machine-wide campaign (Fase 2)
|
||||
depends on the lens being good. Building the campaign shell on today's correctness-only
|
||||
scanners would underdeliver the vision. So: establish + validate the lens on ONE repo,
|
||||
then scale.
|
||||
|
||||
## Decisions locked (2026-06-20)
|
||||
|
||||
- **Sequence:** Fase 1 (lens + knowledge) first, then Fase 2 (campaign + backlog). _(operator)_
|
||||
- **Knowledge format:** **structured register** (YAML/JSON) the lens reads directly;
|
||||
markdown kept as a human-readable mirror. _(operator, over markdown-only)_
|
||||
- **Lens engine:** **new finding family** with a **hybrid motor** — deterministic
|
||||
pre-filter → opus analyzer that judges mechanism-fit and cites the register rule;
|
||||
precision-gated. _(operator, over extend-feature-gap / pure-deterministic-scanner)_
|
||||
|
||||
## Fase 1 — two deliverables
|
||||
|
||||
### Leveranse A — Living knowledge register
|
||||
|
||||
Today `knowledge/*.md` (8 files) is prose with a `Source: … verified DATE` header, and the
|
||||
v5.5 V-rows are an ad-hoc table. The foundation exists; what's missing is a
|
||||
**machine-consumable** form with provenance per claim.
|
||||
|
||||
- **Format:** one register (e.g. `knowledge/best-practices.yaml`), one entry = one
|
||||
best-practice rule, with fields:
|
||||
`id / claim / mechanism / recommendation / source-url / verified-date / confidence
|
||||
{confirmed|inferred|unverified} / lens-check (which detector consumes it)`.
|
||||
- **Markdown stays** as the readable mirror; the v5.5 V-rows are **migrated into** the
|
||||
register (formalizing the existing claim→source→CONFIRMED protocol).
|
||||
- **"Living" =** `/config-audit knowledge-refresh` (semi-auto, **human-approved writes**):
|
||||
polls CC changelog + Anthropic docs/blog, flags `stale` (older than N days / source
|
||||
changed) and `candidate` (new practice found), presents for approval. Mirrors the
|
||||
`architect` plugin's kb-update poll. **No unverified claim is ever auto-written**
|
||||
(Verifiseringsplikt).
|
||||
|
||||
### Leveranse B — Optimization lens (new family, e.g. `CA-OPT`)
|
||||
|
||||
A new finding layer that reads CLAUDE.md / rules / skills / hooks and checks each against
|
||||
the register's mechanism-fit rules. Content is already source-anchored from the Anthropic
|
||||
"Steering Claude Code" blog (read 2026-06-20):
|
||||
|
||||
| Signal in config | Best-practice rule | Source |
|
||||
|---|---|---|
|
||||
| Lifecycle phrasing ("after every commit, do X") in CLAUDE.md | → hook (deterministic) | blog |
|
||||
| Path-specific instruction, unscoped | → path-scoped rule (`paths:`) | blog |
|
||||
| 30-line procedure in CLAUDE.md | → skill | blog |
|
||||
| "Never do X" as an instruction | → permission/hook (an instruction is the wrong tool for absolute prohibitions) | blog |
|
||||
| Custom output-style missing `keep-coding-instructions` | (already covered by CA-OST-001) | blog/docs |
|
||||
|
||||
This is **v5.7 D "mechanism-fit" promoted to a real family**, driven by the register
|
||||
instead of hardcoded rules.
|
||||
|
||||
- **Hybrid motor:** cheap deterministic pre-filter (line counts, lifecycle keywords,
|
||||
path-specificity) → opus analyzer agent (sibling of `feature-gap-agent`) that judges fit
|
||||
and cites the register rule. **Precision-gated** (emit only on high confidence).
|
||||
- **Overlap with `feature-gap`:** feature-gap = "you DON'T use feature X"; optimization
|
||||
lens = "you USE mechanism X, but Y fits THIS content better." Decision: **separate
|
||||
family** to keep those two intents clean.
|
||||
- Findings fold into existing posture/report/plan flow → **Fase 2 campaign inherits them
|
||||
for free**.
|
||||
|
||||
## Proposed chunking (one GO'd session each; per `chunk-work-before-compaction`)
|
||||
|
||||
1. **Chunk 1 — Register foundation.** Structured register format + schema validation +
|
||||
migrate existing `knowledge/` + v5.5 V-rows into it + tests. (No output change → byte-stable.)
|
||||
2. **Chunk 2 — The lens (`CA-OPT`).** Deterministic pre-filter + opus analyzer agent +
|
||||
humanizer/scoring wiring + fixtures + byte-stability strip. (New family → additive.)
|
||||
3. **Chunk 3 — `knowledge-refresh`.** Semi-auto poller: `--dry-run`, stale/candidate
|
||||
flagging, human-approved writes. (The "living" part.)
|
||||
|
||||
Dependency order: Chunk 1 → Chunk 2 (lens reads register) → Chunk 3 (keeps register fresh).
|
||||
|
||||
## Verification (per plan-quality rule)
|
||||
|
||||
- **Register:** schema validates; every entry has source + verified-date;
|
||||
`knowledge-refresh --dry-run` lists stale/candidate **without writing**.
|
||||
- **Lens:** a fixture repo with KNOWN mechanism-fit problems (procedure-in-CLAUDE.md,
|
||||
unscoped path-rule) → lens flags **exactly** those; **zero false positives** on a clean
|
||||
fixture. Precision target stated explicitly (lens is precision-gated).
|
||||
- **Byte-stability:** new `CA-OPT` family is additive → follow the **post-B2 "preserve
|
||||
frozen + strip"** regime (do NOT re-seed); regen only SC-5 default-output. New scanner
|
||||
family also bumps `scanners_ok` on the deterministic fixture → mirror the `strip-added-scanner`
|
||||
precedent from CA-OST (v5.6 C).
|
||||
|
||||
## Fase 2 — deferred (own GO, after Fase 1 validated)
|
||||
|
||||
- **Block 3 — Machine-wide campaign:** a durable campaign ledger above sessions (repo
|
||||
list + per-repo status pending/audited/planned/implemented + machine-wide roll-up by
|
||||
severity), resumable across sessions. Start **thin** (ledger + roll-up), not full
|
||||
orchestration.
|
||||
- **Block 4 — Durable backlog + execution:** make persistence explicit + versioned/
|
||||
migratable; one cross-repo prioritized backlog the user picks from; per-repo plans
|
||||
optionally exported to each repo's own `docs/` ("planer følger arbeidsstedet"); reuse
|
||||
existing backup/rollback for execution.
|
||||
- **Persistence note:** sessions already live in `~/.claude/config-audit/sessions/`
|
||||
(OUTSIDE the plugin dir → survive uninstall/reinstall/upgrade — verified). Fase 2 adds
|
||||
an explicit version field + migration so upgrades don't break the ledger.
|
||||
|
||||
## Synergy / open threads
|
||||
|
||||
- **`/repo-init` synergy:** once the lens exists, it becomes the quality meter for what
|
||||
`/repo-init` produces — evaluate repo-init output with the same lens.
|
||||
- **Open:** final ID prefix for the family (`CA-OPT` proposed); register file format
|
||||
(YAML vs JSON); `knowledge-refresh` poll cadence + which sources beyond changelog/blog.
|
||||
96
docs/v6-quality-plan.md
Normal file
96
docs/v6-quality-plan.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# v6 quality plan — from "green suite" to A+
|
||||
|
||||
**Written 2026-08-12 (session #65), after a gate defect surfaced by accident rather than by
|
||||
process.** This plan is about the *process* that missed it, not about the one defect.
|
||||
|
||||
## 0. Root cause — and why a bigger model is not the fix
|
||||
|
||||
The defect (`fix-engine.mjs` writes files without ever consulting the scope gate) was **already
|
||||
written down**. It sat in `STATE.md`'s ÅPNE POSTER paragraph, mid-sentence, between "P6/M-BUG-44"
|
||||
and "M-BUG-26":
|
||||
|
||||
> *scope-gaten for de FEM andre armene er fortsatt prosa-kontrakt — `fix-engine` leser ikke `gate`*
|
||||
|
||||
It was read at session start and not acted on. **A stronger model reading the same paragraph
|
||||
reaches the same conclusion**, because the paragraph gives it no reason to: an unenforced safety
|
||||
gate is formatted identically to "4 inline copies of a target guard" and "cleanup of invisible
|
||||
session files". The tracking system has **no severity axis**, so nothing in the data says one of
|
||||
these can let a write reach `~/.claude/CLAUDE.md` unapproved and the others cannot.
|
||||
|
||||
That is the finding. Model choice does not fix a missing severity axis.
|
||||
|
||||
**Second occurrence, same class.** #63 found a gate silently not firing because the *command
|
||||
layer* was untested (`--repo <scan-target>` dropped the gate to `silent`, 29 removals, no
|
||||
approval asked). #65 finds a gate not firing because the *engine* never reads it. Two instances
|
||||
of "only prose stood behind a write gate" is a class, not luck ([[defect-found-in-one-file-is-a-class]]).
|
||||
|
||||
## 1. The class, measured
|
||||
|
||||
| Question | Measured 2026-08-12 |
|
||||
|---|---|
|
||||
| Files in `scanners/` that write to disk | **9** |
|
||||
| …that import the scope gate | **1** (`lib/subtraction-write.mjs`) |
|
||||
| Command templates | **21** |
|
||||
| …that name a write action | **17** |
|
||||
| …that invoke `write-scope-cli.mjs` | **5** |
|
||||
|
||||
**The 8 ungated writers are not 8 bugs.** `write-output.mjs`, `backup.mjs`, `baseline.mjs`,
|
||||
`scan-orchestrator.mjs` write plugin-managed artefacts and are legitimately exempt. The defect is
|
||||
that **nothing declares which**: "does this write path need the gate?" is answered by reading
|
||||
code, never by a guard. That is precisely what let `fix-engine` sit unguarded next to
|
||||
`subtraction-write`, which does it right.
|
||||
|
||||
## 2. What A+ means here, concretely
|
||||
|
||||
Not "more care". Three falsifiable properties:
|
||||
|
||||
1. **No invariant is enforced only by prose.** Every contract a command template states about a
|
||||
write, a gate, or a scope is asserted by a test that fails when the code stops honouring it.
|
||||
2. **Every open item carries a severity and a consequence sentence.** "What breaks if this stays
|
||||
open" is written next to it, and anything touching a write, a gate, or user-scope config never
|
||||
lives in the backlog paragraph.
|
||||
3. **Shipped ≠ committed.** Work that is not released is not quality: the machine runs the
|
||||
released plugin, so 29 unreleased commits are 29 fixes nobody has.
|
||||
|
||||
## 3. Chunks, in order
|
||||
|
||||
### Q1 — the gate moves from prose into code (BLOCKS the release)
|
||||
`fix-engine` calls `classifyWriteTarget` + `strongestGate`, exactly as `subtraction-write` already
|
||||
does — share the constant, do not copy it ([[two copies of one table drift]]). Add an **explicit
|
||||
exemption table** naming every plugin-managed writer and *why* it is exempt.
|
||||
**Verify:** a guard that walks `scanners/` for write calls and fails on any writer that neither
|
||||
imports the gate nor appears in the exemption table. Seen RED against today's tree first.
|
||||
|
||||
### Q2 — the command layer gets contract tests
|
||||
The 17 templates that name a write are today verified by nothing. Build the argv **from the
|
||||
template's own text** ([[dogfood-the-command-not-the-cli]]) and assert: the command a template
|
||||
tells the agent to run parses, targets the file the gate classified, and calls the gate before
|
||||
any write.
|
||||
**Verify:** delete the `write-scope-cli` line from one template → its test goes red.
|
||||
|
||||
### Q_AUDIT — one Fable session: find the rest of the class
|
||||
A cross-cutting sweep for other invariants that exist only in prose (agent prompts, command
|
||||
templates, `.claude/rules/`), each rated by what breaks if it silently stops holding. This is
|
||||
review/big-picture work — Fable's documented form strength and a **first choice**, not a fallback.
|
||||
Output: a rated list, not code. A Fable session runs **without advisor**.
|
||||
|
||||
### Q3 — severity axis in the tracking (cheap, rides along)
|
||||
`STATE.md` open items become a table with `severity` + consequence. Rule: safety/write/user-scope
|
||||
items are never in the backlog paragraph. This is the fix for the actual root cause.
|
||||
|
||||
### Q4 — release v6.0.0
|
||||
29 commits, 21 of them `feat`/`fix`, including breaking ID semantics (`7a794b4`). Only after Q1.
|
||||
Gate: `self-audit --check-readme` + full suite + `check-versions.mjs` 0 ERROR.
|
||||
|
||||
### Then B3 (two-layer CNF), as planned.
|
||||
|
||||
## 4. Model routing for this plan
|
||||
|
||||
| Chunk | Model | Why |
|
||||
|---|---|---|
|
||||
| Q1, Q2, Q3 | **Opus 5 / high** | implementation with strong verification (tests fail loudly) |
|
||||
| Q_AUDIT | **Fable 5 / xhigh** | cross-cutting review + planning; deliberate override of the rubric, recorded in STATE as an override, no advisor |
|
||||
| Q4 release | Opus 5 / high | mechanical but one-way (a pushed tag) |
|
||||
|
||||
**Not a blanket model upgrade.** Escalating every session to compensate for a missing guard is the
|
||||
expensive way to not fix the guard.
|
||||
|
|
@ -6,7 +6,11 @@ import { readdirSync, readFileSync, existsSync } from 'fs';
|
|||
import { join, basename } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
|
||||
// Canonical location since v2.2.0. The pre-v2.2.0 path is kept as a fallback so
|
||||
// sessions created before the move are still detected (see commands/cleanup.md).
|
||||
const canonicalSessionsDir = join(homedir(), '.claude', 'config-audit', 'sessions');
|
||||
const legacySessionsDir = join(homedir(), '.config-audit', 'sessions');
|
||||
const sessionsDir = existsSync(canonicalSessionsDir) ? canonicalSessionsDir : legacySessionsDir;
|
||||
|
||||
if (!existsSync(sessionsDir)) {
|
||||
process.exit(0);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
|
|||
import { join, basename, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
|
||||
// Canonical location since v2.2.0. The pre-v2.2.0 path is kept as a fallback so
|
||||
// sessions created before the move are still detected (see commands/cleanup.md).
|
||||
const canonicalSessionsDir = join(homedir(), '.claude', 'config-audit', 'sessions');
|
||||
const legacySessionsDir = join(homedir(), '.config-audit', 'sessions');
|
||||
const sessionsDir = existsSync(canonicalSessionsDir) ? canonicalSessionsDir : legacySessionsDir;
|
||||
|
||||
if (!existsSync(sessionsDir)) {
|
||||
console.log('{}');
|
||||
|
|
|
|||
302
knowledge/best-practices.json
Normal file
302
knowledge/best-practices.json
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
{
|
||||
"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.",
|
||||
"entries": [
|
||||
{
|
||||
"id": "BP-MECH-001",
|
||||
"claim": "Lifecycle automation phrased as an instruction in CLAUDE.md (\"every time\", \"before each\", \"always run X after Y\") should be a hook — a behavior the model chooses to follow is not deterministic.",
|
||||
"mechanism": "hook",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Move the behavior to a PreToolUse/PostToolUse/Stop hook so it runs deterministically, outside the model's discretion.",
|
||||
"confidence": "confirmed",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-MECH-002",
|
||||
"claim": "A file- or path-specific constraint placed in root CLAUDE.md or an unscoped rule should be a path-scoped rule (paths: frontmatter), so it loads only when a matching file is touched.",
|
||||
"mechanism": "rule",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Move it to .claude/rules/ with a paths: frontmatter; unscoped instructions cost tokens every turn whether relevant or not.",
|
||||
"confidence": "confirmed",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-MECH-003",
|
||||
"claim": "A multi-step procedure (deploy/release checklist) in CLAUDE.md should be a skill — CLAUDE.md is for facts Claude should hold all the time; procedures belong in skills.",
|
||||
"mechanism": "skill",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Extract the procedure into .claude/skills/; its body then loads only on invoke instead of every turn.",
|
||||
"confidence": "confirmed",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-MECH-004",
|
||||
"claim": "An absolute prohibition phrased as a \"never do X\" instruction is the wrong tool; for something that absolutely must not happen, use permissions or a PreToolUse hook.",
|
||||
"mechanism": "permission",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Enforce hard prohibitions via permission deny rules or a PreToolUse hook (exit code 2 denies the call), not prose instructions.",
|
||||
"confidence": "confirmed",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-MECH-005",
|
||||
"claim": "A custom output style without keep-coding-instructions: true removes Claude Code's built-in software-engineering instructions when active.",
|
||||
"mechanism": "output-style",
|
||||
"appliesTo": "output-style",
|
||||
"recommendation": "Set keep-coding-instructions: true, or prefer a built-in style (Explanatory / Learning / Proactive) before writing a custom one.",
|
||||
"confidence": "confirmed",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-001",
|
||||
"claim": "Project-root CLAUDE.md and unscoped rules are re-injected from disk after compaction (they survive a /compact).",
|
||||
"appliesTo": "claude-md",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-002",
|
||||
"claim": "Path-scoped rules are lost after compaction until a matching file is read again, and they trigger on Read of a matching file (not on every tool use).",
|
||||
"appliesTo": "rule",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-003",
|
||||
"claim": "A nested (non-root) CLAUDE.md is lost after compaction until a file in its directory is read again.",
|
||||
"appliesTo": "claude-md",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-004",
|
||||
"claim": "A skill's name + description load every turn; its body loads only on invoke.",
|
||||
"appliesTo": "skill",
|
||||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/skills",
|
||||
"title": "Skills",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-005",
|
||||
"claim": "Hook scripts run outside the model context, but any additionalContext they inject is saved to the transcript and is therefore subject to compaction.",
|
||||
"appliesTo": "hook",
|
||||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/hooks",
|
||||
"title": "Hooks",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-LOAD-006",
|
||||
"claim": "A subagent runs in an isolated, fresh context window; only its final summary returns to the main session (parent instructions are not auto-injected).",
|
||||
"appliesTo": "agent",
|
||||
"confidence": "confirmed",
|
||||
"category": "loading-model",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/sub-agents",
|
||||
"title": "Subagents",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-SIZE-001",
|
||||
"claim": "Keep CLAUDE.md under 200 lines; give it an owner and review changes to it like code. Every line costs tokens whether relevant or not.",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Trim CLAUDE.md to facts; move procedures to skills and path-specific rules to .claude/rules/.",
|
||||
"confidence": "confirmed",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-SIZE-002",
|
||||
"claim": "The skill-listing description cap is 1,536 characters (maxSkillDescriptionChars, configurable, v2.1.105+); the name + description load every turn.",
|
||||
"appliesTo": "skill",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "size-budget",
|
||||
"lensCheck": "CA-SKL-002",
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/skills",
|
||||
"title": "Skills",
|
||||
"verified": "2026-06-20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-SUB-001",
|
||||
"claim": "Every line of CLAUDE.md loads into every session whether or not it is relevant, which consumes tokens and dilutes adherence. A line that states a local fact the model cannot derive (build commands, directory layout, conventions, team norms) earns that cost; a line that only restates general engineering behaviour pays it without being the kind of content CLAUDE.md is for, and is a candidate for removal.",
|
||||
"mechanism": "deletion",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Review the block for removal, then re-add it only if the model actually stumbles on it repeatedly. Local facts (remotes, versions, paths, conventions) and policy invariants are the floor and are never removal candidates.",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "subtraction",
|
||||
"lensCheck": "compensatory-instruction",
|
||||
"source": {
|
||||
"url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more",
|
||||
"title": "Steering Claude Code: skills, hooks, rules, subagents and more",
|
||||
"verified": "2026-07-31"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models",
|
||||
"title": "The new rules of context engineering for Claude 5 generation models",
|
||||
"published": "2026-07-24",
|
||||
"verified": "2026-08-03",
|
||||
"note": "Near-verbatim coverage: 'briefly describe what your repo is for, but spend most of the tokens on gotchas inside of the codebase'; 'Avoid stating the obvious things Claude should know by looking at your file system or your repo.'"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "BP-MODEL-001",
|
||||
"claim": "A subagent's `model` frontmatter field defaults to `inherit`, so a subagent that names no model runs on the main conversation's model. Routing mechanical or read-only subagents to a cheaper alias (`haiku`, `sonnet`) while the orchestrating session keeps the stronger model is the documented way to control cost. The pin is not absolute: Claude Code resolves the model as CLAUDE_CODE_SUBAGENT_MODEL, then a per-invocation `model` parameter, then the frontmatter, then the main conversation's model.",
|
||||
"mechanism": "model",
|
||||
"appliesTo": "agent",
|
||||
"recommendation": "Set `model:` explicitly on subagents whose work is mechanical or read-only (search, extraction, summarisation) and leave the orchestrator on the stronger model. Omitting the field is not a neutral default — it inherits, so every subagent costs what the session costs.",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "model-fit",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/sub-agents",
|
||||
"title": "Create custom subagents — supported frontmatter fields / choose a model",
|
||||
"verified": "2026-08-10"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://claude.com/blog/claude-model-and-effort-level-in-claude-code",
|
||||
"title": "Choosing a Claude model and effort level in Claude Code",
|
||||
"published": "2026-07-07",
|
||||
"verified": "2026-08-10",
|
||||
"note": "Verbatim: 'Pick a smaller model when the work is routine. For example, edits you can describe precisely, mechanical changes, or questions about code that's already in context.'"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "BP-MODEL-002",
|
||||
"claim": "Reasoning effort is an axis separate from model choice: five levels (`low`, `medium`, `high`, `xhigh`, `max`) on current models, four on Opus 4.6 and Sonnet 4.6, which omit `xhigh`; the default is `high` on every model that supports effort except Opus 4.7, which defaults to `xhigh`. Higher is not universally better — `max` \"can improve performance on demanding tasks but may show diminishing returns and is prone to overthinking\". Effort is settable in six places: `/effort`, the slider in `/model`, the `--effort` flag, CLAUDE_CODE_EFFORT_LEVEL, `effortLevel` in settings, and `effort:` in skill or subagent frontmatter; the environment variable takes precedence over all of them.",
|
||||
"mechanism": "effort",
|
||||
"appliesTo": "agent",
|
||||
"recommendation": "Treat effort as a per-task dial rather than a global maximum: pin a lower `effort:` in the frontmatter of mechanical skills and subagents, and reserve `xhigh`/`max` for work whose product is judgment. The scale is calibrated per model, so the same level name is not the same amount of thinking across models — and CLAUDE_CODE_EFFORT_LEVEL silently overrides every other source, so verify which level is actually in force.",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "model-fit",
|
||||
"lensCheck": null,
|
||||
"source": {
|
||||
"url": "https://code.claude.com/docs/en/model-config",
|
||||
"title": "Model configuration — adjust effort level / set the effort level",
|
||||
"verified": "2026-08-10"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://claude.com/blog/claude-model-and-effort-level-in-claude-code",
|
||||
"title": "Choosing a Claude model and effort level in Claude Code",
|
||||
"published": "2026-07-07",
|
||||
"verified": "2026-08-10",
|
||||
"note": "Verbatim: 'Claude will be more predisposed to double-checking additional hypotheses or verifying correctness at higher effort levels, but it generally won't artificially inflate usage for simple tasks at higher effort levels.'; 'In fact, our team pays close attention to \"overthinking\" during model training as it degrades effectiveness.'"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "BP-JUDG-001",
|
||||
"claim": "An instruction that fixes a form decision — how code or prose should look (comment density, docstring length, naming shape, sentence or paragraph count) — as an absolute rule buys a guardrail current models no longer need, and is wrong for the cases the rule did not anticipate. Anthropic removed its own example from the Claude Code system prompt: \"Never write multi-paragraph docstrings or multi-line comment blocks — one short line max\" was replaced by \"Write code that reads like the surrounding code: match its comment density, naming, and idiom.\"",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Where an absolute governs a form decision rather than a local fact, state the outcome you want and let the model judge the instance. This does not apply to safety rules, tool or version facts, or a house style you hold deliberately — those are the reason the claim is not machine-checkable.",
|
||||
"confidence": "confirmed",
|
||||
"category": "judgment-fit",
|
||||
"lensCheck": null,
|
||||
"note": "KNOWLEDGE ONLY — no detector, by measurement (docs/b2-judgment-lens-fasit.local.md §9). Across 409 real CLAUDE.md files (38488 lines, 8689 prose blocks) the class fired 7 times and all 7 were false positives; an independent grep in both word orders found 5 lines and 1 line respectively, none an instruction. Where the shape does occur — 45 matching lines across 4755 skill/agent/command files — it is the author's editorial policy (emoji, sentence length, slide titles), which nothing in the text separates from a vendor's over-tight guardrail. Precision-first: no CA-OPT code was allocated and OPT next-free stays 2.",
|
||||
"source": {
|
||||
"url": "https://claude.com/blog/the-new-rules-of-context-engineering-for-claude-5-generation-models",
|
||||
"title": "The new rules of context engineering for Claude 5 generation models",
|
||||
"published": "2026-07-24",
|
||||
"verified": "2026-08-12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "BP-PROMPT-001",
|
||||
"claim": "Claude Opus 5 catches and fixes its own mistakes without being told to, and over-verifies when explicitly instructed to double-check or to delegate verification to a subagent — this adds token cost without improving output quality. The source states both halves: \"Avoid instructing re-checks it already performs ('double-check your answer,' 're-verify before responding')\" and \"If your prompt contains explicit verification instructions ('include a final verification step for any non-trivial task,' 'use a subagent to verify'), remove them\".",
|
||||
"mechanism": "deletion",
|
||||
"appliesTo": "claude-md",
|
||||
"recommendation": "Remove explicit self-verification or delegate-to-subagent-for-verification instructions when targeting Claude Opus 5; re-add only if the model actually stumbles. The claim is model-scoped, not universal — a config that runs a different model in a different session still needs them.",
|
||||
"confidence": "confirmed",
|
||||
"severity": "low",
|
||||
"category": "prompting-fit",
|
||||
"modelScope": ["opus-5"],
|
||||
"lensCheck": null,
|
||||
"note": "lensCheck deliberately null, same discipline as BP-JUDG-001: this is a knowledge-cited ANNOTATION on an existing BP-SUB-001 candidate (scanners/lib/prompting-model-scope.mjs), gated behind an explicit --for-model flag, not a second competing detector. It never widens the candidate set. Auto-detection is not viable — a CLAUDE.md carries no frontmatter and no statically-resolvable target model. source.published is absent because the page carries no visible publish or last-updated date (re-checked 2026-08-12); both quoted sentences were verified verbatim on that date. MEASURED 2026-08-12 across 409 real CLAUDE.md files: 392 BP-SUB-001 candidates, 31 of them (7.9%) carry a verify verb, and 0 also carry a reflexive or delegated target — so the annotation fires 0 times on this corpus. Two independent raw-text greps (reflexive phrasings; subagent-near-verify in both word orders) also found 0, so the zero is the corpus, not an over-narrow regex. The TARGET requirement is what earns its place: without it the same 31 verb-only blocks — 'sjekk relevante config-filer', 'Type-sjekk: pyright', 'To verify plugin functionality' — would all have been tagged, which is the BP-JUDG-001 failure mode (7/7 false positives) arriving one lens over. Precision on the corpus is 0 wrong out of 31 chances to be wrong; recall is untested there because the class is absent — the detector's true positives are the source doc's own example phrasings, pinned in tests/lib/prompting-model-scope.test.mjs.",
|
||||
"source": {
|
||||
"url": "https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-opus-5",
|
||||
"title": "Prompting Claude Opus 5 — Self-correction / Task scope and over-verification",
|
||||
"verified": "2026-08-12"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
118
scanners/agent-listing-scanner.mjs
Normal file
118
scanners/agent-listing-scanner.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* AGT Scanner — Agent-listing always-loaded token budget
|
||||
*
|
||||
* Claude Code injects a listing of every active agent's name+description into the
|
||||
* system prompt so it knows which subagents it can delegate to. That listing is
|
||||
* re-sent on EVERY turn, whether or not a delegation happens — so with many
|
||||
* installed agents it is a large always-loaded cost (on a heavily-plugged machine
|
||||
* the dominant single always-loaded source).
|
||||
*
|
||||
* Detection:
|
||||
* CA-AGT-NNN per-agent description over the soft bloat cap (low, advisory)
|
||||
* CA-AGT-NNN aggregate agent-listing estimate exceeds the listing budget (low)
|
||||
*
|
||||
* Per-agent advisories are emitted FIRST (mirroring SKL 001→002). They are a
|
||||
* soft bloat heuristic (the 500-char threshold TOK pattern F uses for SKILL.md
|
||||
* descriptions), NOT a truncation finding — agents have no verified per-
|
||||
* description cap, so nothing is dropped; the description is simply re-sent in
|
||||
* full every turn.
|
||||
*
|
||||
* INTELLECTUAL-HONESTY CONTRACT (the reason this is `low`, not a hard finding):
|
||||
* unlike the skill listing, the agent-listing mechanism is NOT documented (agents
|
||||
* are absent from Claude Code's published context breakdown). So the finding is an
|
||||
* INFERRED, UPPER-BOUND ESTIMATE, the budget is a config-audit heuristic (no
|
||||
* documented agent allotment) anchored on a conservative 200k window, and the
|
||||
* evidence discloses all of that. The cap, budget, and enumerate-and-measure step
|
||||
* live in `lib/agent-listing-budget.mjs` — AGT only constructs the finding.
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import {
|
||||
AGGREGATE_BUDGET_TOKENS,
|
||||
BUDGET_CALIBRATION_NOTE,
|
||||
PER_AGENT_DESC_SOFT_CAP,
|
||||
measureActiveAgentListing,
|
||||
} from './lib/agent-listing-budget.mjs';
|
||||
|
||||
const SCANNER = 'AGT';
|
||||
|
||||
/**
|
||||
* Main scanner entry point.
|
||||
*
|
||||
* @param {string} _targetPath unused (agent listing is HOME-scoped)
|
||||
* @param {object} _discovery unused (ignores project discovery)
|
||||
*/
|
||||
export async function scan(_targetPath, _discovery) {
|
||||
const start = Date.now();
|
||||
const findings = [];
|
||||
|
||||
const { agents, aggregate } = await measureActiveAgentListing();
|
||||
|
||||
// Per-agent advisory (emitted FIRST so the common "long agent + aggregate"
|
||||
// case reads 001=per-agent, 002=aggregate, mirroring SKL). This is a soft
|
||||
// heuristic, NOT a truncation finding — agents have no verified per-description
|
||||
// cap, so the framing is "this is large and re-sent every turn", never "Claude
|
||||
// Code drops the tail".
|
||||
for (const agent of agents) {
|
||||
if (agent.descLength <= PER_AGENT_DESC_SOFT_CAP) continue;
|
||||
|
||||
const sourceLabel = agent.source === 'plugin'
|
||||
? `plugin:${agent.pluginName}`
|
||||
: 'user';
|
||||
|
||||
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:
|
||||
`Agent "${agent.name}" (${sourceLabel}) has a description of ${agent.descLength} ` +
|
||||
`characters (>${PER_AGENT_DESC_SOFT_CAP}). Claude Code injects every active agent's ` +
|
||||
'name+description into the agent listing on every turn so it knows which subagents it ' +
|
||||
'can delegate to, so every character of this description re-enters context each turn ' +
|
||||
'whether or not you delegate. Unlike the skill listing there is no verified ' +
|
||||
'per-description cap, so nothing is dropped — this is a bloat advisory, not a ' +
|
||||
'hard-cap finding.',
|
||||
file: agent.path,
|
||||
evidence:
|
||||
`description_chars=${agent.descLength}; soft_cap=${PER_AGENT_DESC_SOFT_CAP} ` +
|
||||
`(heuristic, same bloat threshold TOK pattern F uses for SKILL.md descriptions; agents ` +
|
||||
`have NO verified per-description cap); agent="${agent.name}"; source=${sourceLabel}`,
|
||||
recommendation:
|
||||
'Trim the description toward its trigger phrases / "when to use this agent" cues and move ' +
|
||||
'long examples into the agent body, or disable the plugin if you never delegate to this ' +
|
||||
'agent (the whole agent block then leaves the always-loaded listing).',
|
||||
category: 'token-efficiency',
|
||||
}));
|
||||
}
|
||||
|
||||
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:
|
||||
`The ${aggregate.scanned} active agents carry about ${aggregate.aggregateTokens} tokens of ` +
|
||||
'name+description text that Claude Code injects every turn so it knows which subagents it can ' +
|
||||
`delegate to — above the ${AGGREGATE_BUDGET_TOKENS}-token budget this scanner anchors on a 200k ` +
|
||||
'context window. Every one of those tokens is re-sent on every turn whether or not you delegate. ' +
|
||||
'Note: unlike the skill listing, the agent-listing always-loaded mechanism is inferred (not ' +
|
||||
'documented), so this is an upper-bound estimate (see evidence).',
|
||||
evidence:
|
||||
`active_agents_scanned=${aggregate.scanned}; description_chars=${aggregate.aggregateChars}; ` +
|
||||
`description_tokens~${aggregate.aggregateTokens}; budget@200k=${AGGREGATE_BUDGET_TOKENS} tok; ` +
|
||||
`over_by~${aggregate.overBy} tok - ${BUDGET_CALIBRATION_NOTE}`,
|
||||
recommendation:
|
||||
'Shrink the always-loaded agent listing: disable plugins whose agents you do not use (the whole ' +
|
||||
'agent block leaves the listing), remove dead user agents from ~/.claude/agents/, and trim long ' +
|
||||
'agent descriptions toward their trigger phrases.',
|
||||
category: 'token-efficiency',
|
||||
}));
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, aggregate.scanned, Date.now() - start);
|
||||
}
|
||||
|
|
@ -15,9 +15,12 @@
|
|||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { findImports } from './lib/yaml-parser.mjs';
|
||||
|
||||
const SCANNER = 'CPS';
|
||||
|
||||
|
|
@ -27,7 +30,28 @@ const SCANNER = 'CPS';
|
|||
// hits per turn, not to chase every inline date in a long backlog file.
|
||||
const CACHED_PREFIX_LINES = 150;
|
||||
|
||||
// Volatile-pattern set (extends token-hotspots.mjs Pattern A).
|
||||
// CC-provided substitution variables that resolve to a stable per-install or
|
||||
// per-project path (e.g. "${CLAUDE_PLUGIN_ROOT}/hooks/x.mjs"). CC expands them
|
||||
// to the same value every turn, so they never break the prompt cache — unlike a
|
||||
// runtime ${TIMESTAMP}. Excluded from the ${VAR} volatile flag (M-BUG-7).
|
||||
const STABLE_CC_VARS = new Set(['CLAUDE_PLUGIN_ROOT', 'CLAUDE_PROJECT_DIR']);
|
||||
|
||||
// Matches every ${VAR} occurrence on a line so a line carrying only stable CC
|
||||
// vars is not mistaken for a runtime cache-buster.
|
||||
const VAR_RX = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
||||
|
||||
/** True when a line contains at least one non-CC-stable ${VAR} substitution. */
|
||||
function hasVolatileVar(line) {
|
||||
VAR_RX.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = VAR_RX.exec(line)) !== null) {
|
||||
if (!STABLE_CC_VARS.has(m[1])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Volatile-pattern set (extends token-hotspots.mjs Pattern A). The ${VAR} entry
|
||||
// is `varAware` — flagged via hasVolatileVar() so CC-stable vars are excluded.
|
||||
const VOLATILE_PATTERNS = [
|
||||
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
|
||||
{ rx: /\{uuid\}/i, label: '{uuid} placeholder' },
|
||||
|
|
@ -38,9 +62,25 @@ const VOLATILE_PATTERNS = [
|
|||
{ rx: /^\s*\[\d{4}-\d{2}-\d{2}/, label: 'dated log line [YYYY-MM-DD ...]' },
|
||||
// v5 N3 extensions:
|
||||
{ rx: /^\s*!/, label: 'shell-exec line (! prefix)' },
|
||||
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution' },
|
||||
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution', varAware: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve an @import path relative to the file that declares it.
|
||||
* Mirrors import-resolver.mjs / token-hotspots.mjs path semantics.
|
||||
* @param {string} importPath
|
||||
* @param {string} containingFile
|
||||
* @returns {string} absolute resolved path
|
||||
*/
|
||||
function resolveImportPath(importPath, containingFile) {
|
||||
if (importPath.startsWith('~')) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || tmpdir();
|
||||
return resolve(importPath.replace(/^~/, home));
|
||||
}
|
||||
if (importPath.startsWith('/')) return importPath;
|
||||
return resolve(dirname(containingFile), importPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan content for volatile lines within the cached prefix window.
|
||||
* Returns array of {line, label, snippet}.
|
||||
|
|
@ -49,18 +89,35 @@ function findVolatileLines(content) {
|
|||
const out = [];
|
||||
if (!content) return out;
|
||||
const lines = content.split('\n').slice(0, CACHED_PREFIX_LINES);
|
||||
let inFence = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
for (const { rx, label } of VOLATILE_PATTERNS) {
|
||||
if (rx.test(lines[i])) {
|
||||
const line = lines[i];
|
||||
// Fenced code blocks (``` or ~~~) hold illustrative, byte-stable literal
|
||||
// text — a ${VAR} or timestamp shown inside one is documentation, not a
|
||||
// runtime cache-buster — so the fence delimiters and their content are
|
||||
// skipped (M-BUG-7).
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
// Strip `inline code` spans before pattern-testing: a {date} or ${VAR}
|
||||
// shown inside backticks is literal documentation text, byte-stable, not a
|
||||
// runtime cache-buster (M-BUG-7). The original line is still reported as the
|
||||
// snippet so context is preserved.
|
||||
const probe = line.replace(/`[^`]*`/g, '');
|
||||
for (const { rx, label, varAware } of VOLATILE_PATTERNS) {
|
||||
// The ${VAR} pattern flags only non-CC-stable substitutions; every other
|
||||
// pattern keeps its plain line test.
|
||||
if (varAware ? !hasVolatileVar(probe) : !rx.test(probe)) continue;
|
||||
out.push({
|
||||
line: i + 1,
|
||||
label,
|
||||
snippet: lines[i].length > 120 ? lines[i].slice(0, 117) + '...' : lines[i],
|
||||
snippet: line.length > 120 ? line.slice(0, 117) + '...' : line,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -75,25 +132,32 @@ export async function scan(targetPath, discovery) {
|
|||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
// Files already scanned in-file below — an @import resolving to one of these
|
||||
// is reported by its own iteration, not duplicated as an import finding.
|
||||
const discoveredClaudeMd = new Set(
|
||||
discovery.files.filter(f => f.type === 'claude-md').map(f => f.absPath));
|
||||
// @imported files reported once, even when several CLAUDE.md files import them.
|
||||
const reportedImports = new Set();
|
||||
|
||||
for (const f of discovery.files) {
|
||||
if (f.type !== 'claude-md') continue;
|
||||
filesScanned++;
|
||||
const content = await readTextFile(f.absPath);
|
||||
if (!content) continue;
|
||||
const volatile = findVolatileLines(content);
|
||||
if (volatile.length === 0) continue;
|
||||
|
||||
// --- In-file volatility (unchanged behavior) ---
|
||||
const volatile = findVolatileLines(content);
|
||||
// Skip volatility that's already covered by TOK Pattern A (lines 1–30) —
|
||||
// CPS' value is in the 31–150 range. Pattern A handles 1–30.
|
||||
const beyondTopThirty = volatile.filter(v => v.line > 30);
|
||||
if (beyondTopThirty.length === 0) continue;
|
||||
|
||||
if (beyondTopThirty.length > 0) {
|
||||
const evidence =
|
||||
beyondTopThirty.slice(0, 5)
|
||||
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
|
||||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'volatile-in-prefix',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Volatile content inside cached prefix breaks reuse',
|
||||
description:
|
||||
|
|
@ -111,5 +175,50 @@ export async function scan(targetPath, discovery) {
|
|||
}));
|
||||
}
|
||||
|
||||
// --- v5.10 B6: volatility inside @imported files ---
|
||||
// @import-ed content is inlined into the cached prefix at the import site.
|
||||
// TOK Pattern A and the in-file scan above never look past the importing
|
||||
// file, so volatility in an imported file is otherwise invisible. We scan
|
||||
// direct imports only (one hop); IMP owns deep-chain analysis. The whole
|
||||
// imported-file prefix counts (no lines-1–30 skip — that exclusion is
|
||||
// root-file-specific to avoid Pattern A overlap, which does not reach here).
|
||||
for (const imp of findImports(content)) {
|
||||
if (imp.line > CACHED_PREFIX_LINES) continue; // import site outside prefix
|
||||
const resolved = resolveImportPath(imp.path, f.absPath);
|
||||
if (discoveredClaudeMd.has(resolved)) continue; // scanned in its own iteration
|
||||
if (reportedImports.has(resolved)) continue;
|
||||
reportedImports.add(resolved);
|
||||
const importedContent = await readTextFile(resolved);
|
||||
if (!importedContent) continue;
|
||||
const importedVolatile = findVolatileLines(importedContent);
|
||||
if (importedVolatile.length === 0) continue;
|
||||
|
||||
const importEvidence =
|
||||
`imported by ${f.relPath || f.absPath} (@${imp.path} at line ${imp.line}); ` +
|
||||
importedVolatile.slice(0, 5)
|
||||
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
|
||||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'volatile-in-import',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Volatile content in @imported file breaks cached prefix',
|
||||
description:
|
||||
`@${imp.path} (imported by ${f.relPath || f.absPath} at line ${imp.line}) contains ` +
|
||||
`${importedVolatile.length} volatile entr${importedVolatile.length === 1 ? 'y' : 'ies'} ` +
|
||||
`within its first ${CACHED_PREFIX_LINES} lines. @import-ed content is inlined into the ` +
|
||||
'prompt-cache prefix, so volatility there forces a fresh cache write every turn — even ' +
|
||||
'when the importing CLAUDE.md is itself byte-stable.',
|
||||
file: resolved,
|
||||
evidence: importEvidence,
|
||||
recommendation:
|
||||
'Move volatile content (timestamps, !shell-exec, ${VAR} substitutions, dated logs) out ' +
|
||||
'of the @imported file, or import it below the cached-prefix window. Keep imported config ' +
|
||||
'byte-stable so the importing file\'s cache survives.',
|
||||
category: 'token-efficiency',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
|
|
|||
126
scanners/campaign-cli.mjs
Normal file
126
scanners/campaign-cli.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* campaign-cli — read-only reporter for the durable machine-wide campaign ledger
|
||||
* (v5.7 Fase 2, Block 3b).
|
||||
*
|
||||
* Mirrors the knowledge-refresh-cli precedent: it is the DETERMINISTIC, READ-ONLY half
|
||||
* of the hybrid motor. It loads the campaign ledger (the durable file that sits ABOVE
|
||||
* individual config-audit sessions), validates it, and emits the repo list + a
|
||||
* machine-wide roll-up as JSON. It NEVER writes the ledger — initialization and every
|
||||
* status transition belong to the command layer (Block 3c `/config-audit campaign`),
|
||||
* which calls the lib's pure transforms + saveLedger only on explicit, human-approved
|
||||
* action. A missing ledger file is reported gracefully (initialized:false), NEVER created.
|
||||
*
|
||||
* Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only loads
|
||||
* scanner modules), so the scanner count is unchanged and the snapshot suite stays
|
||||
* byte-stable.
|
||||
*
|
||||
* Usage:
|
||||
* node campaign-cli.mjs [--ledger-file <path>] [--output-file <path>]
|
||||
*
|
||||
* Exit codes: 0 = initialized & valid, 1 = not initialized yet (advisory), 3 = error.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { findArgError } from './lib/cli-args.mjs';
|
||||
import {
|
||||
loadLedger,
|
||||
validateLedger,
|
||||
rollUp,
|
||||
buildBacklog,
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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];
|
||||
}
|
||||
|
||||
const ledgerPath = resolve(ledgerFile || defaultLedgerPath());
|
||||
|
||||
let ledger;
|
||||
try {
|
||||
// loadLedger returns null on ENOENT (graceful first run) and throws on parse error.
|
||||
ledger = await loadLedger(ledgerPath);
|
||||
} catch (err) {
|
||||
fail(`could not read ledger at ${ledgerPath}: ${err.message}`);
|
||||
}
|
||||
|
||||
let payload;
|
||||
let exitCode;
|
||||
|
||||
if (ledger === null) {
|
||||
// Graceful first run — the ledger does not exist yet. We DO NOT create it; that is
|
||||
// the command layer's job (Block 3c), on explicit human-approved action.
|
||||
payload = {
|
||||
status: 'ok',
|
||||
initialized: false,
|
||||
ledgerPath,
|
||||
schemaVersion: null,
|
||||
createdDate: null,
|
||||
updatedDate: null,
|
||||
repos: [],
|
||||
rollUp: rollUp({ repos: [] }),
|
||||
backlog: buildBacklog({ repos: [] }),
|
||||
};
|
||||
exitCode = 1; // advisory: there is no campaign to report yet
|
||||
} else {
|
||||
const { valid, errors } = validateLedger(ledger);
|
||||
if (!valid) {
|
||||
fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`);
|
||||
}
|
||||
payload = {
|
||||
status: 'ok',
|
||||
initialized: true,
|
||||
ledgerPath,
|
||||
schemaVersion: ledger.schemaVersion,
|
||||
createdDate: ledger.createdDate,
|
||||
updatedDate: ledger.updatedDate,
|
||||
repos: ledger.repos,
|
||||
rollUp: rollUp(ledger),
|
||||
backlog: buildBacklog(ledger),
|
||||
};
|
||||
exitCode = 0;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
|
||||
process.exitCode = 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;
|
||||
});
|
||||
}
|
||||
222
scanners/campaign-export-cli.mjs
Normal file
222
scanners/campaign-export-cli.mjs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* campaign-export-cli — export a tracked repo's action plan into that repo's own `docs/`
|
||||
* (v5.7 Fase 2, Block 4c).
|
||||
*
|
||||
* Block 4b built the cross-repo prioritized backlog; this is the "plan export" half of Block 4c.
|
||||
* Given a repo tracked in the campaign ledger, it resolves the repo's linked config-audit
|
||||
* session, reads that session's `action-plan.md`, and assembles (via the pure
|
||||
* `campaign-export` lib) a `docs/config-audit-plan-<sessionId>.md` document carrying a
|
||||
* provenance header + the verbatim plan ("planer følger arbeidsstedet").
|
||||
*
|
||||
* Read-only by DEFAULT (a dry-run preview that returns the assembled `document` + `targetPath`
|
||||
* so the command can show the user what will be written). The actual write happens ONLY under
|
||||
* the opt-in `--write` flag — which the `/config-audit campaign` command invokes solely after
|
||||
* explicit human approval (Verifiseringsplikt — nothing auto-written). Writing the file
|
||||
* faithfully (a byte-exact copy of the assembled document) is the CLI's job, not the LLM's, so
|
||||
* a 200-line plan is never re-typed and cannot drift.
|
||||
*
|
||||
* Execution is NOT here: Block 4c reuses the existing `/config-audit implement` (backup +
|
||||
* apply + verify) + `/config-audit rollback`. This CLI only exports the durable record.
|
||||
*
|
||||
* Naming: `-cli` suffix → NOT an orchestrated scanner, so the scanner count is unchanged and
|
||||
* the snapshot suite stays byte-stable.
|
||||
*
|
||||
* Usage:
|
||||
* node campaign-export-cli.mjs --repo <path> [--write]
|
||||
* [--ledger-file <p>] [--sessions-dir <p>] [--reference-date <YYYY-MM-DD>] [--output-file <p>]
|
||||
*
|
||||
* Exit codes: 0 = exportable (preview ready, or written under --write),
|
||||
* 1 = advisory: repo tracked but not exportable yet (no linked session / no plan),
|
||||
* 3 = error (missing --repo, untracked repo, no/corrupt ledger, unreadable plan).
|
||||
*/
|
||||
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import {
|
||||
loadLedger,
|
||||
validateLedger,
|
||||
defaultLedgerPath,
|
||||
} from './lib/campaign-ledger.mjs';
|
||||
import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.mjs';
|
||||
import { evaluateWriteTargets } from './lib/write-scope.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Flag surface. This was the last hand-rolled parser of the fifteen CLIs the
|
||||
* command layer calls, and the only one that misclassified its own argv: the
|
||||
* chain below guards each value flag with `argv[i + 1] !== undefined`, so a
|
||||
* trailing `--repo` fell past every branch to the `startsWith('--')` catch-all
|
||||
* and was reported as an **unknown flag** — about the one flag this CLI
|
||||
* requires. The shared gate runs first and names the real fault; valid argv
|
||||
* reaches the loop below byte-for-byte unchanged.
|
||||
*/
|
||||
const ARG_SPEC = {
|
||||
boolean: ['--write', '--approve-scope'],
|
||||
value: ['--repo', '--ledger-file', '--sessions-dir', '--reference-date', '--output-file', '--session-root'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Usage error. Throws rather than calling process.exit(): exit() discards
|
||||
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
|
||||
* `Error: ` text and sets the same exit code 3, so callers see no difference.
|
||||
*/
|
||||
class CliUsageError extends Error {}
|
||||
|
||||
function fail(message) {
|
||||
throw new CliUsageError(message);
|
||||
}
|
||||
|
||||
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
|
||||
function defaultSessionsDir() {
|
||||
return join(homedir(), '.claude', 'config-audit', 'sessions');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false, approveScope: false, sessionRoot: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--repo' && argv[i + 1] !== undefined) flags.repo = argv[++i];
|
||||
else if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i];
|
||||
else if (a === '--sessions-dir' && argv[i + 1] !== undefined) flags.sessionsDir = 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 === '--write') flags.write = true;
|
||||
else if (a === '--approve-scope') flags.approveScope = true;
|
||||
else if (a === '--session-root' && argv[i + 1] !== undefined) flags.sessionRoot = argv[++i];
|
||||
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
|
||||
else fail(`unexpected argument "${a}"`);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
async function emit(payload, outputFile, exitCode) {
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
|
||||
const flags = parseArgs(args);
|
||||
if (!flags.repo) fail('--repo <path> is required');
|
||||
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||
|
||||
const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath());
|
||||
const sessionsDir = resolve(flags.sessionsDir || defaultSessionsDir());
|
||||
const repoPath = resolve(flags.repo);
|
||||
// The clock is read here ONLY — passed to the pure lib as the injected `now`.
|
||||
const now = flags.referenceDate || new Date().toISOString().slice(0, 10);
|
||||
|
||||
let ledger;
|
||||
try {
|
||||
ledger = await loadLedger(ledgerPath);
|
||||
} catch (err) {
|
||||
fail(`could not read ledger at ${ledgerPath}: ${err.message}`);
|
||||
}
|
||||
if (ledger === null) fail(`no campaign ledger at ${ledgerPath} — run "/config-audit campaign init" first`);
|
||||
|
||||
const { valid, errors } = validateLedger(ledger);
|
||||
if (!valid) fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`);
|
||||
|
||||
const repo = ledger.repos.find((r) => r.path === repoPath);
|
||||
if (!repo) fail(`repo "${repoPath}" is not tracked in the campaign — add it first`);
|
||||
|
||||
const repoInfo = { path: repo.path, name: repo.name, status: repo.status, sessionId: repo.sessionId ?? null };
|
||||
|
||||
// Gate 1: the repo must have a linked session (set via `set-status … --session <id>`).
|
||||
if (typeof repo.sessionId !== 'string' || repo.sessionId.trim() === '') {
|
||||
return emit(
|
||||
{ status: 'ok', action: 'export', repo: repoInfo, exportable: false, problems: ['no-session-linked'],
|
||||
written: false, targetPath: null, document: null },
|
||||
flags.outputFile,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Gate 2: that session must carry an action-plan.md (i.e. `/config-audit plan` has run).
|
||||
const sourcePlanPath = join(sessionsDir, repo.sessionId, 'action-plan.md');
|
||||
let planMarkdown;
|
||||
try {
|
||||
planMarkdown = await readFile(sourcePlanPath, 'utf-8');
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') {
|
||||
return emit(
|
||||
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
|
||||
exportable: false, problems: ['no-action-plan'], written: false, targetPath: null, document: null },
|
||||
flags.outputFile,
|
||||
1,
|
||||
);
|
||||
}
|
||||
fail(`could not read action plan at ${sourcePlanPath}: ${err.message}`);
|
||||
}
|
||||
|
||||
const targetPath = planExportPath(repo.path, repo.sessionId);
|
||||
const document = buildPlanExportDocument({
|
||||
repoName: repo.name,
|
||||
repoPath: repo.path,
|
||||
sessionId: repo.sessionId,
|
||||
planMarkdown,
|
||||
now,
|
||||
});
|
||||
|
||||
// Q1 — the scope gate, in code rather than in commands/campaign.md's prose.
|
||||
//
|
||||
// `--repo` here names the repo being EXPORTED TO, which is the write target's
|
||||
// repo, not the session's. The session root is where the operator stands, so
|
||||
// it comes from `--session-root` (default cwd) — reading it off `--repo`
|
||||
// would make every export look "in-repo" and silence the gate by
|
||||
// construction (#63).
|
||||
//
|
||||
// Export into another project is `cross-repo`, whose gate is `disclose`, NOT
|
||||
// `require-ok`: campaign export is cross-repo BY DESIGN, and tightening it
|
||||
// into a refusal breaks the feature. So the disclosure always rides in the
|
||||
// payload, and only a `require-ok` class (machine-wide config, or a path in
|
||||
// no project at all) actually withholds the write.
|
||||
const scope = evaluateWriteTargets([targetPath], resolve(flags.sessionRoot ?? process.cwd()));
|
||||
|
||||
if (flags.write && scope.requiresApproval && !flags.approveScope) {
|
||||
return emit(
|
||||
{ status: 'refused', action: 'export', reason: 'scope-gate', repo: repoInfo,
|
||||
sessionId: repo.sessionId, sourcePlanPath, exportable: true, problems: [],
|
||||
written: false, targetPath, gate: scope.gate, requiresApproval: true,
|
||||
disclosures: scope.disclosures },
|
||||
flags.outputFile,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
let written = false;
|
||||
if (flags.write) {
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, document, 'utf-8');
|
||||
written = true;
|
||||
}
|
||||
|
||||
return emit(
|
||||
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
|
||||
exportable: true, problems: [], written, targetPath, document,
|
||||
gate: scope.gate, requiresApproval: scope.requiresApproval,
|
||||
disclosures: scope.disclosures },
|
||||
flags.outputFile,
|
||||
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;
|
||||
});
|
||||
}
|
||||
297
scanners/campaign-write-cli.mjs
Normal file
297
scanners/campaign-write-cli.mjs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* campaign-write-cli — the human-approved WRITE half of the durable campaign ledger
|
||||
* (v5.7 Fase 2, Block 3c).
|
||||
*
|
||||
* Sibling of the read-only `campaign-cli`: where that one only reports, this one mutates.
|
||||
* Every mutation is routed through the pure, invariant-enforcing lib transforms
|
||||
* (`createLedger`/`addRepo`/`setRepoStatus`) + `saveLedger` — so path normalization/dedup,
|
||||
* idempotent add, the status-lifecycle guard, and the `updatedDate` bump are never
|
||||
* re-implemented by hand. The `/config-audit campaign` command is a thin opus orchestrator:
|
||||
* it reports (via campaign-cli), proposes a change, and only on explicit human approval
|
||||
* invokes a single subcommand here (Verifiseringsplikt). It NEVER auto-writes.
|
||||
*
|
||||
* Determinism mirrors the lib + the knowledge-refresh CLI: `--reference-date` is the only
|
||||
* place the clock is read (defaulting to today), and it is passed to the transforms as the
|
||||
* injected `now`, so the persisted stamps are fully testable.
|
||||
*
|
||||
* Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only loads
|
||||
* scanner modules), so the scanner count is unchanged and the snapshot suite stays
|
||||
* byte-stable.
|
||||
*
|
||||
* Usage:
|
||||
* node campaign-write-cli.mjs init [--ledger-file <p>] [--reference-date <YYYY-MM-DD>]
|
||||
* node campaign-write-cli.mjs add <path>... [--name <n>] [--ledger-file <p>] [--reference-date <d>]
|
||||
* node campaign-write-cli.mjs set-status <path> <status>
|
||||
* [--findings '<json>'] [--session <id>]
|
||||
* [--ledger-file <p>] [--reference-date <d>]
|
||||
* node campaign-write-cli.mjs refresh-tokens [--ledger-file <p>] [--reference-date <d>]
|
||||
* (all accept [--output-file <p>] to write the result payload to a file instead of stdout)
|
||||
*
|
||||
* `refresh-tokens` is the live cross-repo token sweep (v5.9 B2b): for every tracked
|
||||
* repo it runs the manifest's always-loaded accounting (readActiveConfig → buildManifest)
|
||||
* and splits each source into the shared global layer vs the repo's per-repo delta
|
||||
* (splitManifestByOwnership). The shared layer is HOME-derived and identical across
|
||||
* repos, so it is captured ONCE (from the first successful read) and stored at the
|
||||
* ledger root; each repo gets only its delta. This is the IO half of the machine-wide
|
||||
* token roll-up whose pure data model shipped in B2a.
|
||||
*
|
||||
* Exit codes: 0 = write performed (or no repos to sweep, a benign no-op), 1 = advisory
|
||||
* no-op (init when already initialized), 3 = error (unknown subcommand, bad args,
|
||||
* invalid status, untracked repo, no/corrupt ledger).
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import {
|
||||
createLedger,
|
||||
addRepo,
|
||||
setRepoStatus,
|
||||
setSharedGlobal,
|
||||
setRepoTokens,
|
||||
rollUp,
|
||||
loadLedger,
|
||||
saveLedger,
|
||||
defaultLedgerPath,
|
||||
} 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);
|
||||
}
|
||||
|
||||
/** 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];
|
||||
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 {
|
||||
return await loadLedger(path); // null on ENOENT (no ledger yet)
|
||||
} catch (err) {
|
||||
fail(`could not read ledger at ${path}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function emit(payload, outputFile, exitCode) {
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { subcommand, rest, flags } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!subcommand) fail('a subcommand is required: init | add | set-status | refresh-tokens');
|
||||
|
||||
const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath());
|
||||
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||
// The clock is read here ONLY — the transforms take this injected `now` and stay pure.
|
||||
const now = flags.referenceDate || new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (subcommand === 'init') {
|
||||
const existing = await loadOrFail(ledgerPath);
|
||||
if (existing !== null) {
|
||||
// Advisory no-op: never wipe an existing campaign.
|
||||
return emit(
|
||||
{ status: 'ok', action: 'init', written: false, alreadyInitialized: true, ledgerPath },
|
||||
flags.outputFile,
|
||||
1,
|
||||
);
|
||||
}
|
||||
const ledger = createLedger({ now });
|
||||
await saveLedger(ledgerPath, ledger);
|
||||
return emit(
|
||||
{
|
||||
status: 'ok', action: 'init', written: true, alreadyInitialized: false, ledgerPath,
|
||||
schemaVersion: ledger.schemaVersion, createdDate: ledger.createdDate, updatedDate: ledger.updatedDate,
|
||||
repos: ledger.repos, rollUp: rollUp(ledger),
|
||||
},
|
||||
flags.outputFile,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (subcommand === 'add') {
|
||||
const paths = rest;
|
||||
if (paths.length === 0) fail('add requires at least one repo path');
|
||||
const loaded = await loadOrFail(ledgerPath);
|
||||
const autoInitialized = loaded === null;
|
||||
let ledger = loaded === null ? createLedger({ now }) : loaded;
|
||||
|
||||
const added = [];
|
||||
const addedUnverified = [];
|
||||
const skipped = [];
|
||||
for (const p of paths) {
|
||||
const resolved = resolve(p);
|
||||
const present = ledger.repos.some((r) => r.path === resolved);
|
||||
// --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);
|
||||
}
|
||||
await saveLedger(ledgerPath, ledger);
|
||||
return emit(
|
||||
{
|
||||
status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath,
|
||||
added, addedUnverified, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
|
||||
},
|
||||
flags.outputFile,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (subcommand === 'set-status') {
|
||||
const [path, status] = rest;
|
||||
if (!path || !status) fail('set-status requires <path> <status>');
|
||||
const ledger = await loadOrFail(ledgerPath);
|
||||
if (ledger === null) fail(`no ledger at ${ledgerPath} — run "init" or "add" first`);
|
||||
|
||||
let findingsBySeverity;
|
||||
if (flags.findings !== null) {
|
||||
try {
|
||||
findingsBySeverity = JSON.parse(flags.findings);
|
||||
} catch (err) {
|
||||
fail(`--findings must be valid JSON: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const opts = { now };
|
||||
if (findingsBySeverity !== undefined) opts.findingsBySeverity = findingsBySeverity;
|
||||
if (flags.session !== null) opts.sessionId = flags.session;
|
||||
|
||||
let next;
|
||||
try {
|
||||
next = setRepoStatus(ledger, path, status, opts);
|
||||
} catch (err) {
|
||||
// RangeError (bad status) or Error (untracked repo) → caller error.
|
||||
fail(err.message);
|
||||
}
|
||||
await saveLedger(ledgerPath, next);
|
||||
const resolved = resolve(path);
|
||||
return emit(
|
||||
{
|
||||
status: 'ok', action: 'set-status', written: true, ledgerPath,
|
||||
repo: next.repos.find((r) => r.path === resolved), repos: next.repos, rollUp: rollUp(next),
|
||||
},
|
||||
flags.outputFile,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (subcommand === 'refresh-tokens') {
|
||||
const ledger0 = await loadOrFail(ledgerPath);
|
||||
if (ledger0 === null) fail(`no ledger at ${ledgerPath} — run "init" or "add" first`);
|
||||
|
||||
let ledger = ledger0;
|
||||
const swept = [];
|
||||
const skipped = [];
|
||||
// The shared global layer is HOME-derived and identical across every repo, so it
|
||||
// is captured ONCE (from the first repo that reads cleanly) and stored at the
|
||||
// ledger root — the structural guard against the historic shared-layer double-count.
|
||||
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 });
|
||||
const { sources } = buildManifest(activeConfig);
|
||||
split = splitManifestByOwnership(sources);
|
||||
} catch (err) {
|
||||
skipped.push({ path: repo.path, reason: err.message });
|
||||
continue;
|
||||
}
|
||||
if (sharedSummary === null) sharedSummary = split.shared;
|
||||
ledger = setRepoTokens(ledger, repo.path, split.delta, { now });
|
||||
swept.push(repo.path);
|
||||
}
|
||||
|
||||
if (sharedSummary !== null) ledger = setSharedGlobal(ledger, sharedSummary, { now });
|
||||
|
||||
const written = swept.length > 0;
|
||||
if (written) await saveLedger(ledgerPath, ledger);
|
||||
|
||||
return emit(
|
||||
{
|
||||
status: 'ok', action: 'refresh-tokens', written, ledgerPath,
|
||||
swept, skipped, sharedGlobal: ledger.sharedGlobal ?? null, rollUp: rollUp(ledger),
|
||||
},
|
||||
flags.outputFile,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
fail(`unknown subcommand "${subcommand}" — expected init | add | set-status | refresh-tokens`);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -5,16 +5,23 @@
|
|||
*/
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
|
||||
import { lineCount, truncate } from './lib/string-utils.mjs';
|
||||
import { LARGE_CONTEXT_WINDOW, LARGE_CONTEXT_SCALE, withCommas } from './lib/context-window.mjs';
|
||||
import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, LARGE_CONTEXT_SCALE, scaleForWindow, withCommas } from './lib/context-window.mjs';
|
||||
import { dirname, resolve as resolvePath, sep } from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
const SCANNER = 'CML';
|
||||
const MAX_RECOMMENDED_LINES = 200;
|
||||
const MAX_ABSOLUTE_LINES = 500;
|
||||
|
||||
// Shared remediation for the char-budget finding (byte-identical across the
|
||||
// default and the B8 window-calibrated branches).
|
||||
const CHAR_BUDGET_RECOMMENDATION =
|
||||
'Split detail into @imports and .claude/rules/ files so only the relevant rules load, and keep the top of CLAUDE.md byte-stable for cache hits.';
|
||||
|
||||
// Claude Code's own startup warning ("Large CLAUDE.md will impact performance
|
||||
// (X chars > 40.0k)") fires once a CLAUDE.md passes ~40.0k chars on a
|
||||
// 200k-context model. CC 2.1.169 made that threshold scale with the model's
|
||||
|
|
@ -24,6 +31,134 @@ const MAX_ABSOLUTE_LINES = 500;
|
|||
const CLAUDE_MD_CHAR_WARN_ANCHOR = 40_000; // chars @ 200k context (CC startup warning)
|
||||
const CLAUDE_MD_CHAR_WARN_LARGE = CLAUDE_MD_CHAR_WARN_ANCHOR * LARGE_CONTEXT_SCALE; // 200,000 @ 1M
|
||||
|
||||
// ── C3: dead prose references ───────────────────────────────────────────────
|
||||
// `import-resolver` resolves @import targets; a path written in prose is not
|
||||
// checked by anything. The whole design here is the SILENCE taxonomy — a
|
||||
// precision-first check whose failure mode must be a miss, never a false alarm.
|
||||
// Each rule below was measured against 407 real CLAUDE.md files, not reasoned
|
||||
// about; the numbers live in docs/c3-deadref-fasit.local.md §2.
|
||||
const KNOWN_EXTENSIONS = /\.(?:md|mjs|js|ts|tsx|jsx|json|ya?ml|sh|py|toml|txt|html|css)$/i;
|
||||
|
||||
// How many dead references the evidence names before it summarises the rest.
|
||||
const MAX_LISTED_DEAD_REFS = 5;
|
||||
|
||||
/**
|
||||
* Inline-code spans that sit in prose, i.e. outside fenced code blocks.
|
||||
* Fenced code is illustrative — a dead path in a `bash` sample is a sample,
|
||||
* not a reference (silence class S1).
|
||||
* @param {string} content
|
||||
* @returns {Array<{text: string, line: number}>}
|
||||
*/
|
||||
export function extractInlineSpans(content) {
|
||||
const spans = [];
|
||||
const lines = String(content == null ? '' : content).split('\n');
|
||||
let inFence = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
if (/^\s*(?:```|~~~)/.test(raw)) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
|
||||
const re = /`([^`\n]+)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
const text = m[1].trim();
|
||||
if (text) spans.push({ text, line: i + 1 });
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexical half of the taxonomy: is this token even a path reference?
|
||||
* Order is load-bearing — the FIRST matching rule is the reported reason, so
|
||||
* `npm test` is silenced as `whitespace` (a command) rather than as
|
||||
* `no-separator`, and the taxonomy keeps describing what actually happened.
|
||||
*
|
||||
* @param {string} token - the text inside one backtick span
|
||||
* @returns {{rule: string|null}} rule name, or null when the token is a
|
||||
* candidate that still needs resolving against the filesystem
|
||||
*/
|
||||
export function classifyProseReference(token) {
|
||||
const t = String(token == null ? '' : token);
|
||||
|
||||
// S2 — a command invocation, not a path.
|
||||
if (/\s/.test(t)) return { rule: 'whitespace' };
|
||||
// S3 — an external resource; on-disk existence is meaningless.
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || /^(?:www\.|mailto:)/i.test(t)) return { rule: 'url' };
|
||||
// S4 — a pattern or template: resolves to many, or to nothing until expanded.
|
||||
if (/[*?[\]{}<>$]/.test(t)) return { rule: 'glob-or-placeholder' };
|
||||
// S5 — outside project scope, and machine-dependent.
|
||||
if (t.startsWith('/') || t.startsWith('~')) return { rule: 'absolute-or-home' };
|
||||
// S6 — a config key or a CLI flag.
|
||||
if (t.endsWith(':') || t.startsWith('-')) return { rule: 'key-or-flag' };
|
||||
// S7 — a bare filename in prose is a concept or a tool name, not a reference.
|
||||
// Measured: admitting bare names triples the output, and its top entries are
|
||||
// name-drops of tools that exist elsewhere on the machine.
|
||||
if (!t.includes('/')) return { rule: 'no-separator' };
|
||||
// S8 — has a separator but no unambiguous path shape: org/repo slugs, npm
|
||||
// packages, pytest node ids, prose enumerations. Also swallows S10, a path
|
||||
// carrying a trailing `:54-56` locator — a known v1 gap, and a miss rather
|
||||
// than a false alarm.
|
||||
if (!t.endsWith('/') && !KNOWN_EXTENSIONS.test(t)) return { rule: 'ambiguous-slug' };
|
||||
// S8b — a BARE folder name is a concept one level up from a bare filename,
|
||||
// and the same D-A reasoning applies. Measured on the same corpus: 183 of 699
|
||||
// fires (26 %) are single-segment directory tokens, led by `open/` (39x, a
|
||||
// remote namespace prefix) and generic names — `tests/`, `src/`, `docs/`,
|
||||
// `scripts/` — that prose almost always MENTIONS rather than references. A
|
||||
// specific path like `tools/wiki_ingest/` still qualifies.
|
||||
if (t.endsWith('/') && t.replace(/^\.\//, '').split('/').filter(Boolean).length === 1) {
|
||||
return { rule: 'single-segment-directory' };
|
||||
}
|
||||
|
||||
return { rule: null };
|
||||
}
|
||||
|
||||
/** @returns {Promise<boolean>} */
|
||||
async function pathExists(p) {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is `p` the root itself or below it? */
|
||||
function isInside(p, root) {
|
||||
return p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem half of the taxonomy. Two bases, because a nested CLAUDE.md
|
||||
* routinely writes repo-root-relative paths.
|
||||
*
|
||||
* Containment is checked against the SCAN ROOT, not the file's own directory:
|
||||
* a legitimate `../docs/x.md` inside the same repo must still resolve, while a
|
||||
* `..` chain that leaves the tree must not. Measured: without this,
|
||||
* `../../../../etc/passwd` resolved to the real /etc/passwd and silenced the
|
||||
* finding by accident. A base a `..` chain can escape is not a base.
|
||||
*
|
||||
* @param {string} token
|
||||
* @param {{fileDir: string, scanRoot: string}} bases
|
||||
* @returns {Promise<{rule: string|null}>} null means the reference is dead
|
||||
*/
|
||||
export async function resolveProseReference(token, { fileDir, scanRoot }) {
|
||||
const root = resolvePath(scanRoot);
|
||||
const ownAbs = resolvePath(fileDir, token);
|
||||
|
||||
if (!isInside(ownAbs, root)) return { rule: 'outside-scan-tree' };
|
||||
if (await pathExists(ownAbs)) return { rule: 'resolves-own-dir' };
|
||||
|
||||
const rootAbs = resolvePath(root, token);
|
||||
if (isInside(rootAbs, root) && await pathExists(rootAbs)) return { rule: 'resolves-scan-root' };
|
||||
|
||||
return { rule: null };
|
||||
}
|
||||
|
||||
/** Recommended sections for a project CLAUDE.md */
|
||||
const RECOMMENDED_SECTIONS = [
|
||||
{ pattern: /project|overview|description|what/i, label: 'Project overview' },
|
||||
|
|
@ -38,14 +173,25 @@ const RECOMMENDED_SECTIONS = [
|
|||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
export async function scan(targetPath, discovery, opts = {}) {
|
||||
const start = Date.now();
|
||||
const claudeFiles = discovery.files.filter(f => f.type === 'claude-md');
|
||||
|
||||
// B8 — calibrate the char-budget threshold to the resolved context window. The
|
||||
// default (no opts) is the conservative 200k anchor (40k chars) at full
|
||||
// severity — byte-identical to the pre-B8 finding. An unknown (advisory) window
|
||||
// keeps the anchor but downgrades the finding to info instead of a breach.
|
||||
const cw = opts.contextWindow;
|
||||
const window = (cw && typeof cw.window === 'number') ? cw.window : CONTEXT_WINDOW_ANCHOR;
|
||||
const advisory = !!(cw && cw.advisory);
|
||||
const isDefaultWindow = window === CONTEXT_WINDOW_ANCHOR && !advisory;
|
||||
const charThreshold = scaleForWindow(CLAUDE_MD_CHAR_WARN_ANCHOR, window);
|
||||
|
||||
if (claudeFiles.length === 0) {
|
||||
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.',
|
||||
|
|
@ -68,6 +214,24 @@ export async function scan(targetPath, discovery) {
|
|||
const sections = extractSections(body);
|
||||
const imports = findImports(content);
|
||||
|
||||
// A nested (subdirectory) CLAUDE.md is NOT re-injected after a context
|
||||
// compaction — only the project-root CLAUDE.md is (context-window.md). Its
|
||||
// instructions silently drop until a file in that directory is read again.
|
||||
const relDir = dirname(file.relPath);
|
||||
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.`,
|
||||
file: file.absPath,
|
||||
evidence: `${lines} lines, nested (scope=project, dir="${relDir}")`,
|
||||
recommendation: 'If these instructions must always apply, move the must-hold parts to the project-root CLAUDE.md (re-injected after compaction). Keep nested CLAUDE.md for guidance only needed when working in that directory.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Length checks ---
|
||||
// Raw line count is no longer an absolute adherence threshold: CC 2.1.169
|
||||
// scales the "too long" warning by context window, and cache-prefix
|
||||
|
|
@ -76,6 +240,7 @@ export async function scan(targetPath, discovery) {
|
|||
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).`,
|
||||
|
|
@ -87,6 +252,7 @@ export async function scan(targetPath, discovery) {
|
|||
} 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.`,
|
||||
|
|
@ -104,23 +270,44 @@ export async function scan(targetPath, discovery) {
|
|||
// this budget (short lines), or short by lines yet over it (long lines), so
|
||||
// this is complementary to the line-count checks above.
|
||||
const chars = content.length;
|
||||
if (chars > CLAUDE_MD_CHAR_WARN_ANCHOR) {
|
||||
if (chars > charThreshold) {
|
||||
if (isDefaultWindow) {
|
||||
// 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.`,
|
||||
file: file.absPath,
|
||||
evidence: `${withCommas(chars)} chars > 40.0k (200k-context anchor; ~${withCommas(CLAUDE_MD_CHAR_WARN_LARGE)} at ${withCommas(LARGE_CONTEXT_WINDOW)} context). This is an estimate, not measured telemetry.`,
|
||||
recommendation: 'Split detail into @imports and .claude/rules/ files so only the relevant rules load, and keep the top of CLAUDE.md byte-stable for cache hits.',
|
||||
recommendation: CHAR_BUDGET_RECOMMENDATION,
|
||||
autoFixable: false,
|
||||
}));
|
||||
} else {
|
||||
// B8 — window-calibrated. Advisory (unknown window) downgrades to info.
|
||||
const winLabel = withCommas(window);
|
||||
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.` +
|
||||
(advisory ? ' Your context window is unknown, so this anchors on the conservative 200k window — advisory.' : ''),
|
||||
file: file.absPath,
|
||||
evidence: `${withCommas(chars)} chars > ${threshLabel} (calibrated to a ${winLabel}-token context window). This is an estimate, not measured telemetry.`,
|
||||
recommendation: CHAR_BUDGET_RECOMMENDATION,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Empty file ---
|
||||
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.`,
|
||||
|
|
@ -146,6 +333,7 @@ export async function scan(targetPath, discovery) {
|
|||
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(', ')}`,
|
||||
|
|
@ -161,6 +349,7 @@ export async function scan(targetPath, discovery) {
|
|||
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.`,
|
||||
|
|
@ -177,6 +366,7 @@ export async function scan(targetPath, discovery) {
|
|||
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.`,
|
||||
|
|
@ -194,6 +384,7 @@ export async function scan(targetPath, discovery) {
|
|||
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.`,
|
||||
|
|
@ -215,6 +406,7 @@ export async function scan(targetPath, discovery) {
|
|||
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.`,
|
||||
|
|
@ -230,6 +422,7 @@ export async function scan(targetPath, discovery) {
|
|||
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).`,
|
||||
|
|
@ -237,6 +430,40 @@ export async function scan(targetPath, discovery) {
|
|||
evidence: truncate(todos[0].trim(), 80),
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Dead prose references (C3) ---
|
||||
// One finding per FILE, matching the idiom of the two checks above: a
|
||||
// machine-wide scan measured 699 dead references across 128 files, and
|
||||
// per-token emission would bury the file that has ten of them.
|
||||
const deadRefs = [];
|
||||
for (const span of extractInlineSpans(content)) {
|
||||
if (classifyProseReference(span.text).rule !== null) continue;
|
||||
const { rule } = await resolveProseReference(span.text, {
|
||||
fileDir: dirname(file.absPath),
|
||||
scanRoot: targetPath,
|
||||
});
|
||||
if (rule === null) deadRefs.push(span);
|
||||
}
|
||||
|
||||
if (deadRefs.length > 0) {
|
||||
const listed = deadRefs
|
||||
.slice(0, MAX_LISTED_DEAD_REFS)
|
||||
.map(r => `${r.text} (line ${r.line})`)
|
||||
.join(', ');
|
||||
const rest = deadRefs.length - Math.min(deadRefs.length, MAX_LISTED_DEAD_REFS);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'dead-prose-reference',
|
||||
severity: SEVERITY.low,
|
||||
title: 'CLAUDE.md points at files that are not there',
|
||||
description: `${file.relPath} has ${deadRefs.length} backtick-quoted path reference(s) in prose that resolve to nothing — neither next to the file nor from the scan root. Anyone following them, human or Claude, finds nothing.`,
|
||||
file: file.absPath,
|
||||
line: deadRefs[0].line,
|
||||
evidence: `${listed}${rest > 0 ? `, +${rest} more` : ''}`,
|
||||
recommendation: 'Point each reference at where the file actually lives, or drop it. Only unambiguous relative paths are checked — URLs, globs, absolute paths and bare filenames are left alone.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export async function scan(_targetPath, _discovery) {
|
|||
];
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skill-user-vs-plugin',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Skill name "${name}" collides between user-level and plugin sources`,
|
||||
description:
|
||||
|
|
@ -97,6 +98,7 @@ export async function scan(_targetPath, _discovery) {
|
|||
const pluginNames = pluginSkills.map(s => s.pluginName);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skill-multi-plugin',
|
||||
severity: SEVERITY.low,
|
||||
title: `Skill name "${name}" used by multiple plugins`,
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* Finding IDs: CA-CNF-NNN
|
||||
*/
|
||||
|
||||
import { sep } from 'node:path';
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
|
|
@ -17,6 +18,22 @@ const SCANNER = 'CNF';
|
|||
// Keys checked separately or not meaningful to compare
|
||||
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
|
||||
|
||||
// Files under `.claude/plugins/` are shipped by installed plugins — the plugin's
|
||||
// own settings.json/hooks.json plus bundled test fixtures and examples. They are
|
||||
// not the user's authored cascade and a "conflict" between them is not something
|
||||
// the user can resolve, so they must be excluded from cross-scope conflict
|
||||
// analysis. (Other scanners still need active plugin config, so this exclusion is
|
||||
// CNF-local, not a discovery-level skip. M-BUG-2.)
|
||||
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||
|
||||
/**
|
||||
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
|
||||
* @returns {boolean} true if the file is shipped by an installed plugin
|
||||
*/
|
||||
function isPluginBundled(file) {
|
||||
return file.absPath.includes(PLUGIN_TREE_MARKER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an object's top-level keys into a simple key→value map.
|
||||
* Only first level — we compare top-level settings, not nested.
|
||||
|
|
@ -63,10 +80,10 @@ export async function scan(targetPath, discovery) {
|
|||
const start = Date.now();
|
||||
const findings = [];
|
||||
|
||||
// Collect settings files
|
||||
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
|
||||
// Collect hooks files
|
||||
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
|
||||
// Collect settings files (excluding plugin-bundled — see PLUGIN_TREE_MARKER)
|
||||
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json' && !isPluginBundled(f));
|
||||
// Collect hooks files (excluding plugin-bundled)
|
||||
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json' && !isPluginBundled(f));
|
||||
|
||||
const totalFiles = settingsFiles.length + hooksFiles.length;
|
||||
|
||||
|
|
@ -112,6 +129,7 @@ export async function scan(targetPath, discovery) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'settings-key-conflict',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Settings key conflict: "${key}"`,
|
||||
description: `Key "${key}" has different values across scopes. ${details}`,
|
||||
|
|
@ -143,6 +161,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (rulesIntersect(allowRule, denyRule)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'permission-allow-deny',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Permission allow/deny conflict',
|
||||
description: `"${allowRule}" is allowed in ${a.scope} (${a.file}) but denied in ${b.scope} (${b.file}).`,
|
||||
|
|
@ -160,6 +179,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (rulesIntersect(allowRule, denyRule)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'permission-allow-deny',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Permission allow/deny conflict',
|
||||
description: `"${allowRule}" is allowed in ${b.scope} (${b.file}) but denied in ${a.scope} (${a.file}).`,
|
||||
|
|
@ -210,6 +230,7 @@ export async function scan(targetPath, discovery) {
|
|||
const [event, matcher] = key.split(':');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'duplicate-hook',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Duplicate hook definition',
|
||||
description: `Hook "${event}" with matcher "${matcher}" is defined in ${uniqueSources.length} sources.`,
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deny-and-allow',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Tool listed in both permissions.deny and permissions.allow',
|
||||
description:
|
||||
|
|
@ -134,6 +135,7 @@ export async function scan(targetPath, discovery) {
|
|||
const evidence = `allow: ${ineffective.slice(0, 5).map(e => `"${e}"`).join(', ')}`;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'ineffective-allow-wildcard',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Ineffective allow wildcard — Claude Code ignores this rule',
|
||||
description:
|
||||
|
|
@ -160,6 +162,7 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'forbidden-param-deny',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Permission rule silently ignored — deny/ask uses a forbidden param key',
|
||||
description:
|
||||
|
|
@ -184,6 +187,7 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'forbidden-param-allow',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Permission rule silently ignored — allow uses a forbidden param key (dead config)',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -5,16 +5,22 @@
|
|||
* Compare current configuration against a saved baseline.
|
||||
* Usage:
|
||||
* node drift-cli.mjs <path> --save [--name my-baseline]
|
||||
* node drift-cli.mjs <path> [--baseline my-baseline] [--json]
|
||||
* node drift-cli.mjs <path> [--baseline my-baseline] [--json] [--output-file path]
|
||||
* node drift-cli.mjs --list
|
||||
* Unknown options and value-less --name/--baseline/--output-file exit 3.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
|
||||
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
|
||||
import { humanizeFindings } from './lib/humanizer.mjs';
|
||||
import { humanizeFindings, humanizeFinding } from './lib/humanizer.mjs';
|
||||
|
||||
const BOOL_FLAGS = ['--save', '--list', '--json', '--raw', '--global'];
|
||||
const VALUE_FLAGS = ['--name', '--baseline', '--output-file'];
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
|
@ -25,30 +31,50 @@ async function main() {
|
|||
let jsonMode = false;
|
||||
let rawMode = false;
|
||||
let includeGlobal = false;
|
||||
let outputFile = null;
|
||||
|
||||
// M-BUG-21: this loop used to end in `else if (!arg.startsWith('-')) targetPath = arg`,
|
||||
// with no unknown-flag branch. An unrecognised flag was dropped silently and its
|
||||
// VALUE fell through to targetPath — so `--output-file /tmp/x.json` scanned
|
||||
// /tmp/x.json, a path that does not exist, yielding a near-empty scan and
|
||||
// therefore permanent phantom drift. A missing value for --name was equally
|
||||
// silent and destructive: it left baselineName at 'default' and OVERWROTE the
|
||||
// default baseline. Both now fail loudly (exit 3) instead.
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--save') {
|
||||
save = true;
|
||||
} else if (args[i] === '--name' && args[i + 1]) {
|
||||
baselineName = args[++i];
|
||||
} else if (args[i] === '--baseline' && args[i + 1]) {
|
||||
baselineName = args[++i];
|
||||
} else if (args[i] === '--list') {
|
||||
list = true;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--raw') {
|
||||
rawMode = true;
|
||||
} else if (args[i] === '--global') {
|
||||
includeGlobal = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
const arg = args[i];
|
||||
|
||||
if (BOOL_FLAGS.includes(arg)) {
|
||||
if (arg === '--save') save = true;
|
||||
else if (arg === '--list') list = true;
|
||||
else if (arg === '--json') jsonMode = true;
|
||||
else if (arg === '--raw') rawMode = true;
|
||||
else if (arg === '--global') includeGlobal = true;
|
||||
} else if (VALUE_FLAGS.includes(arg)) {
|
||||
const value = args[i + 1];
|
||||
if (value === undefined || value.startsWith('-')) {
|
||||
throw new Error(`Option ${arg} requires a value.`);
|
||||
}
|
||||
if (arg === '--name' || arg === '--baseline') baselineName = value;
|
||||
else outputFile = value;
|
||||
i++;
|
||||
} else if (arg.startsWith('-')) {
|
||||
throw new Error(
|
||||
`Unknown option: ${arg}\n` +
|
||||
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
||||
);
|
||||
} else {
|
||||
targetPath = arg;
|
||||
}
|
||||
}
|
||||
|
||||
// --- List mode ---
|
||||
if (list) {
|
||||
const result = await listBaselines();
|
||||
// commands/drift.md runs this with `2>/dev/null` (ux-rules rule 2). The
|
||||
// human listing below goes to stderr, so without --output-file the command
|
||||
// received 0 bytes and could render nothing. The flag was already accepted
|
||||
// by the arg parser; only list mode ignored it.
|
||||
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
|
||||
if (jsonMode || rawMode) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
} else {
|
||||
|
|
@ -65,7 +91,12 @@ async function main() {
|
|||
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await requireTargetDir(resolve(targetPath)))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Save mode ---
|
||||
|
|
@ -84,7 +115,7 @@ async function main() {
|
|||
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
|
||||
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
|
||||
}
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Drift mode (default) ---
|
||||
|
|
@ -103,7 +134,28 @@ async function main() {
|
|||
process.stderr.write(`Baseline "${baselineName}" not found.\n`);
|
||||
process.stderr.write(`Save one first: node drift-cli.mjs <path> --save\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// M-BUG-27: a baseline carries the path it was saved from, but nothing ever
|
||||
// compared it against the current target. Diffing a repo against a baseline
|
||||
// anchored elsewhere produced 100% phantom drift — every baseline finding
|
||||
// "resolved", every current finding "new" — and reported it as trend
|
||||
// "improving": a reassuring and entirely false signal, on the DEFAULT
|
||||
// baseline. The warning goes to stderr in every mode; stdout stays
|
||||
// byte-identical to the frozen v5.0.0 shape.
|
||||
const baselineTarget = baseline._baseline?.target_path || '';
|
||||
const currentTarget = resolve(targetPath);
|
||||
const anchorMatches = !baselineTarget || baselineTarget === currentTarget;
|
||||
if (baselineTarget && baselineTarget !== currentTarget) {
|
||||
process.stderr.write(
|
||||
`\nWarning: baseline "${baselineName}" was saved from a different target path.\n` +
|
||||
` baseline: ${baselineTarget}\n` +
|
||||
` current: ${currentTarget}\n` +
|
||||
` The two scans cover different trees, so this diff is not a drift signal.\n` +
|
||||
` Re-anchor with: drift-cli.mjs ${currentTarget} --save --name ${baselineName}\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
// Run current scan
|
||||
|
|
@ -115,25 +167,49 @@ async function main() {
|
|||
// Diff
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
|
||||
// Default mode: humanize finding-bearing diff fields before report rendering.
|
||||
// `_baselineAnchor` rides here and NOT in the raw shape: commands/drift.md runs
|
||||
// the CLI under `2>/dev/null`, so the stderr warning above never reaches the
|
||||
// caller that has to act on it. --json/--raw stay v5.0.0-shaped.
|
||||
//
|
||||
// movedFindings holds {from, to} PAIRS, not flat findings — humanizeFindings()
|
||||
// builds a brand-new object from named finding fields only, so running it
|
||||
// directly over the pairs silently drops `from`/`to` and formatDiffReport's
|
||||
// `m.from.severity` crashes on undefined. Humanize each side of the pair.
|
||||
const humanizedDiff = {
|
||||
...diff,
|
||||
_baselineAnchor: { matches: anchorMatches, baselineTarget, currentTarget },
|
||||
newFindings: humanizeFindings(diff.newFindings || []),
|
||||
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
|
||||
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
|
||||
movedFindings: (diff.movedFindings || []).map((m) => ({
|
||||
from: humanizeFinding(m.from),
|
||||
to: humanizeFinding(m.to),
|
||||
})),
|
||||
};
|
||||
|
||||
if (jsonMode || rawMode) {
|
||||
// --json and --raw both write the raw v5.0.0-shape diff (byte-identical).
|
||||
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
|
||||
} else {
|
||||
// Default mode: humanize finding-bearing diff fields before report rendering.
|
||||
const humanizedDiff = {
|
||||
...diff,
|
||||
newFindings: humanizeFindings(diff.newFindings || []),
|
||||
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
|
||||
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
|
||||
movedFindings: humanizeFindings(diff.movedFindings || []),
|
||||
};
|
||||
const report = formatDiffReport(humanizedDiff);
|
||||
process.stderr.write('\n' + report + '\n');
|
||||
}
|
||||
|
||||
// ux-rules rule 2: every scanner Bash call uses `--output-file <path>` and the
|
||||
// command reads the file with the Read tool. drift-cli had no such flag, and
|
||||
// its default-mode report goes to stderr — which commands/drift.md discarded
|
||||
// via `2>/dev/null` while instructing the agent to "read stdout". The command
|
||||
// captured nothing. Matches posture.mjs: raw diff in --json/--raw, humanized
|
||||
// otherwise; stdout is unaffected.
|
||||
if (outputFile) {
|
||||
const fileDiff = (jsonMode || rawMode) ? diff : humanizedDiff;
|
||||
await writeOutputFile(outputFile, JSON.stringify(fileDiff, null, 2), 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
|
||||
// Exit code: 0=stable/improving, 1=degrading
|
||||
if (diff.summary.trend === 'degrading') process.exit(1);
|
||||
process.exit(0);
|
||||
process.exitCode = diff.summary.trend === 'degrading' ? 1 : 0;
|
||||
}
|
||||
|
||||
// Only run CLI if invoked directly
|
||||
|
|
@ -141,6 +217,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
process.exitCode = 3;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
/**
|
||||
* GAP Scanner — Feature Gap Scanner
|
||||
* Compares actual configuration against complete Claude Code feature register.
|
||||
* 25 gap dimensions across 4 tiers, plus a conditional disableBundledSkills
|
||||
* budget-lever check (remediation companion to SKL CA-SKL-002, fires only under
|
||||
* measured skill-listing pressure). Always runs with includeGlobal: true.
|
||||
* 24 gap dimensions across 4 tiers, plus four conditional levers (bundled-skills
|
||||
* budget, CLI-over-MCP, hook-output filtering, agent model/effort routing) which
|
||||
* fire only under a measured condition and are therefore NOT dimensions: they
|
||||
* stay out of the scoring denominators. Always runs with includeGlobal: true.
|
||||
* Finding IDs: CA-GAP-NNN
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { resolve, join, sep } from 'node:path';
|
||||
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { measureActiveSkillListing, isBundledSkillsDisabled, BUDGET_CALIBRATION_NOTE } from './lib/skill-listing-budget.mjs';
|
||||
import { assessMcpDeferralForRepo } from './lib/mcp-deferral.mjs';
|
||||
import { assessHookContextForRepo } from './lib/hook-additional-context.mjs';
|
||||
|
||||
const SCANNER = 'GAP';
|
||||
|
||||
|
|
@ -44,6 +47,68 @@ function isTargetLocal(ctx, f) {
|
|||
return f.absPath.startsWith(ctx.targetPath);
|
||||
}
|
||||
|
||||
// Files that are test/demo/vendored config — NOT part of the user's authored
|
||||
// cascade — must not satisfy "is feature X present?" checks, or they mask real
|
||||
// gaps. The canonical case: this plugin's own examples/optimal-setup sets
|
||||
// outputStyle/statusLine/worktree/model/keybindings/.lsp.json, and (because GAP
|
||||
// always runs includeGlobal) its copies vendored under ~/.claude/plugins/cache
|
||||
// drive every tier-3 presence check to "present" — hiding the user's real gaps
|
||||
// on ANY target. Two classes to exclude:
|
||||
// - plugin-bundled: anything under ~/.claude/plugins/ (absPath marker, mirrors
|
||||
// the CNF conflict-detector exclusion from M-BUG-2).
|
||||
// - nested demo/test data: a file whose path RELATIVE TO THE SCAN TARGET sits
|
||||
// under an examples/ or tests/fixtures/ subtree. relPath (not absPath) is
|
||||
// deliberate: a fixture scanned AS the target keeps its own files, so the
|
||||
// frozen v5.0.0 byte-snapshots (scanned from tests/fixtures/marketplace-medium)
|
||||
// are untouched. (M-BUG-13)
|
||||
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||
|
||||
/**
|
||||
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
|
||||
* @returns {boolean} true if the file is part of the user's authored config
|
||||
*/
|
||||
function isAuthoredConfig(file) {
|
||||
if (file.absPath.includes(PLUGIN_TREE_MARKER)) return false;
|
||||
const segs = (file.relPath || '').split(sep);
|
||||
if (segs.includes('examples')) return false;
|
||||
const ti = segs.indexOf('tests');
|
||||
if (ti !== -1 && segs[ti + 1] === 'fixtures') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the user→project→local 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,
|
||||
|
|
@ -51,6 +116,36 @@ const TIER_SEVERITY = {
|
|||
t4: SEVERITY.info,
|
||||
};
|
||||
|
||||
/**
|
||||
* Titles of the conditional levers — findings this scanner emits that are NOT
|
||||
* dimensions in GAP_CHECKS. They fire only under a measured condition, so they
|
||||
* carry no tier and never enter the scoring denominators (TIER_COUNTS /
|
||||
* TOTAL_DIMENSIONS) or the scoring TITLE_TO_ID map.
|
||||
*
|
||||
* Exported as the single source of both the code and the title: the
|
||||
* finding-code registry guard needs the codes, the humanizer coverage guard
|
||||
* needs the titles, and a hand-maintained copy of either list in a test is the
|
||||
* two-copies-drift class. One object so the two cannot disagree.
|
||||
*/
|
||||
export const LEVERS = {
|
||||
bundledSkills: {
|
||||
code: 'bundled-skills-lever',
|
||||
title: 'Bundled skills add to an over-budget skill listing',
|
||||
},
|
||||
cliOverMcp: {
|
||||
code: 'cli-over-mcp-lever',
|
||||
title: 'Prefer CLI over MCP for common operations',
|
||||
},
|
||||
filterHookOutput: {
|
||||
code: 'filter-hook-output-lever',
|
||||
title: 'Filter hook output before it enters context',
|
||||
},
|
||||
agentModelRouting: {
|
||||
code: 'agent-model-routing-lever',
|
||||
title: 'Subagents pin neither model nor effort',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Lazily read and cache file content.
|
||||
* @param {CheckContext} ctx
|
||||
|
|
@ -113,7 +208,8 @@ export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
|
|||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Bundled skills add to an over-budget skill listing',
|
||||
code: LEVERS.bundledSkills.code,
|
||||
title: LEVERS.bundledSkills.title,
|
||||
description:
|
||||
`Your ${aggregate.scanned} active skills already carry ~${aggregate.aggregateTokens} tokens of ` +
|
||||
`description text, over the ${aggregate.budgetTokens}-token listing budget Claude Code allots the ` +
|
||||
|
|
@ -134,8 +230,180 @@ export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-over-MCP lever — remediation companion to CA-TOK-006 (v5.10 B4).
|
||||
*
|
||||
* Fires ONLY when MCP tool schemas are forced into the always-loaded prefix
|
||||
* (tool search disabled, or a per-server alwaysLoad), i.e. when MCP is actually
|
||||
* costing always-loaded tokens. When schemas are deferred (the default), MCP is
|
||||
* effectively free until used, so there is nothing to recommend and we stay
|
||||
* silent — opportunity, not noise (mirrors the bundledSkills lever's "fire only
|
||||
* under measured pressure" contract). CLI tools (gh / aws / gcloud) add zero
|
||||
* context tokens until invoked, so they are the lever the deferral mechanism
|
||||
* cannot reach for the forced-upfront servers.
|
||||
*
|
||||
* Pure and exported for unit testing.
|
||||
*
|
||||
* @param {{ assessment: (import('./lib/mcp-deferral.mjs').assessMcpDeferral)|null }} args
|
||||
* @returns {object|null} a GAP finding, or null when nothing is forced upfront
|
||||
*/
|
||||
export function cliOverMcpLeverFinding({ assessment } = {}) {
|
||||
if (!assessment || !assessment.forcedUpfront) return null;
|
||||
const names = (assessment.affectedServers || []).map((m) => m.name).join(', ');
|
||||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
code: LEVERS.cliOverMcp.code,
|
||||
title: LEVERS.cliOverMcp.title,
|
||||
description:
|
||||
`Your active project MCP tool schemas (~${assessment.aggregateTokens} tokens) are forced into the ` +
|
||||
'always-loaded prefix every turn rather than deferred (see CA-TOK-006). CLI tools (gh, aws, gcloud, …) ' +
|
||||
'add ZERO context tokens until you actually call them, so moving common operations off MCP and onto a ' +
|
||||
'CLI reclaims always-loaded budget the deferral mechanism cannot.',
|
||||
evidence:
|
||||
`forced_schema_tokens~${assessment.aggregateTokens}; servers=${names}; ` +
|
||||
`reason=${assessment.reason || 'alwaysLoad'} (companion to CA-TOK-006)`,
|
||||
recommendation:
|
||||
'For operations a CLI already covers (GitHub → gh, AWS → aws, GCP → gcloud), prefer the CLI over an ' +
|
||||
'MCP server — CLI output enters context only when invoked. Keep MCP for capabilities with no CLI ' +
|
||||
'equivalent, disable unused servers via /mcp, and re-enable tool-search deferral so the rest stay names-only.',
|
||||
category: 'token-efficiency',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* filter-before-Claude-reads lever — remediation companion to HKV's B5 advisory
|
||||
* (v5.10). Fires ONLY when ≥1 active hook was detected injecting unfiltered
|
||||
* command output into additionalContext, i.e. when there is a measured chatty
|
||||
* hook to fix. When no such hook exists there is nothing to recommend and we
|
||||
* stay silent — opportunity, not noise (same "fire only under measured pressure"
|
||||
* contract as the cliOverMcp / bundledSkills levers).
|
||||
*
|
||||
* Pure and exported for unit testing.
|
||||
*
|
||||
* @param {{ flaggedHooks: Array<{event:string, scriptPath:string}> }} args
|
||||
* @returns {object|null} a GAP finding, or null when no chatty hook was detected
|
||||
*/
|
||||
export function filterHookLeverFinding({ flaggedHooks } = {}) {
|
||||
const hooks = Array.isArray(flaggedHooks) ? flaggedHooks : [];
|
||||
if (hooks.length === 0) return null;
|
||||
const scripts = hooks.map((h) => h.scriptPath.split('/').slice(-1)[0]).join(', ');
|
||||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
code: LEVERS.filterHookOutput.code,
|
||||
title: LEVERS.filterHookOutput.title,
|
||||
description:
|
||||
`${hooks.length} active hook${hooks.length === 1 ? '' : 's'} build hookSpecificOutput.additionalContext ` +
|
||||
"from un-grepped command output (see HKV advisory). That field enters Claude's context on every fire, " +
|
||||
'so filtering verbose output down to what matters BEFORE Claude reads it reclaims per-turn tokens — the ' +
|
||||
'documented filter-test-output.sh pattern (grep ERROR and return only matches instead of a 10,000-line log).',
|
||||
evidence: `chatty_hooks=${hooks.length}; scripts=${scripts} (companion to HKV additionalContext advisory)`,
|
||||
recommendation:
|
||||
'In each flagged hook, pipe the command output through grep/head/jq to keep only the actionable lines ' +
|
||||
'before assigning additionalContext. Reserve additionalContext for concise signals; leave bulk diagnostics ' +
|
||||
'on plain stdout (exit 0) so they go to the debug log, not context.',
|
||||
category: 'token-efficiency',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent model/effort routing lever (C4) — cites BP-MODEL-001/002.
|
||||
*
|
||||
* A LEVER rather than a GAP_CHECKS dimension, and deliberately so. The question
|
||||
* "do your subagents route model/effort?" has no meaningful reading on a config
|
||||
* with no subagents — the `No custom subagents` dimension (t2_6) owns that case,
|
||||
* and firing here too would just double-report it. A dimension can only express
|
||||
* "not applicable" as "present", which would also inflate the utilization
|
||||
* denominator for every agent-less config.
|
||||
*
|
||||
* ONE check across BOTH axes, not two: it fires only when NEITHER `model:` nor
|
||||
* `effort:` appears on ANY authored agent. A deliberate all-on-one-model setup
|
||||
* therefore stays silent, which is the precision the opportunity framing needs.
|
||||
* The cost is recall — a config that pins `model:` everywhere but never uses
|
||||
* `effort:` gets no nudge. That trade is the v1 boundary, not an oversight.
|
||||
*
|
||||
* Pure and exported for unit testing.
|
||||
*
|
||||
* @param {{ agentCount: number, modelPinned: number, effortPinned: number }} counts
|
||||
* @returns {object|null} a GAP finding, or null when there is no opportunity
|
||||
*/
|
||||
export function agentModelRoutingLeverFinding({ agentCount, modelPinned, effortPinned }) {
|
||||
if (!agentCount) return null;
|
||||
if (modelPinned > 0 || effortPinned > 0) return null;
|
||||
|
||||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
code: LEVERS.agentModelRouting.code,
|
||||
title: LEVERS.agentModelRouting.title,
|
||||
description:
|
||||
`All ${agentCount} of your subagents name neither a \`model:\` nor an \`effort:\` in their frontmatter. ` +
|
||||
'The `model` field defaults to `inherit`, so each one runs on the main conversation\'s model — omitting ' +
|
||||
'it is not a neutral default but a choice to pay the session\'s rate for every delegated task ' +
|
||||
'(BP-MODEL-001, https://code.claude.com/docs/en/sub-agents). Reasoning effort is a separate axis with ' +
|
||||
'its own frontmatter field and its own default, so a subagent can be routed on either or both ' +
|
||||
'(BP-MODEL-002, https://code.claude.com/docs/en/model-config).',
|
||||
evidence:
|
||||
`authored_agents=${agentCount}; model_pinned=${modelPinned}; effort_pinned=${effortPinned}; ` +
|
||||
'lever=agent frontmatter `model:` / `effort:` (plugin-bundled and fixture agents excluded)',
|
||||
recommendation:
|
||||
'Pin a cheaper `model:` on the subagents whose work is mechanical or read-only (search, extraction, ' +
|
||||
'summarisation) and leave the orchestrating session on the stronger model; pin a lower `effort:` on the ' +
|
||||
'same ones and reserve the high levels for work whose product is judgement. If running everything on one ' +
|
||||
'model is a deliberate policy, suppress this with `CA-GAP-028` in `.config-audit-ignore`.',
|
||||
category: 'model-fit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count authored agents and how many pin each routing axis.
|
||||
* Frontmatter-only read; an unparseable or frontmatter-less file counts as an
|
||||
* agent that pins nothing, matching what Claude Code would load.
|
||||
* @param {CheckContext} ctx
|
||||
* @returns {Promise<{ agentCount: number, modelPinned: number, effortPinned: number }>}
|
||||
*/
|
||||
async function countAgentRouting(ctx) {
|
||||
let agentCount = 0;
|
||||
let modelPinned = 0;
|
||||
let effortPinned = 0;
|
||||
for (const file of ctx.files.filter(f => f.type === 'agent-md')) {
|
||||
agentCount++;
|
||||
const content = await getContent(ctx, file.absPath);
|
||||
if (!content) continue;
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
if (!frontmatter) continue;
|
||||
if (isRoutingValue(frontmatter.model) && !isDefaultModel(frontmatter.model)) modelPinned++;
|
||||
if (isRoutingValue(frontmatter.effort)) effortPinned++;
|
||||
}
|
||||
return { agentCount, modelPinned, effortPinned };
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a frontmatter value that actually names something. An empty or
|
||||
* whitespace-only `model:` is a no-op in Claude Code, so it must not read as a pin.
|
||||
* @param {*} v
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRoutingValue(v) {
|
||||
return typeof v === 'string' ? v.trim().length > 0 : v != null && v !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* `inherit` IS the documented default for a subagent's `model` (BP-MODEL-001),
|
||||
* so writing it explicitly routes nothing — the agent still runs on the main
|
||||
* conversation's model. Spelling out a default must not buy silence, or a config
|
||||
* can opt out of the opportunity without changing a single thing about cost.
|
||||
* Effort has no documented sentinel of this kind, so it has no counterpart here.
|
||||
* @param {*} v
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDefaultModel(v) {
|
||||
return typeof v === 'string' && v.trim().toLowerCase() === 'inherit';
|
||||
}
|
||||
|
||||
/** @type {GapCheck[]} */
|
||||
const GAP_CHECKS = [
|
||||
export const GAP_CHECKS = [
|
||||
// --- Tier 1: Foundation ---
|
||||
{
|
||||
id: 't1_1', tier: 't1',
|
||||
|
|
@ -341,12 +609,11 @@ const GAP_CHECKS = [
|
|||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't3_8', tier: 't3',
|
||||
title: 'No autoMode classifier',
|
||||
recommendation: 'Configure autoMode in user/local settings with environment context and allow/deny rules.',
|
||||
check: async (ctx) => anySettingsHas(ctx, 'autoMode'),
|
||||
},
|
||||
// t3_8 ('No autoMode classifier') retired in v5.14: CC 2.1.226's /doctor
|
||||
// Check 8 covers auto mode with usage-weighted judgement, so an "adopt this
|
||||
// feature" nudge is a pure duplicate under the binding /doctor positioning.
|
||||
// The DETERMINISTIC side stays: SET still validates autoMode structure and
|
||||
// flags it as dead config in shared project settings.
|
||||
|
||||
// --- Tier 4: Team/Enterprise ---
|
||||
{
|
||||
|
|
@ -402,18 +669,30 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
? sharedDiscovery
|
||||
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
|
||||
|
||||
// Parse all settings files upfront
|
||||
// Presence checks ("does the user have feature X?") must see only the user's
|
||||
// authored cascade — not bundled/vendored/demo config, which masks real gaps
|
||||
// (M-BUG-13, see isAuthoredConfig).
|
||||
const authoredFiles = discovery.files.filter(isAuthoredConfig);
|
||||
|
||||
// Parse all settings files upfront (authored discovery files) ...
|
||||
const parsedSettings = new Map();
|
||||
for (const file of discovery.files.filter(f => f.type === 'settings-json')) {
|
||||
for (const file of authoredFiles.filter(f => f.type === 'settings-json')) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (content) {
|
||||
const parsed = parseJson(content);
|
||||
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
|
||||
}
|
||||
}
|
||||
// ... plus the real user→project→local cascade read directly, so settings-key
|
||||
// checks see the true resolved config regardless of the discovery cap/gotcha
|
||||
// (M-BUG-13). Merged, not replacing — keeps non-canonical project settings and
|
||||
// the frozen byte-snapshots unchanged.
|
||||
for (const { key, parsed } of await readSettingsCascade(resolve(targetPath))) {
|
||||
parsedSettings.set(key, parsed);
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
files: discovery.files,
|
||||
files: authoredFiles,
|
||||
targetPath: resolve(targetPath),
|
||||
parsedSettings,
|
||||
fileContents: new Map(),
|
||||
|
|
@ -424,6 +703,7 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
if (!present) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: gap.id,
|
||||
severity: TIER_SEVERITY[gap.tier],
|
||||
title: gap.title,
|
||||
description: `Feature gap: ${gap.title}. ${gap.recommendation}`,
|
||||
|
|
@ -441,6 +721,26 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
const leverFinding = bundledSkillsLeverFinding({ leverPulled, aggregate });
|
||||
if (leverFinding) findings.push(leverFinding);
|
||||
|
||||
// CLI-over-MCP lever — companion to CA-TOK-006: fires only when project-local
|
||||
// MCP tool schemas are forced into the always-loaded prefix (tool search
|
||||
// disabled or a per-server alwaysLoad). Reuses the same static assessment.
|
||||
const mcpAssessment = await assessMcpDeferralForRepo(ctx.targetPath);
|
||||
const cliLever = cliOverMcpLeverFinding({ assessment: mcpAssessment });
|
||||
if (cliLever) findings.push(cliLever);
|
||||
|
||||
// filter-before-Claude-reads lever — companion to HKV's B5 advisory: fires
|
||||
// only when an active hook injects unfiltered output into additionalContext.
|
||||
const flaggedHooks = await assessHookContextForRepo(discovery);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,19 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
|
||||
import { createBackup } from './lib/backup.mjs';
|
||||
import { humanizeFinding } from './lib/humanizer.mjs';
|
||||
|
||||
// `--dry-run` is a no-op alias: dry-run is already the default. It exists because
|
||||
// commands/fix.md documents it in argument-hint, and a documented flag that the
|
||||
// CLI silently drops is the same fail-silent class as the unknown-flag sink below.
|
||||
const BOOL_FLAGS = ['--apply', '--dry-run', '--json', '--raw', '--global', '--approve-scope'];
|
||||
const VALUE_FLAGS = ['--output-file', '--repo'];
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
|
|
@ -21,18 +29,44 @@ async function main() {
|
|||
let jsonMode = false;
|
||||
let rawMode = false;
|
||||
let includeGlobal = false;
|
||||
let outputFile = null;
|
||||
let approveScope = false;
|
||||
// The session's root, never the scan target (#63). `--global` fixes files
|
||||
// under `~/.claude` while the session still stands somewhere else, so reading
|
||||
// the root off the target would classify a machine-wide write as "in-repo"
|
||||
// and silence the strongest gate exactly where it matters.
|
||||
let repoRoot = process.cwd();
|
||||
|
||||
// Same defect class as M-BUG-21 in drift-cli: this loop used to end in
|
||||
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
|
||||
// unknown-flag branch, so an unrecognised flag was dropped silently and its
|
||||
// VALUE became the scan target. Here that is worse than in drift: combined
|
||||
// with --apply it silently moves the WRITE target to another tree.
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--apply') {
|
||||
apply = true;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--raw') {
|
||||
rawMode = true;
|
||||
} else if (args[i] === '--global') {
|
||||
includeGlobal = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
const arg = args[i];
|
||||
|
||||
if (BOOL_FLAGS.includes(arg)) {
|
||||
if (arg === '--apply') apply = true;
|
||||
else if (arg === '--json') jsonMode = true;
|
||||
else if (arg === '--raw') rawMode = true;
|
||||
else if (arg === '--global') includeGlobal = true;
|
||||
else if (arg === '--approve-scope') approveScope = true;
|
||||
// --dry-run: default behaviour, accepted so it is not silently dropped.
|
||||
} else if (VALUE_FLAGS.includes(arg)) {
|
||||
const value = args[i + 1];
|
||||
if (value === undefined || value.startsWith('-')) {
|
||||
throw new Error(`Option ${arg} requires a value.`);
|
||||
}
|
||||
if (arg === '--repo') repoRoot = value;
|
||||
else outputFile = value;
|
||||
i++;
|
||||
} else if (arg.startsWith('-')) {
|
||||
throw new Error(
|
||||
`Unknown option: ${arg}\n` +
|
||||
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
||||
);
|
||||
} else {
|
||||
targetPath = arg;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +75,11 @@ async function main() {
|
|||
|
||||
const resolvedPath = resolve(targetPath);
|
||||
|
||||
if (!(await requireTargetDir(resolvedPath))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!machineMode) {
|
||||
process.stderr.write(`Config-Audit Fix CLI v2.1.0\n`);
|
||||
process.stderr.write(`Target: ${resolvedPath}\n`);
|
||||
|
|
@ -103,18 +142,29 @@ async function main() {
|
|||
let verified = [];
|
||||
let regressions = [];
|
||||
let backupId = null;
|
||||
// The engine's scope verdict, carried out to the payload. A `disclose` class
|
||||
// writes without withholding anything, so the ONLY place the command can
|
||||
// learn that a write left the project is here — stderr is discarded by
|
||||
// `2>/dev/null` (ux-rules rule 2), which is F3's defect class.
|
||||
let scopeGate = null;
|
||||
let scopeDisclosures = [];
|
||||
|
||||
if (fixes.length === 0) {
|
||||
if (machineMode) {
|
||||
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
|
||||
if (machineMode) {
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
process.exit(0);
|
||||
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
// Create backup first
|
||||
const filesToBackup = [...new Set(fixes.filter(f => f.type !== 'file-rename').map(f => f.file))];
|
||||
// Create backup first. file-rename used to be excluded here, so a rule file
|
||||
// whose only defect was its extension was renamed with NO backup entry —
|
||||
// while commands/fix.md promised "every fix creates a backup first" and
|
||||
// handed the user a backupId that could not restore it. The source file is
|
||||
// backed up like any other; rollback recreates it at its original path.
|
||||
const filesToBackup = [...new Set(fixes.map(f => f.file))];
|
||||
const backup = createBackup(filesToBackup);
|
||||
backupId = backup.backupId;
|
||||
|
||||
|
|
@ -123,9 +173,43 @@ async function main() {
|
|||
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
|
||||
}
|
||||
|
||||
const result = await applyFixes(fixes, { dryRun: false, backupDir: backup.backupPath });
|
||||
const result = await applyFixes(fixes, {
|
||||
dryRun: false,
|
||||
backupDir: backup.backupPath,
|
||||
repoRoot,
|
||||
approveScope,
|
||||
});
|
||||
applied = result.applied;
|
||||
failed = result.failed;
|
||||
scopeGate = result.gate ?? null;
|
||||
scopeDisclosures = result.disclosures ?? [];
|
||||
|
||||
// A refused set is a verdict about a config that WAS examined, not a tool
|
||||
// failure — so it rides in the payload and keeps the normal exit contract
|
||||
// (#62). Anything a command must act on has to reach it through
|
||||
// `--output-file`; stderr alone is invisible to the command layer (F3).
|
||||
if (result.requiresApproval && result.refused.length > 0) {
|
||||
const payload = {
|
||||
status: 'refused',
|
||||
reason: 'scope-gate',
|
||||
gate: result.gate,
|
||||
requiresApproval: true,
|
||||
disclosures: result.disclosures,
|
||||
refused: result.refused,
|
||||
backupId,
|
||||
};
|
||||
const json = JSON.stringify(payload, null, 2) + '\n';
|
||||
if (machineMode) process.stdout.write(json);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
if (!machineMode) {
|
||||
for (const line of result.disclosures) process.stderr.write(`\n ${line}\n`);
|
||||
process.stderr.write(
|
||||
`\n Refused ${result.refused.length} fix(es) pending your go-ahead.`
|
||||
+ ' Re-run with --approve-scope to apply them.\n',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!machineMode) {
|
||||
process.stderr.write(` Results: ${applied.length} applied, ${failed.length} failed\n`);
|
||||
|
|
@ -142,7 +226,10 @@ async function main() {
|
|||
process.stderr.write(`\n Verifying...\n`);
|
||||
}
|
||||
|
||||
const verification = await verifyFixes(envelope, applied);
|
||||
// Verification must re-scan the scope the fix run used. It hardcoded
|
||||
// includeGlobal:false, so with --global every untouched global-scope
|
||||
// finding fell out of the re-scan and was reported as verified.
|
||||
const verification = await verifyFixes(envelope, applied, { includeGlobal });
|
||||
verified = verification.verified;
|
||||
regressions = verification.regressions;
|
||||
|
||||
|
|
@ -151,7 +238,11 @@ async function main() {
|
|||
if (regressions.length > 0) {
|
||||
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
|
||||
}
|
||||
process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${backupId}\n`);
|
||||
// The user-facing entry, not the CLI: `/config-audit rollback` renders
|
||||
// the scope disclosures and asks before a restore that leaves the repo.
|
||||
// `scanners/rollback-cli.mjs` exists now (R1) and is what the command
|
||||
// runs, but naming it here would hand the user the ungated half.
|
||||
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -165,7 +256,7 @@ async function main() {
|
|||
}
|
||||
|
||||
// JSON output (both --json and --raw write byte-equal v5.0.0-shape stdout)
|
||||
if (machineMode) {
|
||||
{
|
||||
const output = {
|
||||
planned: fixes.map(f => ({
|
||||
findingId: f.findingId,
|
||||
|
|
@ -192,8 +283,20 @@ async function main() {
|
|||
recommendation: m.recommendation,
|
||||
})),
|
||||
backupId,
|
||||
gate: scopeGate,
|
||||
disclosures: scopeDisclosures,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
const serialized = JSON.stringify(output, null, 2) + '\n';
|
||||
if (machineMode) process.stdout.write(serialized);
|
||||
// --output-file carries the same payload to disk. ux-rules rule 2 requires
|
||||
// it: commands run scanners with `2>/dev/null`, so anything the command has
|
||||
// to act on must ride in a file, not in stdout or stderr.
|
||||
if (outputFile) await writeOutputFile(outputFile, serialized, 'utf-8');
|
||||
|
||||
// Exit code follows the convention the other scanners use: 0 PASS,
|
||||
// 2 FAIL, 3 tool error. A failed fix used to exit 0, so a caller could not
|
||||
// tell a clean run from one that silently lost a fix.
|
||||
if (failed.length > 0) process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +305,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
process.exitCode = 3;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import { readFile, writeFile, rename, stat } from 'node:fs/promises';
|
|||
import { dirname } from 'node:path';
|
||||
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { createBackup } from './lib/backup.mjs';
|
||||
import { evaluateWriteTargets } from './lib/write-scope.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { VALID_EFFORT_LEVELS as SETTINGS_EFFORT_LEVELS } from './settings-validator.mjs';
|
||||
|
||||
/**
|
||||
* Fix type constants.
|
||||
|
|
@ -22,8 +24,8 @@ const FIX_TYPES = {
|
|||
FILE_RENAME: 'file-rename',
|
||||
};
|
||||
|
||||
/** Valid effortLevel values for nearest-match */
|
||||
const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
|
||||
/** Valid effortLevel values for nearest-match — the validator's list, not a copy. */
|
||||
const VALID_EFFORT_LEVELS = [...SETTINGS_EFFORT_LEVELS];
|
||||
|
||||
/**
|
||||
* Plan fixes from a scanner envelope.
|
||||
|
|
@ -56,9 +58,21 @@ export function planFixes(envelope) {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort fixes by severity weight (critical first)
|
||||
// Sort fixes by severity weight (critical first), but a file-rename always
|
||||
// sorts after every other fix. A rename moves the file out from under any
|
||||
// later fix that still addresses the old path: a rule file with both
|
||||
// `globs:` and a non-.md extension had the rename applied first, and the
|
||||
// frontmatter fix then failed with ENOENT while the run still exited 0.
|
||||
// `?? 4`, not `|| 4`: critical weighs 0, and `0 || 4` evaluates to 4 — so
|
||||
// critical fixes sorted LAST, the exact opposite of this function's contract
|
||||
// (M-BUG-30). The old test used the same falsy fallback and agreed with the bug.
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));
|
||||
fixes.sort((a, b) => {
|
||||
const aRename = a.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
|
||||
const bRename = b.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
|
||||
if (aRename !== bRename) return aRename - bRename;
|
||||
return (severityOrder[a.severity] ?? 4) - (severityOrder[b.severity] ?? 4);
|
||||
});
|
||||
|
||||
return { fixes, skipped, manual };
|
||||
}
|
||||
|
|
@ -180,7 +194,7 @@ function createFixPlan(finding) {
|
|||
|
||||
// --- RUL scanner fixes ---
|
||||
if (scanner === 'RUL') {
|
||||
if (title === 'Rule uses deprecated "globs" field') {
|
||||
if (title === 'Rule uses "globs" instead of documented "paths"') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.FRONTMATTER_RENAME,
|
||||
|
|
@ -224,6 +238,42 @@ export async function applyFixes(fixPlans, opts = {}) {
|
|||
throw new Error('backupDir is required when not in dryRun mode');
|
||||
}
|
||||
|
||||
// Q1 — the scope gate, in code rather than in the command template's prose.
|
||||
//
|
||||
// A file-rename writes TWO paths: the source disappears and `newPath`
|
||||
// appears. Classifying only `plan.file` would let a rename move a repo file
|
||||
// to a machine-wide destination under a `silent` gate.
|
||||
//
|
||||
// `!dryRun` is load-bearing and is the same rule the subtraction axis
|
||||
// settled (#63): the gate guards a WRITE, and a dry run is not one.
|
||||
// `requiresApproval` is reported either way, so a caller planning a run still
|
||||
// learns that approval will be owed before anything is applied.
|
||||
const scope = evaluateWriteTargets(
|
||||
fixPlans.flatMap((p) => (p.newPath ? [p.file, p.newPath] : [p.file])),
|
||||
opts.repoRoot ?? null,
|
||||
opts.home ? { home: opts.home } : {},
|
||||
);
|
||||
|
||||
if (scope.requiresApproval && !opts.approveScope && !opts.dryRun) {
|
||||
// A refused write is a VERDICT about a config that was examined, not a
|
||||
// tool failure (#62) — the caller renders the disclosure and asks. Nothing
|
||||
// is applied, and this is not an exit-3 situation.
|
||||
return {
|
||||
applied: [],
|
||||
failed: [],
|
||||
gate: scope.gate,
|
||||
requiresApproval: true,
|
||||
disclosures: scope.disclosures,
|
||||
refused: fixPlans.map((plan) => ({
|
||||
findingId: plan.findingId,
|
||||
file: plan.file,
|
||||
status: 'refused',
|
||||
reason: 'scope-gate',
|
||||
type: plan.type,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
for (const plan of fixPlans) {
|
||||
if (opts.dryRun) {
|
||||
applied.push({
|
||||
|
|
@ -256,7 +306,14 @@ export async function applyFixes(fixPlans, opts = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
return { applied, failed };
|
||||
return {
|
||||
applied,
|
||||
failed,
|
||||
gate: scope.gate,
|
||||
requiresApproval: scope.requiresApproval,
|
||||
disclosures: scope.disclosures,
|
||||
refused: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -600,20 +657,29 @@ function extractEventFromDescription(description) {
|
|||
* Verify fixes by re-running affected scanners.
|
||||
* @param {object} originalEnvelope - Original scanner envelope
|
||||
* @param {object[]} appliedResults - Results from applyFixes()
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.includeGlobal=false] - Must match the scope the fix run scanned
|
||||
* @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
|
||||
*/
|
||||
export async function verifyFixes(originalEnvelope, appliedResults) {
|
||||
export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
|
||||
const targetPath = originalEnvelope.meta.target;
|
||||
const verified = [];
|
||||
const regressions = [];
|
||||
const newFindings = [];
|
||||
|
||||
// Re-scan the target
|
||||
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false });
|
||||
// Re-scan the target in the SAME scope the fix run used. This was hardcoded
|
||||
// to includeGlobal:false: after a --global run, every global-scope finding
|
||||
// was absent from the re-scan and therefore counted as verified — a clean
|
||||
// "fixed" report for files nothing had touched.
|
||||
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: opts.includeGlobal === true });
|
||||
|
||||
// Build set of original finding IDs that were fixed
|
||||
const fixedIds = new Set(
|
||||
appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
|
||||
// Build the set of fixed finding INSTANCES. A finding ID names the check, so
|
||||
// one check failing in two files yields two findings sharing an ID; keying on
|
||||
// the ID alone marks both fixed when one was, and the untouched sibling — still
|
||||
// present in the re-scan — is then reported as a regression (M-BUG-28).
|
||||
const instanceKey = (findingId, file) => `${findingId}::${file || ''}`;
|
||||
const fixedInstances = new Set(
|
||||
appliedResults.filter(r => r.status === 'applied').map(r => instanceKey(r.findingId, r.file)),
|
||||
);
|
||||
|
||||
// Build set of new finding titles for comparison
|
||||
|
|
@ -627,11 +693,13 @@ export async function verifyFixes(originalEnvelope, appliedResults) {
|
|||
// Check that fixed findings are gone
|
||||
for (const scanner of originalEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
if (!fixedIds.has(f.id)) continue;
|
||||
if (!fixedInstances.has(instanceKey(f.id, f.file))) continue;
|
||||
|
||||
const key = `${f.scanner}:${f.title}:${f.file}`;
|
||||
// For file-rename fixes, the original file path won't exist anymore
|
||||
const fixResult = appliedResults.find(r => r.findingId === f.id);
|
||||
const fixResult = appliedResults.find(
|
||||
r => instanceKey(r.findingId, r.file) === instanceKey(f.id, f.file),
|
||||
);
|
||||
if (fixResult && fixResult.type === 'file-rename') {
|
||||
// Check that the finding doesn't reappear at the new path
|
||||
verified.push(f.id);
|
||||
|
|
|
|||
|
|
@ -8,16 +8,18 @@ import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
|||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { assessHookAdditionalContext } from './lib/hook-additional-context.mjs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
|
||||
const SCANNER = 'HKV';
|
||||
|
||||
/** All valid hook events (as of June 2026) */
|
||||
/** All valid hook events — verified against code.claude.com/docs/en/hooks.md (2026-06-19) */
|
||||
const VALID_EVENTS = new Set([
|
||||
'SessionStart', 'InstructionsLoaded', 'UserPromptSubmit',
|
||||
'SessionStart', 'Setup', 'InstructionsLoaded',
|
||||
'UserPromptSubmit', 'UserPromptExpansion',
|
||||
'PreToolUse', 'PermissionRequest', 'PermissionDenied',
|
||||
'PostToolUse', 'PostToolUseFailure',
|
||||
'PostToolUse', 'PostToolUseFailure', 'PostToolBatch',
|
||||
'SubagentStart', 'SubagentStop',
|
||||
'TaskCreated', 'TaskCompleted',
|
||||
'Stop', 'StopFailure',
|
||||
|
|
@ -26,9 +28,11 @@ const VALID_EVENTS = new Set([
|
|||
'WorktreeCreate', 'WorktreeRemove',
|
||||
'PreCompact', 'PostCompact',
|
||||
'Elicitation', 'ElicitationResult',
|
||||
'SessionEnd',
|
||||
// CC 2.1.152 (MessageDisplay), 2.1.169 (post-session, kebab — distinct from SessionEnd)
|
||||
'MessageDisplay', 'post-session',
|
||||
'SessionEnd', 'MessageDisplay',
|
||||
// 'post-session' deliberately EXCLUDED: the 2.1.169 changelog `post-session`
|
||||
// is a self-hosted-runner workspace-lifecycle hook, NOT a settings.json hook
|
||||
// event (absent from hooks.md; all settings.json events are PascalCase).
|
||||
// Verified 2026-06-20.
|
||||
]);
|
||||
|
||||
/** Valid hook handler types */
|
||||
|
|
@ -66,6 +70,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed === null) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in hooks.json',
|
||||
description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`,
|
||||
|
|
@ -116,6 +121,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof hooks !== 'object' || Array.isArray(hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-not-object',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Hooks must be an object with event keys',
|
||||
description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`,
|
||||
|
|
@ -131,12 +137,13 @@ 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.`,
|
||||
file: file.absPath,
|
||||
evidence: event,
|
||||
recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (28 total)`,
|
||||
recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (${VALID_EVENTS.size} total)`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
|
|
@ -145,6 +152,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!Array.isArray(handlers)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'handlers-not-array',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook handlers must be an array',
|
||||
description: `${file.relPath}: handlers for "${event}" is not an array.`,
|
||||
|
|
@ -162,6 +170,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof handlerGroup.matcher === 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'matcher-not-string',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Matcher must be a string, not an object',
|
||||
description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`,
|
||||
|
|
@ -176,6 +185,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-hooks-array',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing hooks array in handler group',
|
||||
description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`,
|
||||
|
|
@ -191,6 +201,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!hook.type || !VALID_TYPES.has(hook.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-handler-type',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hook handler type',
|
||||
description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`,
|
||||
|
|
@ -212,6 +223,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'script-not-found',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook script not found',
|
||||
description: `${file.relPath}: "${event}" references script that does not exist.`,
|
||||
|
|
@ -228,6 +240,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (verboseCount > VERBOSE_HOOK_LINE_THRESHOLD) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'verbose-output',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Verbose hook output (loud script)',
|
||||
description:
|
||||
|
|
@ -245,6 +258,36 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// v5.10 B5: advisory (info) — a hook that injects unfiltered
|
||||
// command output into hookSpecificOutput.additionalContext pays
|
||||
// that whole payload into Claude's context on every fire (plain
|
||||
// stdout does not). Low-precision static heuristic, so info only.
|
||||
const scriptContent = await readTextFile(scriptPath);
|
||||
const ac = assessHookAdditionalContext({ scriptContent });
|
||||
if (ac.flagged) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unfiltered-additional-context',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Hook injects unfiltered output into context',
|
||||
description:
|
||||
`${file.relPath}: "${event}" runs ${scriptPath.split('/').slice(-2).join('/')} ` +
|
||||
'which builds hookSpecificOutput.additionalContext from un-grepped command ' +
|
||||
"output. That field enters Claude's context every time the hook fires (plain " +
|
||||
'stdout does not), so an unfiltered payload is a recurring per-turn token cost. ' +
|
||||
'Advisory only — low-precision static heuristic; verify the real payload size.',
|
||||
file: scriptPath,
|
||||
evidence:
|
||||
'additional_context_unfiltered=true; ' +
|
||||
`verbose_capture=${ac.hasVerboseCapture}; filter_applied=${ac.hasFilter}`,
|
||||
recommendation:
|
||||
'Filter before Claude reads: grep/head the command output down to what matters ' +
|
||||
'before putting it in additionalContext (the documented filter-test-output.sh ' +
|
||||
'pattern), or keep large diagnostics on plain stdout so they stay out of context.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -254,6 +297,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof hook.timeout !== 'number') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'timeout-not-number',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Hook timeout must be a number',
|
||||
description: `${file.relPath}: "${event}" has non-numeric timeout.`,
|
||||
|
|
@ -265,6 +309,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
} else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'timeout-out-of-range',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Hook timeout outside recommended range',
|
||||
description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(`tilde::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'tilde-path',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Tilde path in @import',
|
||||
description: `@${imp.path} uses ~ which may not expand correctly in all contexts.`,
|
||||
|
|
@ -91,6 +92,7 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(reportKey);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'broken-link',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Broken @import link',
|
||||
description: `@${imp.path} references a file that does not exist.`,
|
||||
|
|
@ -111,6 +113,7 @@ async function walkImports(file, chain, reported, findings) {
|
|||
const cycle = chain.slice(cycleStart).map(f => basename(f)).join(' → ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'circular-reference',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Circular @import reference',
|
||||
description: `@${imp.path} creates a circular import chain.`,
|
||||
|
|
@ -129,6 +132,7 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(`deep::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deep-chain',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Deep @import chain',
|
||||
description: `@${imp.path} is at depth ${chain.length} (>${MAX_CHAIN_DEPTH} hops).`,
|
||||
|
|
|
|||
117
scanners/knowledge-refresh-cli.mjs
Normal file
117
scanners/knowledge-refresh-cli.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* knowledge-refresh CLI — feeds the v5.7 `/config-audit knowledge-refresh` command
|
||||
* (Chunk 3: the "living" half of the living knowledge base).
|
||||
*
|
||||
* This is the DETERMINISTIC half of the hybrid motor: it loads the best-practices
|
||||
* register and classifies every entry as `fresh` or `stale` by the age of its
|
||||
* `source.verified` stamp (via the pure `assessFreshness` core). It is READ-ONLY —
|
||||
* it NEVER writes the register and NEVER touches the network. Candidate discovery
|
||||
* (polling the CC changelog + Anthropic blog) and the human-approved writes live in
|
||||
* the command layer (Verifiseringsplikt). `--dry-run` is implicit and the only mode;
|
||||
* the flag is accepted for explicitness and echoed back.
|
||||
*
|
||||
* Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only
|
||||
* loads scanner modules), so the scanner count is unchanged and the snapshot suite
|
||||
* stays byte-stable.
|
||||
*
|
||||
* Usage:
|
||||
* node knowledge-refresh-cli.mjs [--output-file <path>] [--stale-after <N>]
|
||||
* [--reference-date <YYYY-MM-DD>] [--dry-run]
|
||||
*
|
||||
* Exit codes: 0 = every entry fresh, 1 = one or more stale (advisory), 3 = error.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { loadRegister, REGISTER_PATH } from './lib/best-practices-register.mjs';
|
||||
import { assessFreshness, STALE_AFTER_DAYS_DEFAULT } from './lib/knowledge-refresh.mjs';
|
||||
import { findArgError } from './lib/cli-args.mjs';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Usage error. Throws rather than calling process.exit(): exit() discards
|
||||
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
|
||||
* `Error: ` text and sets the same exit code 3, so callers see no difference.
|
||||
*/
|
||||
class CliUsageError extends Error {}
|
||||
|
||||
function fail(message) {
|
||||
throw new CliUsageError(message);
|
||||
}
|
||||
|
||||
/** Flag surface, measured 2026-08-09. The gate runs BEFORE the loop below, so the
|
||||
* loop no longer needs to re-check that a value followed its flag. */
|
||||
const ARG_SPEC = { boolean: ['--dry-run'], value: ['--output-file', '--stale-after', '--reference-date'] };
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const argError = findArgError(args, ARG_SPEC);
|
||||
if (argError) fail(argError);
|
||||
let outputFile = null;
|
||||
let staleAfterDays = STALE_AFTER_DAYS_DEFAULT;
|
||||
let referenceDate = null; // null → today
|
||||
let dryRun = false;
|
||||
|
||||
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') {
|
||||
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') {
|
||||
referenceDate = args[++i];
|
||||
if (!DATE_RE.test(referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
||||
// The clock is read here ONLY — the core takes an injected date and stays pure.
|
||||
const ref = referenceDate || new Date();
|
||||
|
||||
let register;
|
||||
try {
|
||||
register = loadRegister();
|
||||
} catch (err) {
|
||||
fail(`could not load register at ${REGISTER_PATH}: ${err.message}`);
|
||||
}
|
||||
|
||||
let assessment;
|
||||
try {
|
||||
assessment = assessFreshness(register, { referenceDate: ref, staleAfterDays });
|
||||
} catch (err) {
|
||||
fail(err.message);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
status: 'ok',
|
||||
registerPath: REGISTER_PATH,
|
||||
version: register.version,
|
||||
dryRun: true, // this CLI never writes; the flag is informational
|
||||
requestedDryRun: dryRun,
|
||||
referenceDate: assessment.referenceDate,
|
||||
staleAfterDays: assessment.staleAfterDays,
|
||||
counts: assessment.counts,
|
||||
stale: assessment.stale,
|
||||
fresh: assessment.fresh,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
|
||||
process.exitCode = assessment.counts.stale > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
const isDirectRun =
|
||||
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
|
||||
process.stderr.write(`${prefix}: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
});
|
||||
}
|
||||
|
|
@ -53,6 +53,134 @@ export function estimateTokens(bytes, kind = 'markdown', opts = {}) {
|
|||
return Math.ceil(bytes / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip block-level HTML comments (`<!-- ... -->`) that lie OUTSIDE fenced code
|
||||
* blocks. Claude Code strips these before injecting a CLAUDE.md / memory file
|
||||
* into context (code.claude.com/docs/en/memory: "block-level HTML comments are
|
||||
* stripped before the content is injected"), preserving them only inside fenced
|
||||
* code blocks (``` / ~~~). A byte-accurate token estimate must therefore discount
|
||||
* them. (M-BUG-6)
|
||||
*
|
||||
* Conservative scope — only *block-level* comments are removed (a comment that
|
||||
* occupies its own line(s)); inline comments sharing a line with other text are
|
||||
* retained, since the verified CC behavior covers block-level stripping only.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {string} content with out-of-fence block comments removed
|
||||
*/
|
||||
export function stripInjectedHtmlComments(content) {
|
||||
if (typeof content !== 'string' || content === '') return '';
|
||||
const lines = content.split('\n');
|
||||
const out = [];
|
||||
let inFence = false;
|
||||
let inComment = false;
|
||||
for (const line of lines) {
|
||||
if (inComment) {
|
||||
// Inside a multi-line block comment: drop lines until the closing `-->`,
|
||||
// keeping any real content that trails the close on the same line.
|
||||
const end = line.indexOf('-->');
|
||||
if (end !== -1) {
|
||||
inComment = false;
|
||||
const rest = line.slice(end + 3);
|
||||
if (rest.trim() !== '') out.push(rest);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Fence delimiters (``` / ~~~) toggle a preserve-verbatim region.
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
// Whole line is a single self-contained block comment → CC strips it.
|
||||
if (/^\s*<!--[\s\S]*?-->\s*$/.test(line)) continue;
|
||||
// Block comment opening with nothing but whitespace before it and no close
|
||||
// on this line → runs onto following lines.
|
||||
const openIdx = line.indexOf('<!--');
|
||||
if (openIdx !== -1 && line.indexOf('-->', openIdx) === -1 && line.slice(0, openIdx).trim() === '') {
|
||||
inComment = true;
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective injected byte length of a CLAUDE.md / memory source: raw UTF-8 bytes
|
||||
* minus the block-level HTML comments CC strips before injection. Used wherever a
|
||||
* CLAUDE.md token estimate must reflect what actually enters context. (M-BUG-6)
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {number}
|
||||
*/
|
||||
export function effectiveMemoryBytes(content) {
|
||||
if (typeof content !== 'string') return 0;
|
||||
return Buffer.byteLength(stripInjectedHtmlComments(content), 'utf8');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Load-pattern model (v5.6 Foundation)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive how a config source loads into context and whether it survives a
|
||||
* `/compact`, from the published Claude Code loading model. Deterministic,
|
||||
* side-effect-free. `derivationConfidence` is 'confirmed' when a primary-doc
|
||||
* row nails the row (V-rows in docs/v5.5-steering-model-plan.md), 'inferred'
|
||||
* when reasoned from an analogue (so a renderer can choose to mark it).
|
||||
*
|
||||
* loadPattern ∈ { 'always', 'on-demand', 'external', 'unknown' }
|
||||
* survivesCompaction ∈ { 'yes', 'no', 'n/a' }
|
||||
*
|
||||
* @param {string} kind - source kind (see switch)
|
||||
* @param {{scoped?: boolean}} [opts] - kind-specific discriminators
|
||||
* @returns {{loadPattern:string, survivesCompaction:string, derivationConfidence:string}}
|
||||
*/
|
||||
export function deriveLoadPattern(kind, opts = {}) {
|
||||
const mk = (loadPattern, survivesCompaction, derivationConfidence) =>
|
||||
({ loadPattern, survivesCompaction, derivationConfidence });
|
||||
switch (kind) {
|
||||
// CLAUDE.md cascade
|
||||
case 'claude-md-root': return mk('always', 'yes', 'confirmed'); // V1
|
||||
case 'claude-md-nested': return mk('on-demand', 'no', 'confirmed'); // V3
|
||||
case 'claude-md-user':
|
||||
case 'claude-md-managed':
|
||||
case 'claude-md-import': return mk('always', 'yes', 'inferred');
|
||||
// Rules
|
||||
case 'rule':
|
||||
return opts.scoped
|
||||
? mk('on-demand', 'no', 'confirmed') // V2, V4 (loads on Read of a match)
|
||||
: mk('always', 'yes', 'confirmed'); // V1, V6 (unscoped = always-on)
|
||||
// Skills
|
||||
case 'skill-listing': return mk('always', 'n/a', 'confirmed'); // V7 (name+desc every turn)
|
||||
case 'skill-body': return mk('on-demand', 'n/a', 'confirmed'); // V7 (body on invoke)
|
||||
// Agents — name+description load for delegation each turn (skill analogue;
|
||||
// no primary-doc row pins it, so 'inferred').
|
||||
case 'agent': return mk('always', 'n/a', 'inferred');
|
||||
// Output styles modify the system prompt, re-sent every turn (V10, V12).
|
||||
case 'output-style': return mk('always', 'yes', 'confirmed');
|
||||
// Hooks run outside context (V18); the hook itself is external.
|
||||
case 'hook': return mk('external', 'n/a', 'confirmed');
|
||||
// MCP tool schemas are part of the per-turn payload (no explicit
|
||||
// compaction-survival row → 'inferred').
|
||||
case 'mcp': return mk('always', 'yes', 'inferred');
|
||||
// Slash-command body loads when the command is invoked (on-demand). No
|
||||
// primary-doc row pins the always-loaded command listing cost → 'inferred'.
|
||||
case 'command': return mk('on-demand', 'n/a', 'inferred');
|
||||
// Harness-config files (settings.json, keybindings.json, .mcp.json, hooks.json,
|
||||
// plugin.json, ~/.claude.json) are read by the CLI to configure the harness —
|
||||
// they are NOT injected into the model context, so they cost no per-turn
|
||||
// context tokens. 'external' = outside the context window (like hooks).
|
||||
case 'harness-config': return mk('external', 'n/a', 'inferred');
|
||||
default: return mk('unknown', 'n/a', 'inferred');
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Git root detection
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -144,7 +272,11 @@ export async function walkClaudeMdCascade(repoPath) {
|
|||
|
||||
const totalBytes = files.reduce((sum, f) => sum + f.bytes, 0);
|
||||
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
|
||||
const estimatedTokens = estimateTokens(totalBytes, 'markdown');
|
||||
// Token estimate is computed from the *effective* (injected) byte count — CC
|
||||
// strips block-level HTML comments before injection — while totalBytes stays
|
||||
// the honest on-disk figure. (M-BUG-6)
|
||||
const effectiveBytes = files.reduce((sum, f) => sum + (f.effectiveBytes ?? f.bytes), 0);
|
||||
const estimatedTokens = estimateTokens(effectiveBytes, 'markdown');
|
||||
|
||||
return { files, totalBytes, totalLines, estimatedTokens };
|
||||
}
|
||||
|
|
@ -159,6 +291,7 @@ async function tryAddClaudeMd(absPath, scope, parent, files, seen) {
|
|||
path: absPath,
|
||||
scope,
|
||||
bytes: s.size,
|
||||
effectiveBytes: effectiveMemoryBytes(content),
|
||||
lines: lineCount(content),
|
||||
parent,
|
||||
};
|
||||
|
|
@ -269,19 +402,120 @@ export async function readClaudeJsonProjectSlice(repoPath) {
|
|||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enumerate all plugins installed under ~/.claude/plugins/marketplaces.
|
||||
* For each plugin: counts commands, agents, skills, hooks, rules; reads version from plugin.json.
|
||||
* Enumerate the plugins Claude Code actually injects for a repo.
|
||||
*
|
||||
* Authoritative source is `~/.claude/plugins/installed_plugins.json` (the install
|
||||
* manifest) gated by the `enabledPlugins` toggle map. Only plugins that are both
|
||||
* installed AND `enabledPlugins[key] === true` are injected, so only those are
|
||||
* counted — each resolved to its ACTIVE `installPath`, which for polyrepo plugins
|
||||
* lives under `plugins/cache` (never under `plugins/marketplaces`, so the historic
|
||||
* marketplaces walk missed them entirely while also counting disabled/uninstalled
|
||||
* marketplaces plugins). Mirrors file-discovery.mjs's "trust installed_plugins.json"
|
||||
* contract: when the manifest is absent (test fixtures, pre-v2 installs) we cannot
|
||||
* tell enabled from installed, so we fall back to discovering everything under
|
||||
* `plugins/marketplaces` rather than silently dropping config. (M-BUG-1)
|
||||
*
|
||||
* @param {string} [repoPath] - when given, project/local-scoped installs and
|
||||
* project-level `enabledPlugins` overrides are resolved relative to it; omit for
|
||||
* HOME/global scope (only user-scope installs + user `enabledPlugins`).
|
||||
* @returns {Promise<Array<{name:string, path:string, version:string|null, commands:number, agents:number, skills:number, hooks:number, rules:number, totalBytes:number, estimatedTokens:number}>>}
|
||||
*/
|
||||
export async function enumeratePlugins() {
|
||||
export async function enumeratePlugins(repoPath) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
if (!home) return [];
|
||||
|
||||
const marketplacesRoot = join(home, '.claude', 'plugins', 'marketplaces');
|
||||
const pluginRoots = await discoverAllPluginsUnder(marketplacesRoot);
|
||||
const installed = await readInstalledPluginsManifest(home);
|
||||
|
||||
// Dedupe via realpath (symlinks are common)
|
||||
let pluginRoots;
|
||||
if (installed) {
|
||||
// Manifest present → inject only ENABLED plugins, from their active installPath.
|
||||
const enabled = await readEnabledPluginsMap(home, repoPath);
|
||||
pluginRoots = [];
|
||||
for (const [key, recs] of Object.entries(installed)) {
|
||||
if (enabled[key] !== true) continue; // not explicitly enabled → not injected
|
||||
const rec = pickActivePluginRecord(recs, repoPath);
|
||||
if (!rec || !rec.installPath) continue;
|
||||
try {
|
||||
await stat(rec.installPath); // skip enabled-but-missing installPaths
|
||||
pluginRoots.push(rec.installPath);
|
||||
} catch { /* installPath gone → not loadable */ }
|
||||
}
|
||||
} else {
|
||||
// No manifest → cannot tell enabled from installed → discover all on disk.
|
||||
pluginRoots = await discoverAllPluginsUnder(join(home, '.claude', 'plugins', 'marketplaces'));
|
||||
}
|
||||
|
||||
return buildPluginRecords(pluginRoots);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the install manifest's `plugins` map ({ "name@marketplace": [record, …] }).
|
||||
* Returns null when absent/unparseable so callers fall back to disk discovery.
|
||||
*/
|
||||
async function readInstalledPluginsManifest(home) {
|
||||
const p = join(home, '.claude', 'plugins', 'installed_plugins.json');
|
||||
let raw;
|
||||
try { raw = await readFile(p, 'utf-8'); } catch { return null; }
|
||||
const parsed = parseJson(raw);
|
||||
if (!parsed || !parsed.plugins || typeof parsed.plugins !== 'object') return null;
|
||||
return parsed.plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the `enabledPlugins` toggle map across the scopes Claude Code reads:
|
||||
* user settings.json, then (when repoPath given) project settings + local + the
|
||||
* ~/.claude.json project slice. Later scopes override earlier ones.
|
||||
*/
|
||||
async function readEnabledPluginsMap(home, repoPath) {
|
||||
const merged = {};
|
||||
const sources = [join(home, '.claude', 'settings.json')];
|
||||
if (repoPath) {
|
||||
sources.push(join(repoPath, '.claude', 'settings.json'));
|
||||
sources.push(join(repoPath, '.claude', 'settings.local.json'));
|
||||
}
|
||||
for (const s of sources) {
|
||||
try {
|
||||
const parsed = parseJson(await readFile(s, 'utf-8'));
|
||||
if (parsed && parsed.enabledPlugins && typeof parsed.enabledPlugins === 'object') {
|
||||
Object.assign(merged, parsed.enabledPlugins);
|
||||
}
|
||||
} catch { /* missing/unreadable scope */ }
|
||||
}
|
||||
if (repoPath) {
|
||||
try {
|
||||
const slice = await readClaudeJsonProjectSlice(repoPath);
|
||||
if (slice && slice.enabledPlugins && typeof slice.enabledPlugins === 'object') {
|
||||
Object.assign(merged, slice.enabledPlugins);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the applicable install record for a plugin. User-scope records apply
|
||||
* everywhere; project/local-scope records only when repoPath is within their
|
||||
* projectPath (so a project-scoped plugin never leaks into HOME/global scope).
|
||||
*/
|
||||
function pickActivePluginRecord(recs, repoPath) {
|
||||
if (!Array.isArray(recs) || recs.length === 0) return null;
|
||||
const applicable = recs.filter((r) => {
|
||||
if (!r || !r.installPath) return false;
|
||||
const scope = r.scope || 'user';
|
||||
if (scope === 'user') return true;
|
||||
if (!repoPath || !r.projectPath) return false;
|
||||
const target = normalizePath(resolve(repoPath));
|
||||
const pp = normalizePath(resolve(r.projectPath));
|
||||
return target === pp || target.startsWith(pp + sep);
|
||||
});
|
||||
return applicable.find((r) => (r.scope || 'user') === 'user') || applicable[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build plugin records from a list of plugin root paths: dedupe via realpath,
|
||||
* count items, read plugin.json name/version.
|
||||
*/
|
||||
async function buildPluginRecords(pluginRoots) {
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
for (const root of pluginRoots) {
|
||||
|
|
@ -413,14 +647,20 @@ async function countPluginItems(pluginRoot) {
|
|||
return counts;
|
||||
}
|
||||
|
||||
async function listMarkdownFiles(dir) {
|
||||
async function listMarkdownFiles(dir, recursive = false) {
|
||||
const out = [];
|
||||
let entries;
|
||||
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
// Opt-in recursion (M-BUG-3): CC scans agents dirs recursively, so agents
|
||||
// organized into subfolders must be enumerated too. Other callers stay flat.
|
||||
if (recursive) out.push(...await listMarkdownFiles(full, true));
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile()) continue;
|
||||
if (!e.name.endsWith('.md')) continue;
|
||||
const full = join(dir, e.name);
|
||||
try {
|
||||
const s = await stat(full);
|
||||
out.push({ path: full, size: s.size });
|
||||
|
|
@ -499,6 +739,153 @@ export async function enumerateSkills(pluginList = []) {
|
|||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rules, agents, output styles (v5.6 Foundation enumeration)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** True when `v` is a non-empty, non-whitespace string (a usable frontmatter field). */
|
||||
function hasText(v) {
|
||||
return typeof v === 'string' && v.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the project/user/plugin directory list for a per-kind enumerator.
|
||||
* Project + user dirs live under `.claude/<dir>`; plugins under each of the
|
||||
* given subpaths relative to the plugin root.
|
||||
*/
|
||||
function configDirs(repoPath, pluginList, subdir, pluginSubdirs = [subdir]) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const projectDir = join(repoPath, '.claude', subdir);
|
||||
const userDir = home ? join(home, '.claude', subdir) : null;
|
||||
const dirs = [];
|
||||
// M-BUG-4: when repoPath === $HOME (the `manifest --global` self-scan), the
|
||||
// project dir resolves to the same path as the user dir. Count it once, as
|
||||
// user scope, instead of enumerating the same directory twice.
|
||||
if (!(userDir && userDir === projectDir)) {
|
||||
dirs.push({ dir: projectDir, source: 'project', pluginName: null });
|
||||
}
|
||||
if (userDir) dirs.push({ dir: userDir, source: 'user', pluginName: null });
|
||||
for (const p of pluginList) {
|
||||
for (const sub of pluginSubdirs) {
|
||||
dirs.push({ dir: join(p.path, sub), source: 'plugin', pluginName: p.name });
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate rule files: `<repo>/.claude/rules/`, `~/.claude/rules/`, and each
|
||||
* plugin's `rules/` + `.claude/rules/`. A rule is path-scoped when its
|
||||
* frontmatter declares `paths:` (the only documented scoping field, V5) — which
|
||||
* determines its load pattern (scoped = on-demand, unscoped = always, V1/V2/V6).
|
||||
*
|
||||
* @param {string} repoPath
|
||||
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, scoped:boolean, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||
*/
|
||||
export async function enumerateRules(repoPath, pluginList = []) {
|
||||
const out = [];
|
||||
const dirs = configDirs(repoPath, pluginList, 'rules', ['rules', join('.claude', 'rules')]);
|
||||
for (const { dir, source, pluginName } of dirs) {
|
||||
const files = await listMarkdownFiles(dir);
|
||||
for (const f of files) {
|
||||
let scoped = false;
|
||||
try {
|
||||
const content = await readFile(f.path, 'utf-8');
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
scoped = !!(frontmatter && frontmatter.paths);
|
||||
} catch { /* unreadable → treat as unscoped */ }
|
||||
out.push({
|
||||
name: basename(f.path),
|
||||
source,
|
||||
pluginName,
|
||||
path: f.path,
|
||||
scoped,
|
||||
bytes: f.size,
|
||||
estimatedTokens: estimateTokens(f.size, 'markdown'),
|
||||
...deriveLoadPattern('rule', { scoped }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate agent definitions: `<repo>/.claude/agents/`, `~/.claude/agents/`,
|
||||
* and each plugin's `agents/`. Only name+description load for delegation each
|
||||
* turn, so cost is estimated like other frontmatter-only sources.
|
||||
*
|
||||
* @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}>>}
|
||||
*/
|
||||
export async function enumerateAgents(repoPath, pluginList = []) {
|
||||
const out = [];
|
||||
const lp = deriveLoadPattern('agent');
|
||||
const dirs = configDirs(repoPath, pluginList, 'agents');
|
||||
for (const { dir, source, pluginName } of dirs) {
|
||||
const files = await listMarkdownFiles(dir, true); // M-BUG-3: CC scans agents dirs recursively
|
||||
for (const f of files) {
|
||||
// M-BUG-5: CC registers a subagent only when its frontmatter declares both
|
||||
// `name` and `description` (docs: identity comes only from `name`; both are
|
||||
// required). Frontmatter-less / incomplete files are registration no-ops
|
||||
// that cost zero always-loaded tokens — don't count them as agents.
|
||||
let frontmatter;
|
||||
try {
|
||||
({ frontmatter } = parseFrontmatter(await readFile(f.path, 'utf-8')));
|
||||
} catch { continue; }
|
||||
if (!hasText(frontmatter && frontmatter.name) || !hasText(frontmatter && frontmatter.description)) continue;
|
||||
out.push({
|
||||
name: basename(f.path).replace(/\.md$/, ''),
|
||||
source,
|
||||
pluginName,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate output styles: `<repo>/.claude/output-styles/`,
|
||||
* `~/.claude/output-styles/`, and each plugin's `output-styles/`. An output
|
||||
* style modifies the system prompt and is re-sent every turn (V10, V12).
|
||||
* Foundation only enumerates them; the `keep-coding-instructions` /
|
||||
* `force-for-plugin` checks are the CA-OST scanner (v5.6 C).
|
||||
*
|
||||
* @param {string} repoPath
|
||||
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||
*/
|
||||
export async function enumerateOutputStyles(repoPath, pluginList = []) {
|
||||
const out = [];
|
||||
const lp = deriveLoadPattern('output-style');
|
||||
const dirs = configDirs(repoPath, pluginList, 'output-styles');
|
||||
for (const { dir, source, pluginName } of dirs) {
|
||||
const files = await listMarkdownFiles(dir);
|
||||
for (const f of files) {
|
||||
out.push({
|
||||
name: basename(f.path).replace(/\.md$/, ''),
|
||||
source,
|
||||
pluginName,
|
||||
path: f.path,
|
||||
bytes: f.size,
|
||||
estimatedTokens: estimateTokens(f.size, 'markdown'),
|
||||
...lp,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Hooks (user + project + plugin)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -610,6 +997,7 @@ export async function readActiveMcpServers(repoPath, claudeJsonSlice = null, plu
|
|||
toolCount,
|
||||
toolCountUnknown: detected.toolCountUnknown,
|
||||
estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }),
|
||||
alwaysLoad: def?.alwaysLoad === true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -639,6 +1027,7 @@ async function collectMcpFromFile(path, source, disabled, out, repoPath) {
|
|||
toolCount,
|
||||
toolCountUnknown: detected.toolCountUnknown,
|
||||
estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }),
|
||||
alwaysLoad: def?.alwaysLoad === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -837,15 +1226,18 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
detectGitRoot(absRepoPath),
|
||||
walkClaudeMdCascade(absRepoPath),
|
||||
readClaudeJsonProjectSlice(absRepoPath),
|
||||
enumeratePlugins(),
|
||||
enumeratePlugins(absRepoPath),
|
||||
readSettingsCascade(absRepoPath),
|
||||
]);
|
||||
|
||||
// Skills depend on plugins
|
||||
const [skills, hooks, mcpServers] = await Promise.all([
|
||||
// Skills, hooks, MCP, and the v5.6 enumerations all depend on plugins
|
||||
const [skills, hooks, mcpServers, rules, agents, outputStyles] = await Promise.all([
|
||||
enumerateSkills(plugins),
|
||||
readActiveHooks(absRepoPath, plugins),
|
||||
readActiveMcpServers(absRepoPath, claudeJsonSlice, plugins),
|
||||
enumerateRules(absRepoPath, plugins),
|
||||
enumerateAgents(absRepoPath, plugins),
|
||||
enumerateOutputStyles(absRepoPath, plugins),
|
||||
]);
|
||||
|
||||
// Totals
|
||||
|
|
@ -854,6 +1246,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
skills: skills.length,
|
||||
mcpServers: mcpServers.length,
|
||||
hooks: hooks.length,
|
||||
rules: rules.length,
|
||||
agents: agents.length,
|
||||
outputStyles: outputStyles.length,
|
||||
claudeMdFiles: claudeMd.files.length,
|
||||
estimatedTokens: {
|
||||
claudeMd: claudeMd.estimatedTokens,
|
||||
|
|
@ -861,6 +1256,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
skills: skills.reduce((s, k) => s + k.estimatedTokens, 0),
|
||||
mcpServers: mcpServers.reduce((s, m) => s + m.estimatedTokens, 0),
|
||||
hooks: hooks.reduce((s, h) => s + h.estimatedTokens, 0),
|
||||
rules: rules.reduce((s, r) => s + r.estimatedTokens, 0),
|
||||
agents: agents.reduce((s, a) => s + a.estimatedTokens, 0),
|
||||
outputStyles: outputStyles.reduce((s, o) => s + o.estimatedTokens, 0),
|
||||
grandTotal: 0,
|
||||
},
|
||||
};
|
||||
|
|
@ -869,7 +1267,10 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
totals.estimatedTokens.plugins +
|
||||
totals.estimatedTokens.skills +
|
||||
totals.estimatedTokens.mcpServers +
|
||||
totals.estimatedTokens.hooks;
|
||||
totals.estimatedTokens.hooks +
|
||||
totals.estimatedTokens.rules +
|
||||
totals.estimatedTokens.agents +
|
||||
totals.estimatedTokens.outputStyles;
|
||||
|
||||
const warnings = [];
|
||||
|
||||
|
|
@ -898,6 +1299,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
skills,
|
||||
mcpServers,
|
||||
hooks,
|
||||
rules,
|
||||
agents,
|
||||
outputStyles,
|
||||
settings: { cascade: settingsCascade },
|
||||
totals,
|
||||
suggestDisables,
|
||||
|
|
|
|||
51
scanners/lib/active-model.mjs
Normal file
51
scanners/lib/active-model.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Active-model resolution for the `--context-window auto` probe (B8b).
|
||||
*
|
||||
* Reads the configured model the way Claude Code itself resolves it, so the
|
||||
* window probe (context-window.mjs `modelToContextWindow`) sees the real model:
|
||||
* 1. the shell `ANTHROPIC_MODEL` override (applies to the launched session);
|
||||
* 2. otherwise the settings cascade `model` field — user `~/.claude`, then
|
||||
* project `.claude`, then project-local `.claude` (local > project > user).
|
||||
*
|
||||
* Reads the cascade files directly (like isBundledSkillsDisabled) rather than via
|
||||
* config-discovery classification, and takes an injectable `env` so it is
|
||||
* deterministic and hermetic under the test HOME. Returns null when no model is
|
||||
* pinned anywhere — the honest signal that `auto` must fall back to advisory.
|
||||
*
|
||||
* Zero external dependencies (repo invariant).
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { readTextFile } from './file-discovery.mjs';
|
||||
import { parseJson } from './yaml-parser.mjs';
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} projectPath - project root, to also read project + local settings
|
||||
* @param {{ env?: Record<string,string|undefined> }} [opts]
|
||||
* @returns {Promise<string|null>} the resolved model id/alias, or null if unset
|
||||
*/
|
||||
export async function resolveActiveModel(projectPath, { env = process.env } = {}) {
|
||||
// 1. Shell ANTHROPIC_MODEL overrides settings (CC: applies to the session).
|
||||
const envModel = typeof env?.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL.trim() : '';
|
||||
if (envModel) return envModel;
|
||||
|
||||
// 2. Settings cascade: user -> project -> project-local, later wins.
|
||||
const home = (env && (env.HOME || env.USERPROFILE)) || '';
|
||||
const candidates = [];
|
||||
if (home) candidates.push(join(home, '.claude', 'settings.json'));
|
||||
if (projectPath) {
|
||||
candidates.push(join(projectPath, '.claude', 'settings.json'));
|
||||
candidates.push(join(projectPath, '.claude', 'settings.local.json'));
|
||||
}
|
||||
|
||||
let model = null;
|
||||
for (const p of candidates) {
|
||||
const content = await readTextFile(p);
|
||||
if (!content) continue;
|
||||
const parsed = parseJson(content);
|
||||
if (parsed && typeof parsed.model === 'string' && parsed.model.trim()) {
|
||||
model = parsed.model.trim();
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}
|
||||
132
scanners/lib/agent-listing-budget.mjs
Normal file
132
scanners/lib/agent-listing-budget.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Agent-listing budget — single source of truth for the AGT scanner.
|
||||
*
|
||||
* Claude Code injects a listing of every active agent's name+description into the
|
||||
* system prompt so the model knows which subagents it can delegate to. With many
|
||||
* installed agents that listing is a large always-loaded cost: re-sent every turn
|
||||
* whether or not a delegation actually happens.
|
||||
*
|
||||
* CRUCIAL HONESTY CAVEAT — this is why AGT differs from SKL. Unlike the skill
|
||||
* listing (documented ~2% allotment, CC 2.1.32; verified 1,536-char truncation
|
||||
* cap, CC 2.1.105), the agent-listing mechanism is NOT documented — agents are
|
||||
* absent from Claude Code's published context breakdown. Therefore:
|
||||
* - "always-loaded" is INFERRED (reasoned from the skill analogue + agents'
|
||||
* absence from the deferred/on-demand list), not documented.
|
||||
* - the token figure is an UPPER-BOUND ESTIMATE, not measured telemetry.
|
||||
* - there is no documented per-listing allotment, so the aggregate budget is a
|
||||
* config-audit heuristic anchored — by analogy to the skill listing — on a
|
||||
* conservative 200k window, and the evidence says so loudly.
|
||||
* - there is no verified per-description truncation cap, so (unlike CA-SKL-001)
|
||||
* each description contributes its FULL length to the aggregate.
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { estimateTokens, enumeratePlugins, enumerateAgents } from './active-config-reader.mjs';
|
||||
import { readTextFile } from './file-discovery.mjs';
|
||||
import { parseFrontmatter } from './yaml-parser.mjs';
|
||||
import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas } from './context-window.mjs';
|
||||
|
||||
// Heuristic budget by analogy to the skill listing's documented ~2% allotment.
|
||||
// Agents have NO documented allotment of their own — disclosed loudly in
|
||||
// BUDGET_CALIBRATION_NOTE so the number is never mistaken for a CC guarantee.
|
||||
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
|
||||
export { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, withCommas };
|
||||
|
||||
// Per-agent soft cap for the description-bloat advisory. This is a HEURISTIC,
|
||||
// NOT a truncation cap: agents have no verified per-description limit (unlike
|
||||
// the skill listing's 1,536-char cap, CC 2.1.105). We reuse the 500-char
|
||||
// bloat threshold TOK pattern F already applies to SKILL.md descriptions, so a
|
||||
// long agent description is flagged for the same reason — every char re-enters
|
||||
// context in the always-loaded agent listing on every turn — without ever
|
||||
// claiming Claude Code drops the tail.
|
||||
export const PER_AGENT_DESC_SOFT_CAP = 500;
|
||||
|
||||
// The honest framing required because (a) the mechanism is inferred and (b) the
|
||||
// budget depends on a context window we cannot observe. Appended to overflow evidence.
|
||||
export const BUDGET_CALIBRATION_NOTE =
|
||||
'the agent-listing always-loaded mechanism is INFERRED, not documented (agents are absent from ' +
|
||||
"Claude Code's published context breakdown), so this token figure is an UPPER-BOUND ESTIMATE, not " +
|
||||
'measured telemetry. unlike the skill listing there is no documented per-listing allotment, so this ' +
|
||||
'budget is a config-audit heuristic anchored on a conservative 200k window; at ' +
|
||||
`${withCommas(LARGE_CONTEXT_WINDOW)} context the budget is ~${withCommas(LARGE_CONTEXT_BUDGET_TOKENS)} ` +
|
||||
'tok and you are likely within it';
|
||||
|
||||
/**
|
||||
* @typedef {object} AgentBudgetAssessment
|
||||
* @property {number} scanned - number of agent descriptions assessed
|
||||
* @property {number} aggregateChars - sum of description lengths (no cap; no verified truncation)
|
||||
* @property {number} aggregateTokens - estimateTokens(aggregateChars, 'markdown')
|
||||
* @property {number} budgetTokens - AGGREGATE_BUDGET_TOKENS (the 200k-anchored heuristic)
|
||||
* @property {boolean} overBudget - aggregateTokens strictly greater than budgetTokens
|
||||
* @property {number} overBy - tokens over budget (0 when not over)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pure aggregate-budget assessment. Each description contributes its full length:
|
||||
* agents have no verified truncation cap, so nothing is dropped from the estimate.
|
||||
*
|
||||
* @param {number[]} descLengths - one entry per active agent (description char count)
|
||||
* @returns {AgentBudgetAssessment}
|
||||
*/
|
||||
export function assessAgentListingBudget(descLengths) {
|
||||
let aggregateChars = 0;
|
||||
for (const len of descLengths) {
|
||||
const safe = (typeof len === 'number' && Number.isFinite(len) && len > 0) ? len : 0;
|
||||
aggregateChars += safe;
|
||||
}
|
||||
const aggregateTokens = estimateTokens(aggregateChars, 'markdown');
|
||||
const overBudget = aggregateTokens > AGGREGATE_BUDGET_TOKENS;
|
||||
return {
|
||||
scanned: descLengths.length,
|
||||
aggregateChars,
|
||||
aggregateTokens,
|
||||
budgetTokens: AGGREGATE_BUDGET_TOKENS,
|
||||
overBudget,
|
||||
overBy: overBudget ? aggregateTokens - AGGREGATE_BUDGET_TOKENS : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} ActiveAgentEntry
|
||||
* @property {string} name
|
||||
* @property {'user'|'plugin'} source
|
||||
* @property {string|null} pluginName
|
||||
* @property {string} path
|
||||
* @property {number} descLength
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enumerate every active agent (user + plugin) and measure the listing budget.
|
||||
* HOME-scoped (mirrors the skill listing): resolves ~/.claude via process.env.HOME
|
||||
* and excludes project-scoped agents — those load only inside their own repo.
|
||||
* Callers that run under test MUST override HOME (runScannerWithHome pattern).
|
||||
*
|
||||
* @returns {Promise<{ agents: ActiveAgentEntry[], aggregate: AgentBudgetAssessment }>}
|
||||
*/
|
||||
export async function measureActiveAgentListing() {
|
||||
const plugins = await enumeratePlugins();
|
||||
// enumerateAgents yields project + user + plugin; drop project (repo-local).
|
||||
const allAgents = await enumerateAgents('', plugins);
|
||||
|
||||
const agents = [];
|
||||
for (const agent of allAgents) {
|
||||
if (!agent || agent.source === 'project' || typeof agent.path !== 'string') continue;
|
||||
const content = await readTextFile(agent.path);
|
||||
if (!content) continue;
|
||||
const fm = parseFrontmatter(content)?.frontmatter || null;
|
||||
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
|
||||
agents.push({
|
||||
name: agent.name,
|
||||
source: agent.source,
|
||||
pluginName: agent.pluginName,
|
||||
path: agent.path,
|
||||
descLength: desc.length,
|
||||
});
|
||||
}
|
||||
|
||||
const aggregate = assessAgentListingBudget(agents.map((a) => a.descLength));
|
||||
return { agents, aggregate };
|
||||
}
|
||||
|
|
@ -10,15 +10,29 @@ import { join, basename } from 'node:path';
|
|||
import { createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const BACKUP_ROOT = join(homedir(), '.config-audit', 'backups');
|
||||
const MAX_BACKUPS = 10;
|
||||
|
||||
/**
|
||||
* Get the backup root directory path.
|
||||
*
|
||||
* Canonical location is `~/.claude/config-audit/backups` — the path every
|
||||
* command, agent and doc uses. `CONFIG_AUDIT_BACKUP_ROOT` overrides it so tests
|
||||
* never write into the operator's real home.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBackupDir() {
|
||||
return BACKUP_ROOT;
|
||||
return process.env.CONFIG_AUDIT_BACKUP_ROOT
|
||||
|| join(homedir(), '.claude', 'config-audit', 'backups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pre-v2.2.0 backup root. Read-only: nothing writes here any more, but
|
||||
* backups made before the move must stay listable and restorable.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getLegacyBackupDir() {
|
||||
return process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT
|
||||
|| join(homedir(), '.config-audit', 'backups');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -56,22 +70,35 @@ export function checksum(content) {
|
|||
|
||||
/**
|
||||
* Create a backup of the specified files.
|
||||
*
|
||||
* `opts.created` records paths the caller is about to CREATE. No backup can
|
||||
* hold a file that does not exist yet, so these are not copied — they are
|
||||
* written into the manifest so `rollback` can tell the user which files it is
|
||||
* leaving behind. That list used to exist only in `commands/implement.md`,
|
||||
* typed out by hand next to a manifest the template also typed out by hand;
|
||||
* moving it here is what lets the template stop owning the format (R2).
|
||||
*
|
||||
* @param {string[]} files - Array of absolute file paths to back up
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.backupId] - Override backup ID (for testing)
|
||||
* @returns {{ backupId: string, backupPath: string, manifest: object }}
|
||||
* @param {string[]} [opts.created] - Paths this run will create (recorded, not copied)
|
||||
* @returns {{ backupId: string, backupPath: string, manifest: object, skipped: string[] }}
|
||||
*/
|
||||
export function createBackup(files, opts = {}) {
|
||||
const backupId = opts.backupId || generateBackupId();
|
||||
const backupPath = join(BACKUP_ROOT, backupId);
|
||||
const backupPath = join(getBackupDir(), backupId);
|
||||
const filesDir = join(backupPath, 'files');
|
||||
|
||||
mkdirSync(filesDir, { recursive: true });
|
||||
|
||||
const manifestFiles = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!existsSync(file)) continue;
|
||||
// A target that is not there is reported, never silently dropped: the
|
||||
// caller asked for a backup of N files and must be able to learn it got
|
||||
// fewer, before it edits anything.
|
||||
if (!existsSync(file)) { skipped.push(file); continue; }
|
||||
|
||||
const safeName = safeFileName(file);
|
||||
copyFileSync(file, join(filesDir, safeName));
|
||||
|
|
@ -92,6 +119,7 @@ export function createBackup(files, opts = {}) {
|
|||
created_at: new Date().toISOString(),
|
||||
backup_id: backupId,
|
||||
files: manifestFiles,
|
||||
created: [...(opts.created || [])],
|
||||
};
|
||||
|
||||
// Write manifest as YAML-like format
|
||||
|
|
@ -101,7 +129,7 @@ export function createBackup(files, opts = {}) {
|
|||
// Cleanup old backups
|
||||
cleanupOldBackups();
|
||||
|
||||
return { backupId, backupPath, manifest };
|
||||
return { backupId, backupPath, manifest, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -119,6 +147,14 @@ function serializeManifest(manifest) {
|
|||
yaml += ` checksum: "${f.checksum}"\n`;
|
||||
yaml += ` size_bytes: ${f.sizeBytes}\n`;
|
||||
}
|
||||
// Emitted only when non-empty, and read back by `parseManifest`'s `created:`
|
||||
// branch — the bare-key form, which is why the implement flow's
|
||||
// `created: <timestamp>` (a VALUE, meaning the backup id) never collides
|
||||
// with it.
|
||||
if (manifest.created && manifest.created.length > 0) {
|
||||
yaml += `created:\n`;
|
||||
for (const c of manifest.created) yaml += ` - ${c}\n`;
|
||||
}
|
||||
return yaml;
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +164,7 @@ function serializeManifest(manifest) {
|
|||
* @returns {object}
|
||||
*/
|
||||
export function parseManifest(content) {
|
||||
const result = { created_at: '', backup_id: '', files: [] };
|
||||
const result = { created_at: '', backup_id: '', files: [], created: [] };
|
||||
|
||||
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
|
||||
if (createdMatch) result.created_at = createdMatch[1];
|
||||
|
|
@ -136,7 +172,7 @@ export function parseManifest(content) {
|
|||
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
|
||||
if (idMatch) result.backup_id = idMatch[1];
|
||||
|
||||
// Parse file entries
|
||||
// Parse file entries — engine format (quoted `original_path:` …).
|
||||
const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1);
|
||||
for (const block of fileBlocks) {
|
||||
const origMatch = block.match(/^\s*"([^"]+)"/);
|
||||
|
|
@ -154,6 +190,48 @@ export function parseManifest(content) {
|
|||
}
|
||||
}
|
||||
|
||||
// Parse file entries — implement-flow format. Until R2, `commands/implement.md`
|
||||
// had the agent hand-build the backup dir, so manifests written by that flow
|
||||
// use unquoted `- backup:` / `original:` / `sha256:`. Reading only the engine
|
||||
// format made restoreBackup a success-shaped no-op on every backup implement
|
||||
// produced (M-BUG-25). The template no longer writes this format, but the
|
||||
// branch stays: backups already on disk in it must remain restorable — the
|
||||
// same reason `getLegacyBackupDir()` is still read.
|
||||
if (result.files.length === 0) {
|
||||
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
|
||||
for (const block of implBlocks) {
|
||||
const bpMatch = block.match(/^\s*(\S+)/);
|
||||
const origMatch = block.match(/original:\s*(\S+)/);
|
||||
const csMatch = block.match(/sha256:\s*(\S+)/);
|
||||
|
||||
if (origMatch && bpMatch && csMatch) {
|
||||
result.files.push({
|
||||
originalPath: origMatch[1],
|
||||
backupPath: bpMatch[1],
|
||||
checksum: csMatch[1],
|
||||
sizeBytes: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.backup_id) {
|
||||
const implId = content.match(/^created:\s*(\S+)\s*$/m);
|
||||
if (implId) result.backup_id = implId[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Files the implement step CREATED. A backup cannot hold a file that did not
|
||||
// exist, so rollback can never restore these — but it must be able to say so.
|
||||
const lines = content.split('\n');
|
||||
const createdAt = lines.findIndex(l => /^created:[ \t]*$/.test(l));
|
||||
if (createdAt !== -1) {
|
||||
for (const line of lines.slice(createdAt + 1)) {
|
||||
const item = line.match(/^[ \t]+-[ \t]+(\S+)[ \t]*$/);
|
||||
if (!item) break;
|
||||
result.created.push(item[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -161,9 +239,10 @@ export function parseManifest(content) {
|
|||
* Remove old backups beyond MAX_BACKUPS.
|
||||
*/
|
||||
function cleanupOldBackups() {
|
||||
if (!existsSync(BACKUP_ROOT)) return;
|
||||
const backupRoot = getBackupDir();
|
||||
if (!existsSync(backupRoot)) return;
|
||||
|
||||
const dirs = readdirSync(BACKUP_ROOT, { withFileTypes: true })
|
||||
const dirs = readdirSync(backupRoot, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
.sort();
|
||||
|
|
@ -171,7 +250,7 @@ function cleanupOldBackups() {
|
|||
if (dirs.length > MAX_BACKUPS) {
|
||||
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
|
||||
for (const dir of toDelete) {
|
||||
rmSync(join(BACKUP_ROOT, dir), { recursive: true, force: true });
|
||||
rmSync(join(backupRoot, dir), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
113
scanners/lib/best-practices-register.mjs
Normal file
113
scanners/lib/best-practices-register.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* best-practices-register — loader + schema validator for the machine-readable
|
||||
* best-practices register (knowledge/best-practices.json).
|
||||
*
|
||||
* The register is the SOURCE OF TRUTH for the v5.7 optimization lens (CA-OPT). Each entry
|
||||
* is provenance-stamped (source.url + source.verified) and carries a confidence; only
|
||||
* CONFIRMED claims are surfaced user-facing (Verifiseringsplikt). Zero-dependency: native
|
||||
* JSON, validated by hand here. See docs/v5.7-optimization-lens-plan.md.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SEVERITY } from './severity.mjs';
|
||||
|
||||
/** Absolute path to the bundled register. */
|
||||
export const REGISTER_PATH = fileURLToPath(
|
||||
new URL('../../knowledge/best-practices.json', import.meta.url)
|
||||
);
|
||||
|
||||
/** Confidence levels. Only `confirmed` is consumed user-facing by the lens. */
|
||||
export const CONFIDENCE_LEVELS = Object.freeze(['confirmed', 'inferred', 'unverified']);
|
||||
|
||||
const VALID_SEVERITIES = new Set(Object.keys(SEVERITY));
|
||||
const ID_RE = /^BP-[A-Z]+-\d{3}$/;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Load + parse a register file (defaults to the bundled one). Throws on missing file or
|
||||
* invalid JSON — callers that want graceful handling should try/catch.
|
||||
* @param {string} [path]
|
||||
* @returns {{version:number, entries:object[]}}
|
||||
*/
|
||||
export function loadRegister(path = REGISTER_PATH) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed register against the schema. Never throws — returns a result so the
|
||||
* caller (and tests) can inspect every problem at once.
|
||||
* @param {unknown} data
|
||||
* @returns {{valid:boolean, errors:string[]}}
|
||||
*/
|
||||
export function validateRegister(data) {
|
||||
const errors = [];
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return { valid: false, errors: ['register must be an object'] };
|
||||
}
|
||||
if (typeof data.version !== 'number') errors.push('version must be a number');
|
||||
if (!Array.isArray(data.entries)) {
|
||||
errors.push('entries must be an array');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
data.entries.forEach((e, i) => {
|
||||
const at = `entry[${i}]${e && typeof e === 'object' && e.id ? ` (${e.id})` : ''}`;
|
||||
if (!e || typeof e !== 'object' || Array.isArray(e)) {
|
||||
errors.push(`${at}: must be an object`);
|
||||
return;
|
||||
}
|
||||
// id — required, BP-TOPIC-NNN, unique
|
||||
if (typeof e.id !== 'string' || !ID_RE.test(e.id)) {
|
||||
errors.push(`${at}: id must match BP-TOPIC-NNN`);
|
||||
} else if (seen.has(e.id)) {
|
||||
errors.push(`${at}: duplicate id`);
|
||||
} else {
|
||||
seen.add(e.id);
|
||||
}
|
||||
// claim — required, non-empty
|
||||
if (typeof e.claim !== 'string' || e.claim.trim() === '') {
|
||||
errors.push(`${at}: claim is required`);
|
||||
}
|
||||
// confidence — required, enum
|
||||
if (!CONFIDENCE_LEVELS.includes(e.confidence)) {
|
||||
errors.push(`${at}: confidence must be one of ${CONFIDENCE_LEVELS.join('|')}`);
|
||||
}
|
||||
// source — required object with url + verified date (provenance)
|
||||
if (!e.source || typeof e.source !== 'object' || Array.isArray(e.source)) {
|
||||
errors.push(`${at}: source is required`);
|
||||
} else {
|
||||
if (typeof e.source.url !== 'string' || e.source.url.trim() === '') {
|
||||
errors.push(`${at}: source.url is required`);
|
||||
}
|
||||
if (typeof e.source.verified !== 'string' || !DATE_RE.test(e.source.verified)) {
|
||||
errors.push(`${at}: source.verified must be a YYYY-MM-DD date`);
|
||||
}
|
||||
}
|
||||
// optional fields — typed only when present
|
||||
if (e.severity !== undefined && !VALID_SEVERITIES.has(e.severity)) {
|
||||
errors.push(`${at}: severity must be one of ${[...VALID_SEVERITIES].join('|')}`);
|
||||
}
|
||||
for (const key of ['mechanism', 'appliesTo', 'recommendation', 'category']) {
|
||||
if (e[key] !== undefined && typeof e[key] !== 'string') {
|
||||
errors.push(`${at}: ${key} must be a string`);
|
||||
}
|
||||
}
|
||||
if (e.lensCheck !== undefined && e.lensCheck !== null && typeof e.lensCheck !== 'string') {
|
||||
errors.push(`${at}: lensCheck must be a string or null`);
|
||||
}
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an entry by id.
|
||||
* @param {{entries:object[]}} register
|
||||
* @param {string} id
|
||||
* @returns {object|undefined}
|
||||
*/
|
||||
export function getEntry(register, id) {
|
||||
if (!register || !Array.isArray(register.entries)) return undefined;
|
||||
return register.entries.find((e) => e.id === id);
|
||||
}
|
||||
78
scanners/lib/campaign-export.mjs
Normal file
78
scanners/lib/campaign-export.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* campaign-export — plan-export transforms (v5.7 Fase 2, Block 4c).
|
||||
*
|
||||
* The second half of Block 4 ("durable backlog + execution"). Block 4b built the cross-repo
|
||||
* prioritized backlog the user picks from; this exports a picked repo's per-repo action plan
|
||||
* into the TARGET repo's OWN `docs/` directory, so the plan gets a durable, human-readable
|
||||
* home where the work is done ("planer følger arbeidsstedet" — the operator's continuity rule
|
||||
* that plans live next to the workplace, in `docs/`).
|
||||
*
|
||||
* Design mirrors campaign-ledger: PURE, deterministic transforms — `now` is injected as a
|
||||
* YYYY-MM-DD string, never read from the clock here, so they are fully unit-testable. The IO
|
||||
* (loading the ledger, reading the session's action-plan.md, writing the exported file) lives
|
||||
* in the thin `campaign-export-cli` shell. The transforms throw on programmer error
|
||||
* (missing/blank required field), consistent with the ledger transforms.
|
||||
*
|
||||
* NOTE on execution: Block 4c deliberately adds NO new execution machinery. Execution reuses
|
||||
* the existing per-repo `/config-audit implement` (which backs up every changed file, applies
|
||||
* the plan from the session, and verifies) + `/config-audit rollback`. The exported `docs/`
|
||||
* copy is the repo's durable record of the plan, NOT the execution input — `implement` still
|
||||
* reads the canonical plan from the session directory. See docs/v5.7-optimization-lens-plan.md
|
||||
* §Fase 2 (Block 4).
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* The exported plan's destination inside the TARGET repo's own `docs/`. Keyed on the source
|
||||
* `sessionId` (timestamp-unique per audit) rather than the calendar date, so two audits of the
|
||||
* same repo on the same day produce distinct files (history is preserved, never silently
|
||||
* overwritten) and the filename ties the export back to the audit that produced it.
|
||||
*
|
||||
* @param {string} repoPath - absolute path to the target repo (the ledger stores it resolved)
|
||||
* @param {string} sessionId - the config-audit session that produced the plan
|
||||
* @returns {string} `<repoPath>/docs/config-audit-plan-<sessionId>.md`
|
||||
*/
|
||||
export function planExportPath(repoPath, sessionId) {
|
||||
if (typeof repoPath !== 'string' || repoPath.trim() === '') {
|
||||
throw new TypeError('repoPath is required');
|
||||
}
|
||||
if (typeof sessionId !== 'string' || sessionId.trim() === '') {
|
||||
throw new TypeError('sessionId is required');
|
||||
}
|
||||
return join(repoPath, 'docs', `config-audit-plan-${sessionId}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the exported document: a provenance header (who/when/where this came from + how to
|
||||
* execute and undo it) followed by the verbatim session plan body. Pure — given the same inputs
|
||||
* it always produces the same bytes, so it is snapshot-testable.
|
||||
*
|
||||
* @param {{repoName:string, repoPath:string, sessionId:string, planMarkdown:string, now:string}} input
|
||||
* @returns {string} the full markdown to write into the repo's docs/
|
||||
*/
|
||||
export function buildPlanExportDocument({ repoName, repoPath, sessionId, planMarkdown, now } = {}) {
|
||||
for (const [k, v] of Object.entries({ repoName, repoPath, sessionId, planMarkdown, now })) {
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
throw new TypeError(`${k} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
const header = [
|
||||
`# Config-Audit Action Plan — ${repoName}`,
|
||||
'',
|
||||
`> Exported from the config-audit machine-wide campaign on ${now}.`,
|
||||
`> **Repo:** \`${repoPath}\``,
|
||||
`> **Source session:** \`${sessionId}\``,
|
||||
'>',
|
||||
'> Generated by `/config-audit plan`. To **execute**: run `/config-audit implement` in this',
|
||||
'> repo — it backs up every changed file, applies the plan, then verifies the result. To',
|
||||
'> **undo**: `/config-audit rollback`. Record progress back in the campaign with',
|
||||
`> \`/config-audit campaign set-status ${repoPath} implemented\`.`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
return `${header}${planMarkdown.trimEnd()}\n`;
|
||||
}
|
||||
369
scanners/lib/campaign-ledger.mjs
Normal file
369
scanners/lib/campaign-ledger.mjs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
/**
|
||||
* campaign-ledger — durable, machine-wide campaign ledger (v5.7 Fase 2, Block 3a THIN).
|
||||
*
|
||||
* The ledger sits ABOVE individual config-audit sessions: it tracks which repos are part
|
||||
* of a machine-wide audit campaign, each repo's lifecycle status (pending → audited →
|
||||
* planned → implemented), and a machine-wide roll-up by status + severity. It is resumable
|
||||
* across sessions because it persists to a single JSON file OUTSIDE the plugin dir
|
||||
* (`~/.claude/config-audit/campaign-ledger.json`, next to `sessions/` and `mcp-cache/`),
|
||||
* so it survives plugin uninstall/reinstall/upgrade.
|
||||
*
|
||||
* Design mirrors the knowledge-refresh precedent: the transformations are PURE and
|
||||
* deterministic (every "now" is injected as a YYYY-MM-DD string, never read from the clock
|
||||
* here), so they are fully unit-testable; a thin IO shell (load/save, explicit path) does
|
||||
* the only filesystem work. `validateLedger` is soft (returns a result, never throws) for
|
||||
* externally-loaded data; the transforms throw on programmer error (invalid status, unknown
|
||||
* path). `schemaVersion` is stamped from the start so a future Block 4 migration is cheap.
|
||||
*
|
||||
* THIN scope (Block 3a): ledger core + roll-up + persistence only — NOT execution,
|
||||
* orchestration, or a command surface (those are Blocks 3b/3c/4). Zero dependencies.
|
||||
* See docs/v5.7-optimization-lens-plan.md §Fase 2.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/** Ledger schema version — bump + add a migration (Block 4) on any breaking shape change. */
|
||||
export const CAMPAIGN_SCHEMA_VERSION = 1;
|
||||
|
||||
/** Per-repo lifecycle, in order. A repo advances through these as the campaign progresses. */
|
||||
export const STATUSES = Object.freeze(['pending', 'audited', 'planned', 'implemented']);
|
||||
|
||||
/** Severity buckets aggregated by the machine-wide roll-up. */
|
||||
const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
|
||||
|
||||
/**
|
||||
* Order-of-magnitude severity weights for the cross-repo backlog priority score. Each tier
|
||||
* dominates the next so a single higher-severity finding outranks many lower ones; exact
|
||||
* score collisions are still broken deterministically by the lexicographic + name tie-break
|
||||
* in `buildBacklog`. Exported so the score is documented, not a magic number.
|
||||
*/
|
||||
export const SEVERITY_WEIGHTS = Object.freeze({ critical: 1000, high: 100, medium: 10, low: 1 });
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Validate an injected `now` (required, YYYY-MM-DD). Throws — callers pass today's date. */
|
||||
function requireNow(now) {
|
||||
if (typeof now !== 'string' || !DATE_RE.test(now)) {
|
||||
throw new TypeError('now must be a YYYY-MM-DD string');
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
/** Canonicalize a repo path so the same repo never appears twice under different spellings. */
|
||||
function normalizePath(path) {
|
||||
if (typeof path !== 'string' || path.trim() === '') {
|
||||
throw new TypeError('repo path is required');
|
||||
}
|
||||
return resolve(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an empty, versioned ledger stamped with `now`.
|
||||
* @param {{now: string}} opts
|
||||
* @returns {{schemaVersion:number, createdDate:string, updatedDate:string, repos:object[]}}
|
||||
*/
|
||||
export function createLedger({ now } = {}) {
|
||||
requireNow(now);
|
||||
return { schemaVersion: CAMPAIGN_SCHEMA_VERSION, createdDate: now, updatedDate: now, repos: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a repo to the campaign (status `pending`). Idempotent on the normalized path — a repo
|
||||
* already present is left untouched (its progress is NOT reset). Returns a NEW ledger.
|
||||
* @param {object} ledger
|
||||
* @param {{path: string, name?: string}} repo
|
||||
* @param {{now: string}} opts
|
||||
*/
|
||||
export function addRepo(ledger, { path, name } = {}, { now } = {}) {
|
||||
requireNow(now);
|
||||
const resolved = normalizePath(path);
|
||||
if (ledger.repos.some((r) => r.path === resolved)) {
|
||||
return ledger; // idempotent: already tracked, preserve its status
|
||||
}
|
||||
const repo = {
|
||||
path: resolved,
|
||||
name: typeof name === 'string' && name.trim() !== '' ? name : resolved.split('/').pop(),
|
||||
status: 'pending',
|
||||
sessionId: null,
|
||||
findingsBySeverity: null,
|
||||
tokens: null,
|
||||
updatedDate: now,
|
||||
};
|
||||
return { ...ledger, updatedDate: now, repos: [...ledger.repos, repo] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a tracked repo to a new status, optionally attaching the audit's
|
||||
* findings-by-severity and the producing sessionId. Returns a NEW ledger.
|
||||
* @param {object} ledger
|
||||
* @param {string} path
|
||||
* @param {string} status - one of STATUSES
|
||||
* @param {{now: string, findingsBySeverity?: object|null, sessionId?: string|null}} opts
|
||||
*/
|
||||
export function setRepoStatus(ledger, path, status, { now, findingsBySeverity, sessionId } = {}) {
|
||||
requireNow(now);
|
||||
if (!STATUSES.includes(status)) {
|
||||
throw new RangeError(`invalid status "${status}" — must be one of ${STATUSES.join(', ')}`);
|
||||
}
|
||||
const resolved = normalizePath(path);
|
||||
const idx = ledger.repos.findIndex((r) => r.path === resolved);
|
||||
if (idx === -1) {
|
||||
throw new Error(`repo "${resolved}" is not in the ledger — addRepo first`);
|
||||
}
|
||||
const updated = { ...ledger.repos[idx], status, updatedDate: now };
|
||||
if (findingsBySeverity !== undefined) updated.findingsBySeverity = findingsBySeverity;
|
||||
if (sessionId !== undefined) updated.sessionId = sessionId;
|
||||
const repos = ledger.repos.slice();
|
||||
repos[idx] = updated;
|
||||
return { ...ledger, updatedDate: now, repos };
|
||||
}
|
||||
|
||||
/** Load-pattern buckets carried by a token summary (manifest `summarizeByLoadPattern` shape). */
|
||||
const LOAD_PATTERNS = Object.freeze(['always', 'onDemand', 'external', 'unknown']);
|
||||
|
||||
/**
|
||||
* Set the machine-wide SHARED global always-loaded layer (global CLAUDE.md + agent listing +
|
||||
* global MCP + unscoped global rules + active plugins' always-loaded components). Stored ONCE
|
||||
* at the ledger root — never per repo — so the machine-wide roll-up counts it exactly once
|
||||
* (the structural guard against the historic double-count). The `summary` is the shape
|
||||
* manifest's `summarizeByLoadPattern` emits: `{always|onDemand|external|unknown: {tokens,count}}`.
|
||||
* Returns a NEW ledger. (B2b populates this from a live cross-repo sweep.)
|
||||
* @param {object} ledger
|
||||
* @param {object} summary
|
||||
* @param {{now: string}} opts
|
||||
*/
|
||||
export function setSharedGlobal(ledger, summary, { now } = {}) {
|
||||
requireNow(now);
|
||||
return { ...ledger, updatedDate: now, sharedGlobal: summary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a tracked repo's PER-REPO always-loaded token delta (its project-scoped contribution
|
||||
* beyond the shared global layer — project CLAUDE.md / rules / agents / MCP). Same `summary`
|
||||
* shape as `setSharedGlobal`. Mirrors `setRepoStatus`: throws if the repo is untracked. Returns
|
||||
* a NEW ledger.
|
||||
* @param {object} ledger
|
||||
* @param {string} path
|
||||
* @param {object} tokens
|
||||
* @param {{now: string}} opts
|
||||
*/
|
||||
export function setRepoTokens(ledger, path, tokens, { now } = {}) {
|
||||
requireNow(now);
|
||||
const resolved = normalizePath(path);
|
||||
const idx = ledger.repos.findIndex((r) => r.path === resolved);
|
||||
if (idx === -1) {
|
||||
throw new Error(`repo "${resolved}" is not in the ledger — addRepo first`);
|
||||
}
|
||||
const repos = ledger.repos.slice();
|
||||
repos[idx] = { ...ledger.repos[idx], tokens, updatedDate: now };
|
||||
return { ...ledger, updatedDate: now, repos };
|
||||
}
|
||||
|
||||
/** Tolerantly read the four load-pattern token numbers from a stored summary (or null/old data). */
|
||||
function bucketTokens(summary) {
|
||||
const out = {};
|
||||
for (const k of LOAD_PATTERNS) {
|
||||
const v = summary && summary[k];
|
||||
out[k] = v && typeof v.tokens === 'number' ? v.tokens : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine-wide roll-up: repo counts by status, a severity total across every repo carrying
|
||||
* `findingsBySeverity`, AND a machine-wide always-loaded token bill. The token aggregate adds
|
||||
* the SHARED global layer (counted once, from the ledger root) to the SUM of per-repo deltas,
|
||||
* so `tokens.machineWide` is the honest "context spent every turn across the whole machine".
|
||||
* Pure derivation — never mutates; tolerant of old ledgers without token fields.
|
||||
* @param {object} ledger
|
||||
* @returns {{totalRepos:number, byStatus:object, bySeverity:object, reposWithFindings:number, tokens:object}}
|
||||
*/
|
||||
export function rollUp(ledger) {
|
||||
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
|
||||
const bySeverity = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
|
||||
let reposWithFindings = 0;
|
||||
|
||||
const sharedGlobal = bucketTokens(ledger.sharedGlobal);
|
||||
const perRepoDelta = Object.fromEntries(LOAD_PATTERNS.map((k) => [k, 0]));
|
||||
const byRepo = [];
|
||||
let reposWithTokens = 0;
|
||||
|
||||
for (const repo of ledger.repos) {
|
||||
if (byStatus[repo.status] !== undefined) byStatus[repo.status] += 1;
|
||||
const f = repo.findingsBySeverity;
|
||||
if (f && typeof f === 'object') {
|
||||
reposWithFindings += 1;
|
||||
for (const sev of SEVERITIES) {
|
||||
if (typeof f[sev] === 'number') bySeverity[sev] += f[sev];
|
||||
}
|
||||
}
|
||||
if (repo.tokens && typeof repo.tokens === 'object') {
|
||||
reposWithTokens += 1;
|
||||
const b = bucketTokens(repo.tokens);
|
||||
for (const k of LOAD_PATTERNS) perRepoDelta[k] += b[k];
|
||||
byRepo.push({ name: repo.name, path: repo.path, always: b.always, onDemand: b.onDemand, external: b.external });
|
||||
}
|
||||
}
|
||||
|
||||
// DESC by always-loaded cost ("most expensive repos"); deterministic name tie-break.
|
||||
byRepo.sort((x, y) => y.always - x.always || x.name.localeCompare(y.name));
|
||||
|
||||
const machineWide = Object.fromEntries(
|
||||
LOAD_PATTERNS.map((k) => [k, sharedGlobal[k] + perRepoDelta[k]]),
|
||||
);
|
||||
|
||||
return {
|
||||
totalRepos: ledger.repos.length,
|
||||
byStatus,
|
||||
bySeverity,
|
||||
reposWithFindings,
|
||||
tokens: { sharedGlobal, perRepoDelta, machineWide, reposWithTokens, byRepo },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single, machine-wide PRIORITIZED backlog the user picks from. Pure derivation —
|
||||
* never mutates. The actionable unit is a REPO (the ledger tracks per-repo severity counts,
|
||||
* not individual findings — it tracks state, it does not re-run audits), so each backlog item
|
||||
* is one repo with outstanding work.
|
||||
*
|
||||
* Inclusion: a repo is in the backlog iff it is NOT yet `implemented` AND has at least one
|
||||
* outstanding finding (`totalFindings > 0`). `implemented` repos are done; `pending` and
|
||||
* zero-finding repos have nothing known to fix (they still surface in `rollUp.byStatus`).
|
||||
*
|
||||
* Order: DESC by `weightedScore` (SEVERITY_WEIGHTS), tie-broken lexicographically by
|
||||
* critical→high→medium→low count, then ascending by `name` — fully deterministic, and the
|
||||
* tie-break preserves "criticals always win" even when two repos share a weighted score.
|
||||
*
|
||||
* @param {object} ledger
|
||||
* @returns {Array<{path:string,name:string,status:string,sessionId:string|null,findingsBySeverity:object,totalFindings:number,weightedScore:number,rank:number}>}
|
||||
*/
|
||||
export function buildBacklog(ledger) {
|
||||
const items = [];
|
||||
|
||||
for (const repo of ledger.repos) {
|
||||
if (repo.status === 'implemented') continue;
|
||||
const f = repo.findingsBySeverity;
|
||||
if (!f || typeof f !== 'object') continue;
|
||||
|
||||
const findingsBySeverity = Object.fromEntries(
|
||||
SEVERITIES.map((s) => [s, typeof f[s] === 'number' ? f[s] : 0]),
|
||||
);
|
||||
const totalFindings = SEVERITIES.reduce((sum, s) => sum + findingsBySeverity[s], 0);
|
||||
if (totalFindings === 0) continue;
|
||||
|
||||
const weightedScore = SEVERITIES.reduce(
|
||||
(score, s) => score + findingsBySeverity[s] * SEVERITY_WEIGHTS[s],
|
||||
0,
|
||||
);
|
||||
items.push({
|
||||
path: repo.path,
|
||||
name: repo.name,
|
||||
status: repo.status,
|
||||
sessionId: repo.sessionId ?? null,
|
||||
findingsBySeverity,
|
||||
totalFindings,
|
||||
weightedScore,
|
||||
});
|
||||
}
|
||||
|
||||
items.sort(
|
||||
(x, y) =>
|
||||
y.weightedScore - x.weightedScore ||
|
||||
y.findingsBySeverity.critical - x.findingsBySeverity.critical ||
|
||||
y.findingsBySeverity.high - x.findingsBySeverity.high ||
|
||||
y.findingsBySeverity.medium - x.findingsBySeverity.medium ||
|
||||
y.findingsBySeverity.low - x.findingsBySeverity.low ||
|
||||
x.name.localeCompare(y.name),
|
||||
);
|
||||
|
||||
return items.map((item, i) => ({ ...item, rank: i + 1 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed ledger against the schema. Never throws — returns every problem at once
|
||||
* so the caller (and tests) can inspect them. Soft by design (loaded data may be corrupt).
|
||||
* @param {unknown} data
|
||||
* @returns {{valid:boolean, errors:string[]}}
|
||||
*/
|
||||
export function validateLedger(data) {
|
||||
const errors = [];
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return { valid: false, errors: ['ledger must be an object'] };
|
||||
}
|
||||
if (data.schemaVersion !== CAMPAIGN_SCHEMA_VERSION) {
|
||||
errors.push(`schemaVersion must be ${CAMPAIGN_SCHEMA_VERSION}`);
|
||||
}
|
||||
for (const field of ['createdDate', 'updatedDate']) {
|
||||
if (typeof data[field] !== 'string' || !DATE_RE.test(data[field])) {
|
||||
errors.push(`${field} must be YYYY-MM-DD`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(data.repos)) {
|
||||
errors.push('repos must be an array');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
data.repos.forEach((r, i) => {
|
||||
const at = `repos[${i}]${r && typeof r === 'object' && r.path ? ` (${r.path})` : ''}`;
|
||||
if (!r || typeof r !== 'object' || Array.isArray(r)) {
|
||||
errors.push(`${at}: must be an object`);
|
||||
return;
|
||||
}
|
||||
if (typeof r.path !== 'string' || r.path.trim() === '') {
|
||||
errors.push(`${at}: path is required`);
|
||||
} else if (seen.has(r.path)) {
|
||||
errors.push(`${at}: duplicate path`);
|
||||
} else {
|
||||
seen.add(r.path);
|
||||
}
|
||||
if (!STATUSES.includes(r.status)) {
|
||||
errors.push(`${at}: status must be one of ${STATUSES.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ── Persistence (thin IO shell) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default on-disk location: next to `sessions/`, OUTSIDE the plugin dir, so the campaign
|
||||
* survives plugin uninstall/reinstall/upgrade.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function defaultLedgerPath() {
|
||||
return join(homedir(), '.claude', 'config-audit', 'campaign-ledger.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load + parse a ledger file. Returns `null` if the file does not exist (graceful first run);
|
||||
* other read/parse errors propagate so corruption is not silently swallowed.
|
||||
* @param {string} [path]
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
export async function loadLedger(path = defaultLedgerPath()) {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(path, 'utf8');
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a ledger as human-readable JSON, creating parent directories as needed.
|
||||
* @param {string} path
|
||||
* @param {object} ledger
|
||||
* @returns {Promise<{path: string}>}
|
||||
*/
|
||||
export async function saveLedger(path = defaultLedgerPath(), ledger) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8');
|
||||
return { path };
|
||||
}
|
||||
85
scanners/lib/cli-args.mjs
Normal file
85
scanners/lib/cli-args.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Argv precondition shared by the CLIs — the companion to `require-target-dir`.
|
||||
*
|
||||
* Every CLI here parses argv with a chain of `if (a === '--x') … else if …`.
|
||||
* Two things fall through that chain silently:
|
||||
*
|
||||
* 1. **An unknown flag.** With no `else` branch, `--zzz` leaves no trace: the
|
||||
* CLI exits 0 with a full payload — a confident answer to a question the
|
||||
* caller did not ask. Measured live (#51): `knowledge-refresh`'s only knob
|
||||
* reached the CLI malformed, was ignored, and the command reported "all 14
|
||||
* entries re-verified within the last 90 days" about a threshold the user
|
||||
* had just overridden.
|
||||
*
|
||||
* 2. **A value-taking flag whose value is another flag.** The guard
|
||||
* `a === '--output-file' && args[i + 1]` asks only whether a next token
|
||||
* exists, never whether it is a *value*. Measured live (#57):
|
||||
* `manifest --output-file --json` wrote a file literally named `--json`
|
||||
* into the caller's working directory, exit 0, with `--json` mode silently
|
||||
* dropped. A wrong answer is bad; an unintended file on disk is worse.
|
||||
*
|
||||
* This runs BEFORE the CLI's own loop and does not replace it. That is
|
||||
* deliberate: valid argv reaches the existing parser byte-for-byte unchanged, so
|
||||
* no frozen snapshot can move. Malformed argv never reaches it at all.
|
||||
*
|
||||
* By the exit-code contract, a malformed argument is exit **3** — the scanner
|
||||
* did not get to do its job — never 0/1/2, which are verdicts about a
|
||||
* configuration that WAS examined.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Find the first thing wrong with `args`.
|
||||
*
|
||||
* @param {string[]} args - argv slice (no node/script entries).
|
||||
* @param {{ boolean?: string[], value?: string[] }} spec - the CLI's flag surface.
|
||||
* @returns {string|null} diagnostic, or null when argv is well-formed.
|
||||
*/
|
||||
export function findArgError(args, spec) {
|
||||
const booleanFlags = new Set(spec.boolean || []);
|
||||
const valueFlags = new Set(spec.value || []);
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
|
||||
// Positionals and subcommands are the CLI's own business.
|
||||
if (!a.startsWith('-')) continue;
|
||||
if (booleanFlags.has(a)) continue;
|
||||
|
||||
if (valueFlags.has(a)) {
|
||||
const next = args[i + 1];
|
||||
if (next === undefined) {
|
||||
return `flag "${a}" needs a value, but nothing followed it`;
|
||||
}
|
||||
if (next.startsWith('-')) {
|
||||
return `flag "${a}" needs a value, but the next argument was another flag: "${next}"`;
|
||||
}
|
||||
i++; // consume the value so it is never re-examined as a positional
|
||||
continue;
|
||||
}
|
||||
|
||||
return `unknown flag "${a}"`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a CLI on well-formed argv. Writes the diagnostic and sets the exit code
|
||||
* itself, so callers stay a two-line guard:
|
||||
*
|
||||
* if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
*
|
||||
* Never throws, and never calls `process.exit()` — an abrupt exit discards
|
||||
* unflushed stdout when the CLI is on a pipe.
|
||||
*
|
||||
* @param {string[]} args - argv slice.
|
||||
* @param {{ boolean?: string[], value?: string[] }} spec - the CLI's flag surface.
|
||||
* @returns {boolean} true when the CLI may proceed.
|
||||
*/
|
||||
export function requireValidArgs(args, spec) {
|
||||
const error = findArgError(args, spec);
|
||||
if (error === null) return true;
|
||||
process.stderr.write(`Error: ${error}\n`);
|
||||
process.exitCode = 3;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -26,3 +26,107 @@ export const LARGE_CONTEXT_SCALE = LARGE_CONTEXT_WINDOW / CONTEXT_WINDOW_ANCHOR;
|
|||
|
||||
// Dependency-free thousands separator (repo invariant: zero external deps).
|
||||
export const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
// Model families whose context window is the large (1M) tier. Verified June 2026
|
||||
// (platform.claude.com models overview): Fable 5, Opus 4.8/4.7/4.6 and Sonnet 4.6
|
||||
// all run a 1M context window. Matched by substring so dated IDs
|
||||
// (claude-opus-4-8-20260528) and provider-prefixed IDs
|
||||
// (us.anthropic.claude-opus-4-8) resolve too. Models we cannot confirm (e.g.
|
||||
// Haiku, older 200k-era IDs) are deliberately left out: the caller then keeps the
|
||||
// conservative anchor rather than guess a relaxed budget.
|
||||
export const LARGE_CONTEXT_MODEL_IDS = [
|
||||
'claude-fable-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
];
|
||||
|
||||
// Short aliases Claude Code accepts in the `model` setting / ANTHROPIC_MODEL that
|
||||
// currently resolve to a 1M-tier model (`opusplan` plans on an Opus-tier model).
|
||||
export const LARGE_CONTEXT_MODEL_ALIASES = new Set(['opus', 'sonnet', 'fable', 'opusplan']);
|
||||
|
||||
/**
|
||||
* Map a configured model id/alias to its context window, or null when we cannot
|
||||
* confirm it. Pure: no IO. Used by the `--context-window auto` probe (B8b) so
|
||||
* known 1M-tier models calibrate budgets instead of falling back to the
|
||||
* conservative advisory anchor.
|
||||
*
|
||||
* @param {string} modelId - e.g. "claude-opus-4-8[1m]", "claude-sonnet-4-6", "opus"
|
||||
* @returns {number|null} the context window, or null if unrecognized
|
||||
*/
|
||||
export function modelToContextWindow(modelId) {
|
||||
if (typeof modelId !== 'string') return null;
|
||||
const id = modelId.trim().toLowerCase();
|
||||
if (!id) return null;
|
||||
// Explicit tier tag wins — the running session model surfaces as e.g.
|
||||
// "claude-opus-4-8[1m]". This is the strongest, most future-proof signal.
|
||||
if (id.includes('[1m]')) return LARGE_CONTEXT_WINDOW;
|
||||
// Known 1M-tier families (substring → tolerant of date/provider-prefix variants).
|
||||
for (const fam of LARGE_CONTEXT_MODEL_IDS) {
|
||||
if (id.includes(fam)) return LARGE_CONTEXT_WINDOW;
|
||||
}
|
||||
// Short aliases.
|
||||
if (LARGE_CONTEXT_MODEL_ALIASES.has(id)) return LARGE_CONTEXT_WINDOW;
|
||||
// Unknown: cannot confirm the window — keep the conservative anchor (null).
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolvedContextWindow
|
||||
* @property {number} window - the context window budgets calibrate against
|
||||
* @property {boolean} advisory - true when the window is unknown: keep the anchor
|
||||
* but downgrade budget findings to info instead of
|
||||
* firing them as a breach
|
||||
* @property {'default'|'explicit'|'auto-probed'|'auto-unresolved'} source
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the raw `--context-window` CLI value into a window + advisory flag.
|
||||
*
|
||||
* Design (B8): the DEFAULT (no flag) is byte-identical to the pre-B8 behavior —
|
||||
* the conservative 200k anchor at full severity. Only an explicit value changes
|
||||
* calibration. `auto` asks the tool to figure out the window.
|
||||
*
|
||||
* B8b: `auto` now probes the configured model (`opts.model`, resolved from the
|
||||
* settings cascade / ANTHROPIC_MODEL by the orchestrator). A recognized 1M-tier
|
||||
* model calibrates to its window (source `auto-probed`, not advisory). When the
|
||||
* model is unknown or unpinned, it keeps the conservative anchor but marks the
|
||||
* result advisory (source `auto-unresolved`) so SKL/CML downgrade their budget
|
||||
* findings to info rather than "crying wolf" on a window we cannot confirm.
|
||||
*
|
||||
* @param {string|number|null|undefined} arg
|
||||
* @param {{ model?: string|null }} [opts] - probe input for `auto` (ignored on the
|
||||
* default/explicit paths, which stay byte-stable).
|
||||
* @returns {ResolvedContextWindow}
|
||||
*/
|
||||
export function resolveContextWindow(arg, opts = {}) {
|
||||
if (arg == null) {
|
||||
return { window: CONTEXT_WINDOW_ANCHOR, advisory: false, source: 'default' };
|
||||
}
|
||||
if (String(arg).trim().toLowerCase() === 'auto') {
|
||||
const probed = modelToContextWindow(opts.model);
|
||||
if (probed) {
|
||||
return { window: probed, advisory: false, source: 'auto-probed' };
|
||||
}
|
||||
return { window: CONTEXT_WINDOW_ANCHOR, advisory: true, source: 'auto-unresolved' };
|
||||
}
|
||||
const n = typeof arg === 'number' ? arg : parseInt(String(arg).trim(), 10);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
return { window: n, advisory: false, source: 'explicit' };
|
||||
}
|
||||
// Unparseable / non-positive: fall back to the conservative default (no advisory).
|
||||
return { window: CONTEXT_WINDOW_ANCHOR, advisory: false, source: 'default' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale a 200k-anchored budget to a given context window. Linear in the window,
|
||||
* so it is the identity at the anchor (keeps the default byte-stable).
|
||||
*
|
||||
* @param {number} anchorValue - the budget/threshold defined at the 200k anchor
|
||||
* @param {number} window - the target context window
|
||||
* @returns {number}
|
||||
*/
|
||||
export function scaleForWindow(anchorValue, window) {
|
||||
return Math.round(anchorValue * (window / CONTEXT_WINDOW_ANCHOR));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,148 @@ const SKIP_DIRS = new Set([
|
|||
'node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__',
|
||||
'.next', '.nuxt', '.output', '.cache', '.turbo', '.parcel-cache',
|
||||
'vendor', 'venv', '.venv', '.tox',
|
||||
// A `backups` dir holds backup COPIES, not live config — auditing it as if
|
||||
// live produces stale findings. config-audit's own session backups
|
||||
// (~/.claude/config-audit/backups/<ts>/files/.../CLAUDE.md) are the canonical
|
||||
// case (M-BUG-8), but the rule is general: backups are never live config.
|
||||
'backups',
|
||||
]);
|
||||
|
||||
// Path marker for the plugin install cache (~/.claude/plugins/cache).
|
||||
// Structure: <...>/plugins/cache/<marketplace>/<plugin>/<version>/...
|
||||
// installed_plugins.json's installPath points INTO this tree at the ACTIVE
|
||||
// version; any other version dir is a stale leftover (superseded install).
|
||||
const PLUGIN_CACHE_MARKER = `plugins${sep}cache${sep}`;
|
||||
|
||||
/**
|
||||
* Extract the `<marketplace>/<plugin>/<version>` key for a path inside
|
||||
* ~/.claude/plugins/cache. Returns null when the path is not under
|
||||
* plugins/cache, or is shallower than the version directory.
|
||||
* @param {string} absPath
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function cacheVersionKey(absPath) {
|
||||
const i = absPath.indexOf(PLUGIN_CACHE_MARKER);
|
||||
if (i === -1) return null;
|
||||
const rest = absPath.slice(i + PLUGIN_CACHE_MARKER.length);
|
||||
const segs = rest.split(sep).filter(Boolean);
|
||||
if (segs.length < 3) return null; // need marketplace/plugin/version
|
||||
return segs.slice(0, 3).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a path inside plugins/cache, return the absolute `<...>/plugins`
|
||||
* directory that owns it (where installed_plugins.json lives).
|
||||
* @param {string} absPath
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function pluginsDirForCachePath(absPath) {
|
||||
const i = absPath.indexOf(PLUGIN_CACHE_MARKER);
|
||||
if (i === -1) return null;
|
||||
return absPath.slice(0, i + 'plugins'.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the set of ACTIVE cache version-keys from a `<...>/plugins` directory's
|
||||
* installed_plugins.json. Each record's installPath points at the version
|
||||
* Claude Code loads. Returns null when the manifest is absent or unparseable —
|
||||
* callers must then NOT filter (we cannot safely tell active from stale, and
|
||||
* silently dropping active config is the worse failure).
|
||||
* @param {string} pluginsDir
|
||||
* @returns {Promise<Set<string> | null>}
|
||||
*/
|
||||
async function readActiveCacheVersions(pluginsDir) {
|
||||
let raw;
|
||||
try {
|
||||
raw = await readFile(join(pluginsDir, 'installed_plugins.json'), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const active = new Set();
|
||||
const plugins = parsed && parsed.plugins;
|
||||
if (plugins && typeof plugins === 'object') {
|
||||
for (const recs of Object.values(plugins)) {
|
||||
if (!Array.isArray(recs)) continue;
|
||||
for (const r of recs) {
|
||||
const key = r && r.installPath ? cacheVersionKey(resolve(r.installPath)) : null;
|
||||
if (key) active.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify stale plugin-cache versions among discovered files and (optionally)
|
||||
* filter them out. "Stale" = a cache version-key not referenced by the owning
|
||||
* installed_plugins.json. Active versions are always kept — installPaths point
|
||||
* INTO the cache, so a blunt "skip all of plugins/cache" would drop live config.
|
||||
*
|
||||
* @param {Array} files
|
||||
* @param {boolean} excludeCache - when true, stale-version files are removed
|
||||
* @returns {Promise<{ files: Array, staleCacheVersions: Array<{key:string, fileCount:number, estimatedBytes:number}> }>}
|
||||
*/
|
||||
async function applyCacheFilter(files, excludeCache) {
|
||||
const cacheFiles = [];
|
||||
for (const f of files) {
|
||||
const key = cacheVersionKey(f.absPath);
|
||||
if (key) cacheFiles.push({ f, key });
|
||||
}
|
||||
if (cacheFiles.length === 0) return { files, staleCacheVersions: [] };
|
||||
|
||||
// Union active version-keys across every installed_plugins.json adjacent to
|
||||
// the cache (a full-machine sweep only ever sees one, but be robust).
|
||||
const pluginsDirs = new Set();
|
||||
for (const { f } of cacheFiles) {
|
||||
const pd = pluginsDirForCachePath(f.absPath);
|
||||
if (pd) pluginsDirs.add(pd);
|
||||
}
|
||||
let active = null;
|
||||
for (const pd of pluginsDirs) {
|
||||
const keys = await readActiveCacheVersions(pd);
|
||||
if (keys) {
|
||||
if (active === null) active = new Set();
|
||||
for (const k of keys) active.add(k);
|
||||
}
|
||||
}
|
||||
|
||||
// No readable manifest → cannot distinguish active from stale → do nothing.
|
||||
if (active === null) return { files, staleCacheVersions: [] };
|
||||
|
||||
const byKey = new Map();
|
||||
for (const { f, key } of cacheFiles) {
|
||||
if (!byKey.has(key)) byKey.set(key, []);
|
||||
byKey.get(key).push(f);
|
||||
}
|
||||
const staleCacheVersions = [];
|
||||
for (const [key, group] of byKey) {
|
||||
if (!active.has(key)) {
|
||||
staleCacheVersions.push({
|
||||
key,
|
||||
fileCount: group.length,
|
||||
estimatedBytes: group.reduce((s, f) => s + (f.size || 0), 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
staleCacheVersions.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
||||
|
||||
let outFiles = files;
|
||||
if (excludeCache && staleCacheVersions.length > 0) {
|
||||
const staleKeys = new Set(staleCacheVersions.map(s => s.key));
|
||||
outFiles = files.filter(f => {
|
||||
const k = cacheVersionKey(f.absPath);
|
||||
return k === null || !staleKeys.has(k);
|
||||
});
|
||||
}
|
||||
return { files: outFiles, staleCacheVersions };
|
||||
}
|
||||
|
||||
/** Config file patterns to discover */
|
||||
const CONFIG_PATTERNS = {
|
||||
claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i,
|
||||
|
|
@ -34,7 +174,8 @@ const CONFIG_PATTERNS = {
|
|||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxFiles=500] - max files to return
|
||||
* @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/
|
||||
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
|
||||
* @param {boolean} [opts.excludeCache=false] - drop stale ~/.claude/plugins/cache versions (B3)
|
||||
* @returns {Promise<{ files: ConfigFile[], skipped: number, staleCacheVersions: Array }>}
|
||||
*
|
||||
* @typedef {{ absPath: string, relPath: string, type: string, scope: string, size: number }} ConfigFile
|
||||
*/
|
||||
|
|
@ -68,7 +209,8 @@ export async function discoverConfigFiles(targetPath, opts = {}) {
|
|||
} catch { /* doesn't exist */ }
|
||||
}
|
||||
|
||||
return { files, skipped: skippedRef.count };
|
||||
const { files: outFiles, staleCacheVersions } = await applyCacheFilter(files, opts.excludeCache || false);
|
||||
return { files: outFiles, skipped: skippedRef.count, staleCacheVersions };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -245,7 +387,8 @@ export async function discoverFullMachinePaths() {
|
|||
* @param {Array<{ path: string, maxDepth: number }>} roots
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxFiles=2000] - global max across all roots
|
||||
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
|
||||
* @param {boolean} [opts.excludeCache=false] - drop stale ~/.claude/plugins/cache versions (B3)
|
||||
* @returns {Promise<{ files: ConfigFile[], skipped: number, staleCacheVersions: Array }>}
|
||||
*/
|
||||
export async function discoverConfigFilesMulti(roots, opts = {}) {
|
||||
const maxFiles = opts.maxFiles || 2000;
|
||||
|
|
@ -287,7 +430,8 @@ export async function discoverConfigFilesMulti(roots, opts = {}) {
|
|||
} catch { /* doesn't exist */ }
|
||||
}
|
||||
|
||||
return { files: allFiles, skipped: totalSkipped };
|
||||
const { files: outFiles, staleCacheVersions } = await applyCacheFilter(allFiles, opts.excludeCache || false);
|
||||
return { files: outFiles, skipped: totalSkipped, staleCacheVersions };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
321
scanners/lib/finding-codes.mjs
Normal file
321
scanners/lib/finding-codes.mjs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
/**
|
||||
* Finding-code registry — the authority for the {NNN} in `CA-{SCANNER}-{NNN}`.
|
||||
*
|
||||
* A finding ID names the CHECK, not the finding's position in a run (M-BUG-28).
|
||||
* Before this registry, `{NNN}` came from an emission counter, so the same check
|
||||
* carried different IDs on different configurations: fixing an unrelated earlier
|
||||
* gap silently renumbered every later one, and a `.config-audit-ignore` entry
|
||||
* retargeted to a neighbouring finding without the user changing anything.
|
||||
*
|
||||
* Rules for editing this file:
|
||||
*
|
||||
* 1. A number, once published, belongs to its check forever. Adding a check
|
||||
* takes the next free number for that scanner — never the next source-order
|
||||
* position, and never a number listed in RETIRED.
|
||||
* 2. Removing a check moves its key to RETIRED. The number is never reissued;
|
||||
* a user's suppression must go dead rather than quietly point at a
|
||||
* different finding. (D1 retired GAP `t3_8` under the old scheme, which is
|
||||
* the incident that motivated the registry.)
|
||||
* 3. Several call sites may share one code when they are arms of one check —
|
||||
* e.g. the forward/reverse arms of a permission conflict. Duplicate
|
||||
* emission is legal; a finding is identified by (id, file, line).
|
||||
* 4. Numbers below are NOT all source order: the ones marked "documented"
|
||||
* are pinned by README / command copy that shipped before the registry.
|
||||
*
|
||||
* GAP keys are the `GAP_CHECKS[].id` values from `feature-gap-scanner.mjs`,
|
||||
* which were already stable. They are declared here rather than derived, so
|
||||
* numbers live in exactly one place; `tests/lib/finding-codes.test.mjs` binds
|
||||
* the two together instead of a second copy of the table drifting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Record<string, Record<string, number>>}
|
||||
* scanner prefix → check key → number
|
||||
*/
|
||||
export const FINDING_CODES = {
|
||||
// ── CML: claude-md-linter (source order) ────────────────────────────────
|
||||
// `over-char-budget` has two call sites: the conservative 200k anchor and the
|
||||
// `--context-window` calibrated variant. One check, one code.
|
||||
CML: {
|
||||
'no-claude-md': 1,
|
||||
'nested-not-reinjected': 2,
|
||||
'over-500-lines': 3,
|
||||
'over-200-lines': 4,
|
||||
'over-char-budget': 5,
|
||||
'nearly-empty': 6,
|
||||
'missing-sections': 7,
|
||||
'no-headings': 8,
|
||||
'deep-relative-import': 9,
|
||||
'html-comments': 10,
|
||||
'repeated-content': 11,
|
||||
'todo-markers': 12,
|
||||
'dead-prose-reference': 13,
|
||||
},
|
||||
|
||||
// ── SET: settings-validator (source order) ──────────────────────────────
|
||||
SET: {
|
||||
'invalid-json': 1,
|
||||
'key-typo': 2,
|
||||
'deprecated-key': 3,
|
||||
'type-mismatch': 4,
|
||||
'invalid-effort-level': 5,
|
||||
'missing-schema': 6,
|
||||
'no-deny-rules': 7,
|
||||
'no-allow-rules': 8,
|
||||
'many-additional-dirs': 9,
|
||||
'automode-not-object': 10,
|
||||
'automode-unknown-subkey': 11,
|
||||
'automode-subkey-not-string-array': 12,
|
||||
'automode-in-shared-settings': 13,
|
||||
'hooks-as-array': 14,
|
||||
},
|
||||
|
||||
// ── HKV: hook-validator (source order) ──────────────────────────────────
|
||||
HKV: {
|
||||
'invalid-json': 1,
|
||||
'hooks-not-object': 2,
|
||||
'unknown-event': 3,
|
||||
'handlers-not-array': 4,
|
||||
'matcher-not-string': 5,
|
||||
'missing-hooks-array': 6,
|
||||
'invalid-handler-type': 7,
|
||||
'script-not-found': 8,
|
||||
'verbose-output': 9,
|
||||
'unfiltered-additional-context': 10,
|
||||
'timeout-not-number': 11,
|
||||
'timeout-out-of-range': 12,
|
||||
},
|
||||
|
||||
// ── RUL: rules-validator (source order) ─────────────────────────────────
|
||||
RUL: {
|
||||
'no-frontmatter': 1,
|
||||
'globs-instead-of-paths': 2,
|
||||
'pattern-matches-nothing': 3,
|
||||
'nearly-empty': 4,
|
||||
'large-unscoped': 5,
|
||||
'large-scoped-lost-after-compaction': 6,
|
||||
'not-markdown': 7,
|
||||
},
|
||||
|
||||
// ── MCP: mcp-config-validator (source order) ────────────────────────────
|
||||
MCP: {
|
||||
'invalid-json': 1,
|
||||
'unknown-server-type': 2,
|
||||
'sse-transport': 3,
|
||||
'unreferenced-env-var': 4,
|
||||
'unknown-server-field': 5,
|
||||
},
|
||||
|
||||
// ── IMP: import-resolver (source order) ─────────────────────────────────
|
||||
IMP: {
|
||||
'tilde-path': 1,
|
||||
'broken-link': 2,
|
||||
'circular-reference': 3,
|
||||
'deep-chain': 4,
|
||||
},
|
||||
|
||||
// ── CNF: conflict-detector ──────────────────────────────────────────────
|
||||
// `permission-allow-deny` covers both arms (allow-in-A/deny-in-B and reverse).
|
||||
CNF: {
|
||||
'settings-key-conflict': 1,
|
||||
'permission-allow-deny': 2,
|
||||
'duplicate-hook': 3,
|
||||
},
|
||||
|
||||
// ── DIS: disabled-in-schema-scanner (source order) ──────────────────────
|
||||
DIS: {
|
||||
'deny-and-allow': 1,
|
||||
'ineffective-allow-wildcard': 2,
|
||||
'forbidden-param-deny': 3,
|
||||
'forbidden-param-allow': 4,
|
||||
},
|
||||
|
||||
// ── CPS: cache-prefix-scanner (CPS-001 documented) ──────────────────────
|
||||
CPS: {
|
||||
'volatile-in-prefix': 1,
|
||||
'volatile-in-import': 2,
|
||||
},
|
||||
|
||||
// ── COL: collision-scanner (source order) ───────────────────────────────
|
||||
COL: {
|
||||
'skill-user-vs-plugin': 1,
|
||||
'skill-multi-plugin': 2,
|
||||
},
|
||||
|
||||
// ── AGT: agent-listing-scanner (both documented; source order matches) ──
|
||||
AGT: {
|
||||
'description-bloat': 1,
|
||||
'aggregate-listing-budget': 2,
|
||||
},
|
||||
|
||||
// ── OST: output-style-scanner (all three documented) ────────────────────
|
||||
OST: {
|
||||
'strips-coding-instructions': 1,
|
||||
'plugin-forces-style': 2,
|
||||
'style-not-found': 3,
|
||||
},
|
||||
|
||||
// ── OPT: optimization-lens-scanner (documented) ─────────────────────────
|
||||
OPT: {
|
||||
'procedure-should-be-skill': 1,
|
||||
},
|
||||
|
||||
// ── SKL: skill-listing-scanner (all three documented) ───────────────────
|
||||
// `aggregate-listing-budget` has two call sites: the conservative 200k anchor
|
||||
// and the calibrated `--context-window` variant. One check, one code.
|
||||
SKL: {
|
||||
'description-over-cap': 1,
|
||||
'aggregate-listing-budget': 2,
|
||||
'oversized-body': 3,
|
||||
},
|
||||
|
||||
// ── TOK: token-hotspots ─────────────────────────────────────────────────
|
||||
// 1/2/3/5/6 are documented (README + commands/tokens.md). `mcp-schema-deferral`
|
||||
// is documented as 006 although it is the 8th call site in source order, so
|
||||
// `cascade-over-budget` and `stale-plugin-cache` take the free 7 and 8.
|
||||
TOK: {
|
||||
'volatile-top': 1,
|
||||
'redundant-permissions': 2,
|
||||
'deep-import-chain': 3,
|
||||
'bloated-skill-description': 4,
|
||||
'mcp-schema-budget': 5,
|
||||
'mcp-schema-deferral': 6,
|
||||
'cascade-over-budget': 7,
|
||||
'stale-plugin-cache': 8,
|
||||
},
|
||||
|
||||
// ── PLH: plugin-health-scanner ──────────────────────────────────────────
|
||||
// 15 and 16 are documented (README v5.4.0 entry) but sit at source positions
|
||||
// 3 and 4; the remaining checks take {1…14, 17, 18, 19} in source order.
|
||||
PLH: {
|
||||
'invalid-plugin-json': 1,
|
||||
'missing-required-field': 2,
|
||||
'missing-plugin-json': 3,
|
||||
'claude-md-missing-section': 4,
|
||||
'missing-claude-md': 5,
|
||||
'command-missing-frontmatter': 6,
|
||||
'command-missing-field': 7,
|
||||
'agent-missing-frontmatter': 8,
|
||||
'agent-missing-field': 9,
|
||||
'agent-ignored-key': 10,
|
||||
'hooks-json-invalid-structure': 11,
|
||||
'hooks-json-array': 12,
|
||||
'hooks-json-invalid': 13,
|
||||
'unknown-plugin-file': 14,
|
||||
'plugin-json-shadows-default': 15,
|
||||
'skills-array-entry': 16,
|
||||
'no-plugins-found': 17,
|
||||
'command-name-collision': 18,
|
||||
'namespace-collision': 19,
|
||||
},
|
||||
|
||||
// ── GAP: feature-gap-scanner ────────────────────────────────────────────
|
||||
// Keys are GAP_CHECKS[].id for dimensions, and the lever code for the
|
||||
// conditional levers the scanner emits after the loop. Numbers 1–24 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);
|
||||
}
|
||||
126
scanners/lib/floor-exclusion.mjs
Normal file
126
scanners/lib/floor-exclusion.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* floor-exclusion — the deterministic veto that runs BEFORE the subtraction
|
||||
* judge sees anything (v5.13, brief §6.0 / §7 q2).
|
||||
*
|
||||
* The subtraction lens asks "what no longer earns its always-loaded rent?", and
|
||||
* that question is only safe to ask because this module answers a prior one
|
||||
* first: **which blocks are not eligible to be asked about at all?**
|
||||
*
|
||||
* Brief §6.0 splits CLAUDE.md content on one axis:
|
||||
*
|
||||
* compensatory — corrects model *behaviour* ("think before you code").
|
||||
* A more capable model does it unprompted. Deletable.
|
||||
* load-bearing — a *local fact* no amount of intelligence derives from the
|
||||
* codebase ("only Forgejo at git.example.test", "system bash
|
||||
* is 3.2"). FLOOR. Never a deletion candidate.
|
||||
*
|
||||
* Why this is deterministic and not the judge's call: precision is asymmetric.
|
||||
* A missed dead line costs a few tokens per turn; a deleted load-bearing line
|
||||
* costs a wrong remote, a broken script, or a lost afternoon. A blocking
|
||||
* guarantee must not rest on a probabilistic prose judgement, so the floor is
|
||||
* decided here, in code, and the judge only ever ranks what survives.
|
||||
*
|
||||
* The veto keys on **underivable local literals** — the textual fingerprints of
|
||||
* a fact that came from this machine rather than from general engineering
|
||||
* knowledge: an inline code span, a path, a domain, a version pin, a concrete
|
||||
* filename. Plus §6.0's explicit carve-out: policy invariants (secrets,
|
||||
* credentials, production, destructive operations) are floor *by decision, not
|
||||
* by classification* — the model would probably honour them unprompted, but the
|
||||
* cost of being wrong is asymmetric and their token cost is trivial.
|
||||
*
|
||||
* Deliberately over-broad. A false veto costs recall (a dead line survives
|
||||
* another turn); a false clearance costs the guarantee. When in doubt: floor.
|
||||
*
|
||||
* Zero external dependencies. Pure: input text → boolean.
|
||||
*/
|
||||
|
||||
/** An inline code span — the single strongest local-literal signal. */
|
||||
const CODE_SPAN_RE = /`[^`\n]+`/;
|
||||
|
||||
/** A URL or a bare hostname. `.test`/`.local` included for fixtures. */
|
||||
const URL_RE = /https?:\/\/|\b[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+\.(?:com|org|net|io|dev|sh|no|test|local|ai)\b/i;
|
||||
|
||||
/**
|
||||
* A rooted path (`~/x`, `./x`, `/Users/x`) or a glob. Deliberately does NOT
|
||||
* match a bare `word/word`: "pros/cons" is not a path, and treating it as one
|
||||
* vetoed the single largest deletable block in the dogfood run. Real filenames
|
||||
* are FILENAME_RE's job, and backticked paths are CODE_SPAN_RE's.
|
||||
*/
|
||||
const PATH_RE = /(?:^|[\s(«"'])(?:~|\.{1,2})?\/[\w.~/*-]+|\*\*?\//;
|
||||
|
||||
/** A concrete filename with a known extension (STATE.md, .zshenv, foo.sh). */
|
||||
const FILENAME_RE =
|
||||
/\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|md|json|ya?ml|toml|go|rs|java|rb|php|c|cpp|h|hpp|sh|sql|css|scss|html|env|template|lock)\b|(?:^|\s)\.\w+rc\b|\bzshenv\b/;
|
||||
|
||||
/**
|
||||
* A version pin — "bash 3.2", "Opus 4.8", "v5.12.5". A number that specific is
|
||||
* a fact about this machine's world, not general knowledge.
|
||||
*/
|
||||
const VERSION_RE = /\bv?\d+\.\d+(?:\.\d+)?\b/;
|
||||
|
||||
/**
|
||||
* §6.0's carve-out. These stay in the floor by decision: the downside is
|
||||
* asymmetric and the lines are cheap. Do not let a "the model knows this now"
|
||||
* argument reach them.
|
||||
*/
|
||||
const POLICY_RE =
|
||||
/\b(?:secret|secrets|credential|credentials|password|passphrase|api[\s-]?key|access[\s-]?token|keychain|\.env|production|prod|force[\s-]push|rm\s+-rf|destructive|hemmelighet|passord|untrusted|injection|prompt[\s-]injection|angrepsflate|attack[\s-]surface|exfiltrat\w*)\b/i;
|
||||
|
||||
/**
|
||||
* An unresolved local entity: a mixed-case capitalized word appearing
|
||||
* mid-sentence. In config prose that is almost always a product, service or
|
||||
* tool name — "push til deres egne Forgejo-remotes", "Bruk Explore for søk" —
|
||||
* i.e. exactly the local vocabulary that makes a line underivable, but with no
|
||||
* literal syntax for the other markers to key on.
|
||||
*
|
||||
* This marker is the CONSERVATIVE DEFAULT, and it is deliberately blunt: the
|
||||
* mechanism cannot tell "Forgejo" from an ordinary capitalized word without a
|
||||
* dictionary, so it declines to decide and keeps the block. Any finer rule
|
||||
* (lowercase-form-appears-elsewhere, curated entity lists) is a proxy for a
|
||||
* dictionary that would be tuned against one machine's config and fail silently
|
||||
* on the next one. Paying in recall is the direction brief §6.0 mandates.
|
||||
*
|
||||
* All-caps tokens are exempt: this config's emphasis convention is ALDRI /
|
||||
* ALLTID / FØR / MÅ, 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;
|
||||
}
|
||||
160
scanners/lib/hook-additional-context.mjs
Normal file
160
scanners/lib/hook-additional-context.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Hook additionalContext injection advisory (v5.10 B5).
|
||||
*
|
||||
* A hook that emits `hookSpecificOutput.additionalContext` has that content
|
||||
* injected into Claude's context EVERY time the hook fires. Plain stdout on
|
||||
* exit 0 does NOT enter context (it goes to the debug log only). So a hook that
|
||||
* dumps large, unfiltered command output into additionalContext is a recurring
|
||||
* per-turn token cost that compounds and is compaction-sensitive.
|
||||
*
|
||||
* This module is a STATIC heuristic over hook SCRIPT SOURCE. It is deliberately
|
||||
* LOW PRECISION — it cannot run the script or measure the real payload — so it
|
||||
* ships as an INFO advisory (weight 0, never severity-bearing), paired with a
|
||||
* feature-gap "filter-before-Claude-reads" lever. The signal:
|
||||
*
|
||||
* the script references `additionalContext`
|
||||
* AND captures output from a verbose-prone command (cat/find/git log/test/curl…)
|
||||
* AND applies no truncating/filtering tool anywhere (grep/head/jq/.slice…).
|
||||
*
|
||||
* A script that pipes through a filter, or only captures cheap output ($(date)),
|
||||
* is assumed bounded and is not flagged. Conversely a verbose capture with no
|
||||
* filter is the un-grepped pattern worth surfacing.
|
||||
*
|
||||
* Mechanism verified 2026-06-23 against code.claude.com/docs:
|
||||
* context-window.md — "A PostToolUse hook … reports back via
|
||||
* hookSpecificOutput.additionalContext. That field enters Claude's context.
|
||||
* Plain stdout on exit 0 does not." + tip: keep output concise; it enters
|
||||
* context without truncation. The remediation lever is the documented
|
||||
* filter-test-output.sh pattern (grep before Claude reads).
|
||||
*
|
||||
* The pure `assessHookAdditionalContext` takes already-read script text so it is
|
||||
* fully unit-testable without IO. `assessHookContextForRepo` is the thin IO
|
||||
* wrapper (walk hooks → scripts → assess) shared by feature-gap; the HKV scanner
|
||||
* calls the pure function inline on scripts it already reads.
|
||||
*/
|
||||
|
||||
import { readTextFile } from './file-discovery.mjs';
|
||||
import { parseJson } from './yaml-parser.mjs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
|
||||
// The field that actually enters Claude's context (vs. plain stdout / debug log).
|
||||
const ADDITIONAL_CONTEXT_RX = /additionalContext/;
|
||||
|
||||
// Commands whose UNfiltered output can be large. A capture invoking one of these
|
||||
// with no filter anywhere is the low-precision "un-grepped output" signal.
|
||||
// Shell substitution AND node child_process / file reads are both covered.
|
||||
const VERBOSE_CAPTURE_RX =
|
||||
/\b(?:cat|find|ls|git\s+(?:log|diff|status|show)|npm|yarn|pnpm|pytest|jest|go\s+test|cargo\s+test|curl|wget|env|printenv|dmesg|journalctl|execSync|spawnSync|readFileSync)\b/;
|
||||
|
||||
// Truncating / filtering tools that BOUND a payload before it reaches context.
|
||||
// Their presence anywhere in the script suppresses the advisory (assumed bounded).
|
||||
// Shell filters + the common node-side bounding operations.
|
||||
const FILTER_RX =
|
||||
/\b(?:grep|egrep|rg|head|tail|sed|awk|jq|cut|wc|uniq|sort)\b|\.(?:slice|substring|substr)\s*\(/;
|
||||
|
||||
/**
|
||||
* Assess one hook script's source for unfiltered additionalContext injection.
|
||||
*
|
||||
* @param {{ scriptContent?: string }} [args]
|
||||
* @returns {{
|
||||
* buildsAdditionalContext: boolean,
|
||||
* hasVerboseCapture: boolean,
|
||||
* hasFilter: boolean,
|
||||
* capturesUnfiltered: boolean,
|
||||
* flagged: boolean,
|
||||
* }}
|
||||
*/
|
||||
export function assessHookAdditionalContext({ scriptContent } = {}) {
|
||||
const content = typeof scriptContent === 'string' ? scriptContent : '';
|
||||
const buildsAdditionalContext = ADDITIONAL_CONTEXT_RX.test(content);
|
||||
const hasVerboseCapture = VERBOSE_CAPTURE_RX.test(content);
|
||||
const hasFilter = FILTER_RX.test(content);
|
||||
const capturesUnfiltered = hasVerboseCapture && !hasFilter;
|
||||
return {
|
||||
buildsAdditionalContext,
|
||||
hasVerboseCapture,
|
||||
hasFilter,
|
||||
capturesUnfiltered,
|
||||
flagged: buildsAdditionalContext && capturesUnfiltered,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a filesystem script path from a hook command string.
|
||||
* Mirrors hook-validator's extractScriptPath (kept local so the shared lib has
|
||||
* no upward dependency on a scanner). Handles ${CLAUDE_PLUGIN_ROOT}.
|
||||
*/
|
||||
function extractScriptPath(command, baseDir) {
|
||||
const match = command.match(/(?:bash|node|sh)\s+(.+?)(?:\s|$)/);
|
||||
if (!match) return null;
|
||||
let scriptPath = match[1].trim();
|
||||
scriptPath = scriptPath.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, resolve(baseDir, '..'));
|
||||
scriptPath = scriptPath.replace(/\$CLAUDE_PLUGIN_ROOT/g, resolve(baseDir, '..'));
|
||||
if (scriptPath.includes('$')) return null;
|
||||
return resolve(baseDir, scriptPath);
|
||||
}
|
||||
|
||||
/** Yield every command-hook { event, command } from a hooks object. */
|
||||
function* iterateCommandHooks(hooks) {
|
||||
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return;
|
||||
for (const [event, handlers] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(handlers)) continue;
|
||||
for (const group of handlers) {
|
||||
const hookList = group && Array.isArray(group.hooks) ? group.hooks : [];
|
||||
for (const hook of hookList) {
|
||||
if (hook && hook.type === 'command' && typeof hook.command === 'string') {
|
||||
yield { event, command: hook.command };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IO wrapper: walk discovered hooks (hooks.json + settings.json hooks), resolve
|
||||
* each command hook's script, and return the ones flagged by the heuristic.
|
||||
* Shared by feature-gap (the HKV scanner assesses inline on scripts it reads).
|
||||
*
|
||||
* @param {{ files: import('./file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<Array<{ event: string, scriptPath: string, file: string,
|
||||
* assessment: ReturnType<typeof assessHookAdditionalContext> }>>}
|
||||
*/
|
||||
export async function assessHookContextForRepo(discovery) {
|
||||
const flagged = [];
|
||||
const files = (discovery && Array.isArray(discovery.files)) ? discovery.files : [];
|
||||
|
||||
const hooksObjects = [];
|
||||
for (const file of files.filter((f) => f.type === 'hooks-json')) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
const parsed = content ? parseJson(content) : null;
|
||||
if (parsed) hooksObjects.push({ hooks: parsed.hooks || parsed, file });
|
||||
}
|
||||
for (const file of files.filter((f) => f.type === 'settings-json')) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
const parsed = content ? parseJson(content) : null;
|
||||
if (parsed && parsed.hooks && !Array.isArray(parsed.hooks)) {
|
||||
hooksObjects.push({ hooks: parsed.hooks, file });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { hooks, file } of hooksObjects) {
|
||||
const baseDir = dirname(file.absPath);
|
||||
for (const { event, command } of iterateCommandHooks(hooks)) {
|
||||
const scriptPath = extractScriptPath(command, baseDir);
|
||||
if (!scriptPath) continue;
|
||||
try {
|
||||
await stat(scriptPath);
|
||||
} catch {
|
||||
continue; // missing script — HKV reports that separately
|
||||
}
|
||||
const scriptContent = await readTextFile(scriptPath);
|
||||
const assessment = assessHookAdditionalContext({ scriptContent });
|
||||
if (assessment.flagged) {
|
||||
flagged.push({ event, scriptPath, file: file.absPath, assessment });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flagged;
|
||||
}
|
||||
|
|
@ -32,6 +32,11 @@ export const TRANSLATIONS = {
|
|||
description: 'Without `CLAUDE.md` at your project root, Claude has to work out your conventions from scratch every conversation. Project-specific guidance is the single highest-impact thing you can add.',
|
||||
recommendation: 'Create a file called `CLAUDE.md` in your project root. Start with a one-paragraph project overview, common commands, and any quirks Claude should know about.',
|
||||
},
|
||||
'Nested CLAUDE.md is not re-injected after compaction': {
|
||||
title: 'A `CLAUDE.md` in a subfolder can quietly drop out mid-session',
|
||||
description: 'Only the main project `CLAUDE.md` is restored when Claude trims older history. A `CLAUDE.md` in a subfolder loads when you open a file there, but it does not come back after a trim until you open one again.',
|
||||
recommendation: 'If its guidance must always apply, move that part into the main `CLAUDE.md`. Keep the subfolder file for things only needed when working in that folder.',
|
||||
},
|
||||
'CLAUDE.md is nearly empty': {
|
||||
title: 'Your `CLAUDE.md` is mostly empty',
|
||||
description: 'An empty instructions file gives Claude no project-specific context, so behavior falls back to defaults.',
|
||||
|
|
@ -72,6 +77,11 @@ export const TRANSLATIONS = {
|
|||
description: 'HTML comments still count as text sent to Claude on every turn — they don\'t actually hide anything.',
|
||||
recommendation: 'Delete the comment text if you don\'t want it sent, or convert it to a regular note.',
|
||||
},
|
||||
'CLAUDE.md points at files that are not there': {
|
||||
title: 'Your instructions file links to files that are not there',
|
||||
description: 'Some file paths written in `CLAUDE.md` point at files that do not exist — not next to the file, and not from your project root. Anyone following them finds nothing.',
|
||||
recommendation: 'Point each path at where the file actually lives, or drop the reference. Only clear relative paths are checked; web links, wildcards and plain file names are left alone.',
|
||||
},
|
||||
'Contains TODO/FIXME markers': {
|
||||
title: 'Your file has TODO or FIXME notes',
|
||||
description: 'These notes are sent to Claude on every turn even when they\'re internal reminders.',
|
||||
|
|
@ -91,10 +101,10 @@ export const TRANSLATIONS = {
|
|||
// ─────────────────────────────────────────────────────────────
|
||||
SET: {
|
||||
static: {
|
||||
'Unknown settings key': {
|
||||
title: 'A settings key isn\'t recognized',
|
||||
description: 'A key in your settings file isn\'t one Claude Code understands. It will be ignored.',
|
||||
recommendation: 'Check the key name for typos, or remove the key if it\'s no longer in use.',
|
||||
'Possible typo in settings key': {
|
||||
title: 'A settings key looks like a typo',
|
||||
description: 'A key in your settings file isn\'t recognized, but it\'s very close to a real one — likely a typo. Claude Code forwards unrecognized keys unchanged rather than rejecting them, so a misspelled key silently has no effect.',
|
||||
recommendation: 'Check the suggested key name. Fix the spelling, or keep the key if it\'s intentional (e.g. a newer key this audit doesn\'t know yet).',
|
||||
},
|
||||
'Deprecated settings key': {
|
||||
title: 'A settings key is no longer supported',
|
||||
|
|
@ -254,10 +264,15 @@ export const TRANSLATIONS = {
|
|||
description: 'Without scoping, the rule loads on every conversation regardless of which files you\'re working with.',
|
||||
recommendation: 'Add a scoping block at the top of the file to limit when the rule loads (see the details).',
|
||||
},
|
||||
'Rule uses deprecated "globs" field': {
|
||||
title: 'A rule uses an old field name',
|
||||
description: 'The field was renamed; the old name still works for now but may stop working in a future release.',
|
||||
recommendation: 'Rename the field to the current equivalent shown in the details.',
|
||||
'Large path-scoped rule is lost after compaction': {
|
||||
title: 'A large scoped rule can quietly drop out mid-session',
|
||||
description: 'Scoped rules load only when you open a matching file, and they fall out of context when Claude trims older history — they do not come back until you open a matching file again.',
|
||||
recommendation: 'If part of it must always apply, move that part into the main `CLAUDE.md`, which is restored automatically.',
|
||||
},
|
||||
'Rule uses "globs" instead of documented "paths"': {
|
||||
title: 'A rule uses an unrecognized scoping field',
|
||||
description: 'Claude Code\'s docs use `paths:` to scope a rule; `globs:` is not the documented field, so the rule may not scope the way you intend.',
|
||||
recommendation: 'Rename the field to `paths:` (see the details).',
|
||||
},
|
||||
'Rule file is not .md': {
|
||||
title: 'A rule file uses an unexpected extension',
|
||||
|
|
@ -425,12 +440,12 @@ export const TRANSLATIONS = {
|
|||
recommendation: 'Consider moving team-wide settings to project scope and keeping personal ones at user or local scope.',
|
||||
},
|
||||
'CLAUDE.md not modular': {
|
||||
title: 'Your instructions file is one big block',
|
||||
description: 'Splitting long instructions into smaller linked files makes them easier to maintain and easier on the loading time.',
|
||||
title: 'Your instructions all live in one file',
|
||||
description: 'Splitting your instructions into smaller linked files with `@import` or `.claude/rules/` keeps each part focused and easier to maintain.',
|
||||
recommendation: 'Break out long sections into separate files and link them with `@import`.',
|
||||
},
|
||||
'No path-scoped rules': {
|
||||
title: 'Your rules all load on every conversation',
|
||||
title: 'You haven\'t set up path-scoped rules yet',
|
||||
description: 'Path-scoped rules only load when you\'re working with files that match — keeps each conversation focused.',
|
||||
recommendation: 'Add scoping to your rules so they only load for the files they apply to.',
|
||||
},
|
||||
|
|
@ -480,7 +495,7 @@ export const TRANSLATIONS = {
|
|||
recommendation: 'Add fields like `model`, `tools`, or `description` to your skill files where useful.',
|
||||
},
|
||||
'No subagent isolation': {
|
||||
title: 'Your subagents share Claude\'s main work folder',
|
||||
title: 'You haven\'t set up subagent isolation yet',
|
||||
description: 'Isolated subagents run in their own copy of the repo so they can\'t accidentally disturb your main work.',
|
||||
recommendation: 'Add `isolation: worktree` to subagents that do destructive or experimental work.',
|
||||
},
|
||||
|
|
@ -489,11 +504,6 @@ export const TRANSLATIONS = {
|
|||
description: 'Dynamic context lets a skill see fresh information (file contents, command output) at the moment it runs, not at the time it was written.',
|
||||
recommendation: 'Use the dynamic-context block in skills that need up-to-date information.',
|
||||
},
|
||||
'No autoMode classifier': {
|
||||
title: 'You haven\'t set up auto-mode classification',
|
||||
description: 'Auto-mode classification helps Claude decide when to act on its own vs. ask you, based on the kind of task.',
|
||||
recommendation: 'Add an auto-mode classifier in your settings if you want this nuance.',
|
||||
},
|
||||
'No project .mcp.json in git': {
|
||||
title: 'Your team has no shared list of connected services',
|
||||
description: 'Without a project-level connected-services file, every teammate has to set up their own connections.',
|
||||
|
|
@ -519,6 +529,30 @@ export const TRANSLATIONS = {
|
|||
description: 'Language-server connections let Claude see types, error messages, and definitions the same way your editor does.',
|
||||
recommendation: 'Set up LSP integration if you work in a typed language.',
|
||||
},
|
||||
// Conditional levers. These are not "a feature you haven't set up" — they
|
||||
// fire only under a measured condition, so the generic _default would
|
||||
// misdescribe them. Every title the scanner can emit needs an entry here
|
||||
// (guarded in tests/scanners/feature-gap-scanner.test.mjs).
|
||||
'Bundled skills add to an over-budget skill listing': {
|
||||
title: 'Built-in skills are crowding an already-full skill list',
|
||||
description: 'Claude Code loads its own built-in skills into the same limited list as yours. Your list is already over budget, so entries risk being cut off and Claude may miss the right skill.',
|
||||
recommendation: 'Turn off the built-in skills to free up room — unless you use them, in which case shorten your own skill descriptions instead.',
|
||||
},
|
||||
'Prefer CLI over MCP for common operations': {
|
||||
title: 'Some connected services load their full tool list every turn',
|
||||
description: 'Most connected services only cost tokens when used, but yours are set to load everything upfront. That weight is there whether you use them or not.',
|
||||
recommendation: 'For services with a command-line equivalent (like `gh` or `aws`), the command line costs nothing until you run it.',
|
||||
},
|
||||
'Filter hook output before it enters context': {
|
||||
title: 'An automation is pasting its full output into the conversation',
|
||||
description: 'An automation that injects its output adds it to every turn that follows. Unfiltered command output can be much larger than the part that actually matters.',
|
||||
recommendation: 'Trim the output inside the script itself, so only the useful lines reach the conversation.',
|
||||
},
|
||||
'Subagents pin neither model nor effort': {
|
||||
title: 'Your helper agents all run at the same cost as your main session',
|
||||
description: 'A subagent that names no model inherits the one you are using, so routine delegated work costs the same as your hardest work. Reasoning effort is a separate dial with the same default.',
|
||||
recommendation: 'Give mechanical agents (search, extraction, summarizing) a smaller model or a lower effort level, and keep the strong settings for the work that needs judgement.',
|
||||
},
|
||||
},
|
||||
patterns: [],
|
||||
_default: {
|
||||
|
|
@ -559,6 +593,11 @@ export const TRANSLATIONS = {
|
|||
description: 'Skill descriptions load on every turn whether you use the skill or not. Long descriptions add up.',
|
||||
recommendation: 'Trim the description to one short sentence and move details into the skill body.',
|
||||
},
|
||||
'Stale plugin-cache versions (disk cleanup, zero live-context impact)': {
|
||||
title: 'Old plugin versions are sitting on disk (safe to delete)',
|
||||
description: 'Your plugin cache holds older versions that newer installs have replaced. They take up disk space but are never loaded into a conversation — so they cost zero tokens per turn. This is housekeeping, not a performance problem.',
|
||||
recommendation: 'Delete the old version folders to reclaim disk. The details list exactly which ones; the active version of each plugin stays untouched.',
|
||||
},
|
||||
},
|
||||
patterns: [
|
||||
{
|
||||
|
|
@ -706,6 +745,14 @@ export const TRANSLATIONS = {
|
|||
},
|
||||
},
|
||||
patterns: [
|
||||
{
|
||||
regex: /^Plugin agent sets ".+", which Claude Code ignores$/,
|
||||
translation: {
|
||||
title: 'A plugin agent sets a field Claude Code ignores',
|
||||
description: 'Plugin subagents ignore the `hooks`, `mcpServers`, and `permissionMode` settings — only agents in `.claude/agents/` honor them. The field shown here has no effect, and `permissionMode` can give a false sense of restriction.',
|
||||
recommendation: 'Remove the field, or move the agent into `.claude/agents/`, where it takes effect.',
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: /^Missing required field in plugin\.json/,
|
||||
translation: {
|
||||
|
|
@ -794,6 +841,11 @@ export const TRANSLATIONS = {
|
|||
description: 'Claude Code keeps every active skill\'s description in one shared listing it reads to choose which skill to use, and that listing has a limited size. Added up, your skills\' descriptions run past that size on a smaller setup, so Claude Code may drop some of them — and stop seeing those skills. This is an estimate; a larger setup has more room.',
|
||||
recommendation: 'Free up room: turn off bundled skills you do not use, collapse the heaviest ones so only their names show, or shorten the longest descriptions. The details show the measured total and the room available.',
|
||||
},
|
||||
'Skill body is large (loads on demand when the skill runs)': {
|
||||
title: 'A skill\'s body is large (it loads only when that skill runs)',
|
||||
description: 'This skill\'s instructions run longer than the rough guidance for a skill body. The body is not part of the always-loaded listing Claude reads every turn — it loads only when you invoke the skill, so it costs nothing until then. Once it loads, though, it stays in context for the rest of that session.',
|
||||
recommendation: 'Move reference material into supporting files the skill opens only when needed, so the body stays lean. For a heavy skill you can also run its body in a separate context with `context: fork` in the skill\'s settings.',
|
||||
},
|
||||
},
|
||||
patterns: [],
|
||||
_default: {
|
||||
|
|
@ -802,4 +854,54 @@ export const TRANSLATIONS = {
|
|||
recommendation: 'See the details for which skill to trim or turn off.',
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// OST — Output-Style Validation
|
||||
// Category: Configuration mistake
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
OST: {
|
||||
static: {
|
||||
'Custom output style removes built-in coding instructions': {
|
||||
title: 'A custom output style turns off Claude\'s coding know-how',
|
||||
description: 'This style replaces Claude Code\'s built-in coding guidance with only your own text, so while it\'s active Claude forgets how to scope changes, comment, and verify work. The setting that keeps that guidance is off by default.',
|
||||
recommendation: 'Add `keep-coding-instructions: true` to the top of the style file to keep that guidance. If you meant to drop it for a non-coding style, leave it as is.',
|
||||
},
|
||||
'Plugin output style overrides your selected output style': {
|
||||
title: 'A plugin is forcing its own output style on you',
|
||||
description: 'This plugin applies its own output style automatically whenever it\'s on, replacing the one you picked. If two plugins both do this, the first one to load wins.',
|
||||
recommendation: 'If you didn\'t want this, turn off the plugin or remove the force setting from its style. Otherwise there\'s nothing to do — the plugin works this way on purpose.',
|
||||
},
|
||||
'Configured output style does not exist': {
|
||||
title: 'Your chosen output style can\'t be found',
|
||||
description: 'Your settings point to an output style that doesn\'t exist by that name, so Claude Code quietly uses the default instead. The style you wanted never takes effect.',
|
||||
recommendation: 'Check the spelling against your styles (built-ins are Default, Explanatory, Learning, Proactive), add the missing style file, or remove the setting.',
|
||||
},
|
||||
},
|
||||
patterns: [],
|
||||
_default: {
|
||||
title: 'Something about your output styles needs a look',
|
||||
description: 'A check on your output styles flagged something worth reviewing.',
|
||||
recommendation: 'See the details for which output style to adjust.',
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// OPT — Optimization Lens (mechanism-fit)
|
||||
// Category: Missed opportunity
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
OPT: {
|
||||
static: {
|
||||
'A multi-step procedure in CLAUDE.md belongs in a skill': {
|
||||
title: 'A long checklist in CLAUDE.md could be a skill instead',
|
||||
description: 'Your CLAUDE.md has a multi-step procedure that loads on every turn, costing tokens whether or not you\'re doing that task. Procedures fit better as a skill, whose steps load only when you actually run them.',
|
||||
recommendation: 'Move the steps into a skill under `.claude/skills/`. Keep CLAUDE.md for facts Claude should always know, not step-by-step procedures.',
|
||||
},
|
||||
},
|
||||
patterns: [],
|
||||
_default: {
|
||||
title: 'Your setup could fit Claude Code a little better',
|
||||
description: 'A check found a setup that works but where a different mechanism would fit the job better.',
|
||||
recommendation: 'See the details for the suggested change.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,9 +38,23 @@ 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',
|
||||
OST: 'Configuration mistake',
|
||||
OPT: 'Missed opportunity',
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-finding `category` values that override the scanner-default impact label.
|
||||
* Needed when one finding inside a scanner means something different from the
|
||||
* scanner's usual bucket — e.g. stale plugin-cache versions are emitted by TOK
|
||||
* (normally "Wasted tokens") but load on ZERO turns, so their honest impact is
|
||||
* "Dead config" (present on disk, never loaded), not wasted per-turn tokens.
|
||||
*/
|
||||
const CATEGORY_TO_IMPACT = {
|
||||
'plugin-cache-hygiene': 'Dead config',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -123,7 +137,8 @@ export function humanizeFinding(finding) {
|
|||
}
|
||||
|
||||
const translation = lookupTranslation(finding.scanner, finding.title);
|
||||
const category = SCANNER_TO_CATEGORY[finding.scanner] || 'Other';
|
||||
const category =
|
||||
CATEGORY_TO_IMPACT[finding.category] || SCANNER_TO_CATEGORY[finding.scanner] || 'Other';
|
||||
const action = SEVERITY_TO_ACTION[finding.severity] || 'FYI';
|
||||
const relevance = computeRelevanceContext(finding.file);
|
||||
|
||||
|
|
|
|||
148
scanners/lib/knowledge-refresh.mjs
Normal file
148
scanners/lib/knowledge-refresh.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* knowledge-refresh — deterministic freshness core for the best-practices register.
|
||||
*
|
||||
* The "living" half of the v5.7 living knowledge base (Chunk 3). This module is the
|
||||
* PURE, deterministic part of the hybrid `/config-audit knowledge-refresh` motor: given a
|
||||
* register and an injected reference date, it classifies each entry as `fresh` or `stale`
|
||||
* by the age of its `source.verified` stamp. It NEVER touches the network and NEVER writes
|
||||
* — candidate discovery (polling CC changelog + Anthropic blog) and the human-approved
|
||||
* writes live in the command layer (Verifiseringsplikt: no unverified claim is auto-written).
|
||||
*
|
||||
* `referenceDate` is injected (not read from the clock here) so the function is fully
|
||||
* deterministic and unit-testable; the CLI passes today's date. See
|
||||
* docs/v5.7-optimization-lens-plan.md.
|
||||
*/
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* Normalize a reference date (Date or YYYY-MM-DD string) to a UTC-midnight {iso, ms}.
|
||||
* Throws TypeError on anything else — the reference date is required and must be valid.
|
||||
*/
|
||||
function normalizeReferenceDate(value) {
|
||||
let iso;
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) throw new TypeError('referenceDate is an invalid Date');
|
||||
iso = value.toISOString().slice(0, 10);
|
||||
} else if (typeof value === 'string' && DATE_RE.test(value)) {
|
||||
iso = value;
|
||||
} else {
|
||||
throw new TypeError('referenceDate must be a Date or a YYYY-MM-DD string');
|
||||
}
|
||||
const ms = Date.parse(`${iso}T00:00:00Z`);
|
||||
if (Number.isNaN(ms)) throw new TypeError(`referenceDate is not a real calendar date: ${iso}`);
|
||||
return { iso, ms };
|
||||
}
|
||||
|
||||
/** Parse an entry's `source.verified` to UTC-midnight ms, or null if missing/unparseable. */
|
||||
function verifiedMs(entry) {
|
||||
const v = entry && entry.source && entry.source.verified;
|
||||
if (typeof v !== 'string' || !DATE_RE.test(v)) return null;
|
||||
const ms = Date.parse(`${v}T00:00:00Z`);
|
||||
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
|
||||
* @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[]}>,
|
||||
* fresh: Array<{id:string, verified:string|undefined, ageDays:number}>,
|
||||
* counts: { total:number, stale:number, fresh:number }
|
||||
* }}
|
||||
*/
|
||||
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 = [];
|
||||
const fresh = [];
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// A source explicitly marked as superseded is stale no matter how fresh the
|
||||
// verified stamp is — the stamp certifies the OLD source.
|
||||
if (e && e.source && e.source.supersededBy) reasons.push('superseded');
|
||||
|
||||
// Evidence age: keyed on the newest published date across all sources, so a
|
||||
// re-read of the old source never clears it — only newer evidence does.
|
||||
const evMs = newestEvidenceMs(e);
|
||||
if (evMs !== null && Math.floor((ref.ms - evMs) / DAY_MS) > evidenceStaleAfterDays) {
|
||||
reasons.push('evidence-age');
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
stale.push({ id: e && e.id, verified, ageDays, url: e && e.source && e.source.url, claim: e && e.claim, reasons });
|
||||
} else {
|
||||
fresh.push({ id: e.id, verified, ageDays });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
referenceDate: ref.iso,
|
||||
staleAfterDays,
|
||||
evidenceStaleAfterDays,
|
||||
stale,
|
||||
fresh,
|
||||
counts: { total: entries.length, stale: stale.length, fresh: fresh.length },
|
||||
};
|
||||
}
|
||||
114
scanners/lib/lens-prefilter.mjs
Normal file
114
scanners/lib/lens-prefilter.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* lens-prefilter — deterministic, recall-oriented candidate generator for the
|
||||
* v5.7 optimization lens (CA-OPT) hybrid motor, Chunk 2b.
|
||||
*
|
||||
* The OPT *scanner* (Chunk 2a) handles the one mechanism-fit case it can decide
|
||||
* deterministically with high precision (a long numbered procedure → skill). The
|
||||
* other three cases in the register are PROSE-JUDGMENT calls — whether a line is
|
||||
* really lifecycle automation, a path-specific constraint, or an absolute
|
||||
* prohibition depends on reading intent, which a regex cannot settle. So the
|
||||
* hybrid motor splits the work:
|
||||
*
|
||||
* pre-filter (this module, CHEAP, recall-oriented)
|
||||
* → surfaces candidate lines tagged with the register rule they might fit
|
||||
* opus optimization-lens-agent (PRECISION gate)
|
||||
* → reads each candidate in context, keeps only genuine mechanism-fit
|
||||
* opportunities, cites the register rule + source
|
||||
*
|
||||
* Therefore this pre-filter deliberately errs toward recall: a false candidate
|
||||
* costs the agent a moment's judgement, a missed line is never recoverable. It
|
||||
* does, however, avoid the two obvious noise sources — fenced code blocks and
|
||||
* (when the caller passes the parsed body) YAML frontmatter — and it requires an
|
||||
* imperative-looking line for the path-specific class so plain "see docs/x.md"
|
||||
* references don't flood the candidate list.
|
||||
*
|
||||
* The detector names mirror the `lensCheck` fields of the register entries
|
||||
* (knowledge/best-practices.json), so the agent can map each candidate straight
|
||||
* back to its provenance.
|
||||
*
|
||||
* Zero external dependencies. Pure: input text → candidate array.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The three prose-judgment detectors, keyed to their register entries.
|
||||
* `mechanism` is the better-fit mechanism the register recommends.
|
||||
*/
|
||||
export const LENS_DETECTORS = Object.freeze([
|
||||
{ lensCheck: 'claude-md-lifecycle-phrasing', registerId: 'BP-MECH-001', mechanism: 'hook' },
|
||||
{ lensCheck: 'unscoped-path-specific-instruction', registerId: 'BP-MECH-002', mechanism: 'rule' },
|
||||
{ lensCheck: 'never-instruction', registerId: 'BP-MECH-004', mechanism: 'permission' },
|
||||
]);
|
||||
|
||||
// Lifecycle automation phrased as an instruction: "after every commit", "before
|
||||
// each push", "every time you …", "whenever you …", "always run". Recall-first.
|
||||
const LIFECYCLE_RE =
|
||||
/\b(?:after (?:every|each)|before (?:every|each)|on (?:every|each)|every time|each time|always run|whenever)\b/i;
|
||||
|
||||
// Absolute prohibition: a standalone "never" followed by an action word. Kept
|
||||
// permissive (recall); the agent decides whether it is a real hard rule.
|
||||
const NEVER_RE = /\bnever\s+[a-z]/i;
|
||||
|
||||
// A concrete path / glob / known-extension filename anywhere in the line.
|
||||
const PATH_RE =
|
||||
/(?:(?:\.{0,2}\/)?[\w.-]+\/[\w.*/-]+|\*\*?\/[\w.*-]+|\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)\b)/;
|
||||
|
||||
// An imperative / modal verb that marks a line as an instruction rather than a
|
||||
// bare cross-reference. Gates the path-specific class to cut "see foo/bar.md".
|
||||
const INSTRUCTION_RE =
|
||||
/\b(?:use|edit|run|always|must|should|put|place|write|add|modify|update|format|lint|test|name|store|keep|never|generate|build|deploy|commit)\b/i;
|
||||
|
||||
const getDetector = (lensCheck) => LENS_DETECTORS.find((d) => d.lensCheck === lensCheck);
|
||||
|
||||
function candidate(lensCheck, lineNo, lineText) {
|
||||
const d = getDetector(lensCheck);
|
||||
return {
|
||||
lensCheck,
|
||||
registerId: d.registerId,
|
||||
mechanism: d.mechanism,
|
||||
line: lineNo,
|
||||
text: lineText.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan CLAUDE.md text for prose-judgment mechanism-fit candidates.
|
||||
*
|
||||
* Pass the file body (frontmatter stripped) for clean line numbers; the caller
|
||||
* is then responsible for offsetting `line` by the body's start line. Raw text
|
||||
* also works — fenced code is skipped either way.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{lensCheck:string, registerId:string, mechanism:string, line:number, text:string}>}
|
||||
*/
|
||||
export function prefilterClaudeMd(text) {
|
||||
const lines = String(text == null ? '' : text).split('\n');
|
||||
const out = [];
|
||||
let inFence = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
const lineNo = i + 1;
|
||||
|
||||
// Toggle fenced code blocks (``` or ~~~). Fence lines themselves are skipped.
|
||||
if (/^\s*(?:```|~~~)/.test(raw)) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') continue;
|
||||
|
||||
if (LIFECYCLE_RE.test(raw)) {
|
||||
out.push(candidate('claude-md-lifecycle-phrasing', lineNo, raw));
|
||||
}
|
||||
if (NEVER_RE.test(raw)) {
|
||||
out.push(candidate('never-instruction', lineNo, raw));
|
||||
}
|
||||
if (PATH_RE.test(raw) && INSTRUCTION_RE.test(raw)) {
|
||||
out.push(candidate('unscoped-path-specific-instruction', lineNo, raw));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
206
scanners/lib/mcp-deferral.mjs
Normal file
206
scanners/lib/mcp-deferral.mjs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* MCP tool-schema deferral assessment (v5.10 B4).
|
||||
*
|
||||
* By default Claude Code DEFERS MCP tool schemas: only tool *names* enter the
|
||||
* always-loaded prefix (~120 tokens total) and full schemas load on demand via
|
||||
* tool search. Certain conditions force ALL full schemas into the always-loaded
|
||||
* prefix instead — paid on every turn whether or not a tool is used.
|
||||
*
|
||||
* This module is a STATIC, CONFIG-FILE assessment. It triggers only on signals
|
||||
* that live in config files a scanner can read deterministically:
|
||||
* - settings.json `env.ENABLE_TOOL_SEARCH` = "false" (HIGH confidence)
|
||||
* - settings.json `permissions.deny` contains "ToolSearch" (HIGH confidence)
|
||||
* - settings.json `model` is a Haiku model (MEDIUM confidence)
|
||||
* - a per-server `.mcp.json` `alwaysLoad: true` (HIGH confidence)
|
||||
*
|
||||
* It deliberately does NOT read process.env shell variables. Tool search is also
|
||||
* disabled on Vertex AI, with a custom ANTHROPIC_BASE_URL (non-first-party host),
|
||||
* or after a runtime `/model` switch to Haiku — but those are launch/runtime
|
||||
* state, not config files, so a static scan cannot see them without becoming
|
||||
* machine-dependent. They are disclosed (DEFERRAL_DISCLOSURE), never triggered.
|
||||
*
|
||||
* Mechanism verified 2026-06-23 against code.claude.com/docs:
|
||||
* context-window.md (MCP tools deferred, ~120 tok), mcp.md#configure-tool-search
|
||||
* + #exempt-a-server-from-deferral, costs.md (reduce MCP server overhead).
|
||||
*
|
||||
* The pure `assessMcpDeferral` takes already-parsed inputs so it is fully
|
||||
* unit-testable without file IO. `assessMcpDeferralForRepo` is the thin IO
|
||||
* wrapper shared by token-hotspots and feature-gap.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { readTextFile } from './file-discovery.mjs';
|
||||
import { parseJson } from './yaml-parser.mjs';
|
||||
import { readActiveMcpServers } from './active-config-reader.mjs';
|
||||
|
||||
// Aggregate forced-upfront schema cost (tokens) → severity ladder. MCP token
|
||||
// estimates are base 500 + ~200/tool (active-config-reader estimateTokens), so
|
||||
// these anchor on a couple of small servers (medium) vs a large one / several
|
||||
// (high). Heuristic, not measured — disclosed in every finding.
|
||||
export const FORCED_SCHEMA_TOKENS_MEDIUM = 1500;
|
||||
export const FORCED_SCHEMA_TOKENS_HIGH = 5000;
|
||||
|
||||
// Appended to every CA-TOK-006 finding: the launch/runtime conditions a static
|
||||
// config scan cannot see, so the user knows to check them manually.
|
||||
export const DEFERRAL_DISCLOSURE =
|
||||
'static config-file check: tool search is ALSO disabled (all MCP schemas forced ' +
|
||||
'upfront) on Vertex AI, with a custom ANTHROPIC_BASE_URL (non-first-party host), ' +
|
||||
'or after a runtime /model switch to a Haiku model — none visible to a static ' +
|
||||
'scan, so verify at launch. Tool-level "anthropic/alwaysLoad" set server-side is ' +
|
||||
'likewise invisible. Per-server alwaysLoad requires Claude Code v2.1.121+.';
|
||||
|
||||
/**
|
||||
* Does a permissions.deny list disable the ToolSearch tool? Matches a bare
|
||||
* "ToolSearch" tool name (with or without an argument suffix), same shape Claude
|
||||
* Code uses for built-in tool denies.
|
||||
*/
|
||||
function denyListDisablesToolSearch(deny) {
|
||||
if (!Array.isArray(deny)) return false;
|
||||
return deny.some((entry) => {
|
||||
if (typeof entry !== 'string') return false;
|
||||
const tool = entry.replace(/\(.*\)$/, '').trim();
|
||||
return tool === 'ToolSearch';
|
||||
});
|
||||
}
|
||||
|
||||
function isHaikuModel(model) {
|
||||
return typeof model === 'string' && /haiku/i.test(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map severity for the forced-upfront aggregate. High-confidence reasons scale
|
||||
* with the token cost; medium-confidence reasons (inferred from a configured
|
||||
* model) are capped at medium so the finding never overstates certainty.
|
||||
*
|
||||
* @param {number} aggregateTokens
|
||||
* @param {'high'|'medium'} confidence
|
||||
* @returns {'high'|'medium'|'low'}
|
||||
*/
|
||||
export function severityForForcedSchemas(aggregateTokens, confidence) {
|
||||
const tok = typeof aggregateTokens === 'number' ? aggregateTokens : 0;
|
||||
if (confidence === 'high') {
|
||||
if (tok >= FORCED_SCHEMA_TOKENS_HIGH) return 'high';
|
||||
if (tok >= FORCED_SCHEMA_TOKENS_MEDIUM) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
// medium confidence: cap at medium
|
||||
if (tok >= FORCED_SCHEMA_TOKENS_HIGH) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess whether MCP tool schemas are forced into the always-loaded prefix.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {{ env?: object, permissions?: { deny?: string[] }, model?: string }} [args.settings]
|
||||
* merged settings.json view (env block, permissions, model).
|
||||
* @param {Array<{name:string, source?:string, enabled?:boolean, toolCount?:number,
|
||||
* estimatedTokens?:number, alwaysLoad?:boolean}>} [args.mcpServers]
|
||||
* @returns {{
|
||||
* toolSearchDisabled: boolean, reason: string|null,
|
||||
* confidence: 'high'|'medium'|null, thresholdMode: boolean,
|
||||
* alwaysLoadServers: object[], affectedServers: object[],
|
||||
* aggregateTokens: number, forcedUpfront: boolean,
|
||||
* }}
|
||||
*/
|
||||
export function assessMcpDeferral({ settings = {}, mcpServers = [] } = {}) {
|
||||
const s = settings || {};
|
||||
const tsRaw = s.env && typeof s.env === 'object' ? s.env.ENABLE_TOOL_SEARCH : undefined;
|
||||
const ts = String(tsRaw ?? '').trim().toLowerCase();
|
||||
const denyTS = denyListDisablesToolSearch(s.permissions?.deny);
|
||||
const haiku = isHaikuModel(s.model);
|
||||
|
||||
let toolSearchDisabled = false;
|
||||
let reason = null;
|
||||
let confidence = null;
|
||||
let thresholdMode = false;
|
||||
|
||||
if (ts === 'false') {
|
||||
toolSearchDisabled = true;
|
||||
reason = 'enable-tool-search-false';
|
||||
confidence = 'high';
|
||||
} else if (denyTS) {
|
||||
toolSearchDisabled = true;
|
||||
reason = 'deny-tool-search';
|
||||
confidence = 'high';
|
||||
} else if (haiku) {
|
||||
// Haiku lacks tool_reference support, so tool search cannot run even when
|
||||
// ENABLE_TOOL_SEARCH=true. Medium confidence: the configured model can be
|
||||
// switched at runtime (/model), which a static scan cannot observe.
|
||||
toolSearchDisabled = true;
|
||||
reason = 'haiku-model';
|
||||
confidence = 'medium';
|
||||
} else if (ts === 'true') {
|
||||
toolSearchDisabled = false;
|
||||
} else if (ts.startsWith('auto')) {
|
||||
// Threshold mode (auto / auto:N): schemas load upfront only when they fit a
|
||||
// percentage of the context window — not a clear always-load. Info, not a
|
||||
// forced-upfront trigger.
|
||||
thresholdMode = true;
|
||||
}
|
||||
|
||||
const active = (Array.isArray(mcpServers) ? mcpServers : []).filter(
|
||||
(m) => m && m.enabled !== false,
|
||||
);
|
||||
const alwaysLoadServers = active.filter((m) => m.alwaysLoad === true);
|
||||
const affectedServers = toolSearchDisabled ? active : alwaysLoadServers;
|
||||
const aggregateTokens = affectedServers.reduce(
|
||||
(sum, m) => sum + (typeof m.estimatedTokens === 'number' ? m.estimatedTokens : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
toolSearchDisabled,
|
||||
reason,
|
||||
confidence,
|
||||
thresholdMode,
|
||||
alwaysLoadServers,
|
||||
affectedServers,
|
||||
aggregateTokens,
|
||||
forcedUpfront: affectedServers.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge project + local settings.json for the deferral check. Scoped to the
|
||||
* audited path (NOT the user cascade) so the result stays deterministic and free
|
||||
* of ambient HOME leakage — mirrors Pattern G's project-local scoping. Local
|
||||
* overrides project; env/permissions shallow-merge, deny lists concatenate.
|
||||
*/
|
||||
async function readMergedProjectSettings(repoPath) {
|
||||
const merged = { env: {}, permissions: {}, model: undefined };
|
||||
for (const rel of ['.claude/settings.json', '.claude/settings.local.json']) {
|
||||
const content = await readTextFile(resolve(repoPath, rel));
|
||||
if (!content) continue;
|
||||
const parsed = parseJson(content);
|
||||
if (!parsed || typeof parsed !== 'object') continue;
|
||||
if (parsed.env && typeof parsed.env === 'object') Object.assign(merged.env, parsed.env);
|
||||
if (parsed.permissions && typeof parsed.permissions === 'object') {
|
||||
const deny = [
|
||||
...(Array.isArray(merged.permissions.deny) ? merged.permissions.deny : []),
|
||||
...(Array.isArray(parsed.permissions.deny) ? parsed.permissions.deny : []),
|
||||
];
|
||||
merged.permissions = { ...merged.permissions, ...parsed.permissions, deny };
|
||||
}
|
||||
if (typeof parsed.model === 'string') merged.model = parsed.model;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* IO wrapper: assess MCP deferral for a repo path. Scopes MCP servers to active
|
||||
* project-local `.mcp.json` (plugin / ~/.claude.json servers are the manifest's
|
||||
* concern). Pass `mcpServers` (e.g. an already-loaded activeConfig.mcpServers) to
|
||||
* avoid a re-read; otherwise they are read via readActiveMcpServers.
|
||||
*
|
||||
* @param {string} repoPath
|
||||
* @param {{ mcpServers?: object[] }} [opts]
|
||||
*/
|
||||
export async function assessMcpDeferralForRepo(repoPath, opts = {}) {
|
||||
const all = Array.isArray(opts.mcpServers)
|
||||
? opts.mcpServers
|
||||
: await readActiveMcpServers(repoPath);
|
||||
const projectLocal = all.filter((m) => m && m.enabled && m.source === '.mcp.json');
|
||||
const settings = await readMergedProjectSettings(repoPath);
|
||||
return assessMcpDeferral({ settings, mcpServers: projectLocal });
|
||||
}
|
||||
|
|
@ -5,18 +5,13 @@
|
|||
*/
|
||||
|
||||
import { riskScore, riskBand, verdict } from './severity.mjs';
|
||||
|
||||
let findingCounter = 0;
|
||||
|
||||
/** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */
|
||||
export function resetCounter() {
|
||||
findingCounter = 0;
|
||||
}
|
||||
import { findingId } from './finding-codes.mjs';
|
||||
|
||||
/**
|
||||
* Create a finding object with auto-incremented ID.
|
||||
* Create a finding object. The ID names the CHECK — see `finding-codes.mjs`.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.scanner - 3-letter scanner prefix (CML, SET, HKV, RUL, etc.)
|
||||
* @param {string} opts.code - check key declared in FINDING_CODES for this scanner
|
||||
* @param {string} opts.severity - critical | high | medium | low | info
|
||||
* @param {string} opts.title
|
||||
* @param {string} opts.description
|
||||
|
|
@ -30,10 +25,8 @@ export function resetCounter() {
|
|||
* @returns {object}
|
||||
*/
|
||||
export function finding(opts) {
|
||||
findingCounter++;
|
||||
const id = `CA-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
|
||||
const result = {
|
||||
id,
|
||||
id: findingId(opts.scanner, opts.code),
|
||||
scanner: opts.scanner,
|
||||
severity: opts.severity,
|
||||
title: opts.title,
|
||||
|
|
|
|||
109
scanners/lib/prompting-model-scope.mjs
Normal file
109
scanners/lib/prompting-model-scope.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Model-scoped ANNOTATION on top of the subtraction lens.
|
||||
*
|
||||
* This module generates no candidates of its own. `optimize --subtract` already
|
||||
* surfaces compensatory instructions through the single `compensatory-instruction`
|
||||
* detector (`BP-SUB-001`); this answers a narrower question over text that
|
||||
* already passed it: is this specifically the class of instruction a named model
|
||||
* documents as redundant — self-verification, or verification delegated to a
|
||||
* subagent?
|
||||
*
|
||||
* A second competing detector is deliberately NOT what this is. The register
|
||||
* entry it cites carries `lensCheck: null`, the same discipline `BP-JUDG-001`
|
||||
* shipped under: a plausible-looking detector for "instruction a model no longer
|
||||
* needs" measured 7/7 false positives across 409 real CLAUDE.md files, so this
|
||||
* claim class rides an existing measured detector rather than widening the
|
||||
* candidate set.
|
||||
*
|
||||
* Precision comes from requiring a reflexive/delegate TARGET alongside the
|
||||
* verify verb, never from narrowing the verb list. A bare "check"/"verify" also
|
||||
* matches EXTERNAL verification ("check the CI status") — which stays a true
|
||||
* negative here even though it is, correctly, still a `BP-SUB-001` candidate
|
||||
* upstream.
|
||||
*
|
||||
* Pure: text → annotation or null. Zero external dependencies.
|
||||
*/
|
||||
import { LB, RB } from './subtraction-prefilter.mjs';
|
||||
|
||||
/**
|
||||
* The reflexive self-verification target. This is what narrows a bare
|
||||
* verify/check imperative down to the specific claim the model's documented
|
||||
* self-correction contradicts.
|
||||
*/
|
||||
const SELF_TARGET_RE = new RegExp(
|
||||
LB +
|
||||
'(?:your (?:own )?(?:work|output|answer|changes)|yourself|' +
|
||||
'before (?:responding|submitting|finalizing)|dine egne?|deg selv|før du svarer)' +
|
||||
RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Verify verbs — deliberately as broad as `subtraction-prefilter.mjs`'s
|
||||
* `IMPERATIVE_RE`. Breadth here is safe because a co-occurring target is
|
||||
* required; narrowing the list would only lose true positives.
|
||||
*/
|
||||
const VERIFY_VERB_RE = new RegExp(
|
||||
LB +
|
||||
'(?:double-check|re-verify|re-check|confirm|verify|review|check|' +
|
||||
'dobbeltsjekk|verifiser|sjekk)' +
|
||||
RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Delegated verification. Order-free on purpose: it must match both
|
||||
* "verify X with a subagent" and "use a subagent to verify X".
|
||||
*/
|
||||
const DELEGATE_VERIFY_RE = new RegExp(
|
||||
LB + '(?:subagent|sub-agent|another (?:agent|instance)|task tool)' + RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Does this text carry a self- or delegate-targeted verification instruction?
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModelContradictedVerification(text) {
|
||||
return (
|
||||
VERIFY_VERB_RE.test(text) &&
|
||||
(SELF_TARGET_RE.test(text) || DELEGATE_VERIFY_RE.test(text))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model names are compared on their alphanumeric skeleton, so "opus-5",
|
||||
* "Opus 5" and "OPUS5" are one model. A typo'd name simply fails to match —
|
||||
* the CLI reports that it did not recognize the name rather than reporting a
|
||||
* silent zero.
|
||||
*
|
||||
* @param {unknown} s
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalizeModel = (s) =>
|
||||
String(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
|
||||
/**
|
||||
* Annotate a subtraction candidate with a model-scoped register citation.
|
||||
*
|
||||
* @param {string} text candidate block text (already a `BP-SUB-001` candidate)
|
||||
* @param {string|null|undefined} targetModel the model named by `--for-model`
|
||||
* @param {Array<{id: string, claim: string, modelScope?: string[]}>} entries
|
||||
* confirmed register entries with `category: 'prompting-fit'`
|
||||
* @returns {{registerId: string, claim: string, requestedModel: string}|null}
|
||||
*/
|
||||
export function matchModelScope(text, targetModel, entries) {
|
||||
if (!targetModel || !isModelContradictedVerification(text)) return null;
|
||||
const wanted = normalizeModel(targetModel);
|
||||
const entry = (entries || []).find((e) =>
|
||||
(e.modelScope || []).some((m) => normalizeModel(m) === wanted),
|
||||
);
|
||||
if (!entry) return null;
|
||||
return { registerId: entry.id, claim: entry.claim, requestedModel: targetModel };
|
||||
}
|
||||
|
||||
export { normalizeModel };
|
||||
49
scanners/lib/require-target-dir.mjs
Normal file
49
scanners/lib/require-target-dir.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Target-path precondition shared by the target-taking CLIs.
|
||||
*
|
||||
* A scan target is a scan ROOT. If it does not exist, or is not a directory,
|
||||
* the scanner cannot do its job — and by the plugin's exit-code contract that
|
||||
* is exit 3, not a verdict. Codes 0/1/2 are PASS/WARNING/FAIL *about a
|
||||
* configuration that was examined*; every command template gates on exactly
|
||||
* that distinction, so returning a verdict for an unreadable target sends a
|
||||
* typo'd path through the whole workflow as a clean result.
|
||||
*
|
||||
* Measured before this guard existed (session #56):
|
||||
* node scanners/posture.mjs /nonexistent/path/xyz → exit 0,
|
||||
* "Health: B (86/100) — Good shape — a few items to address"
|
||||
*
|
||||
* The message and exit code here are not new: `manifest.mjs`,
|
||||
* `token-hotspots-cli.mjs`, `whats-active.mjs` and `optimize-lens-cli.mjs`
|
||||
* already carried this exact block inline. This module is where the CLIs that
|
||||
* lacked it get it from; the four that have their own copies are left alone
|
||||
* (consolidating them is a cleanup, not part of this fix).
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Verify that `absPath` is an existing directory.
|
||||
*
|
||||
* Writes the diagnostic to stderr itself, so callers stay a two-line guard:
|
||||
*
|
||||
* if (!(await requireTargetDir(resolvedPath))) { process.exitCode = 3; return; }
|
||||
*
|
||||
* Never throws, and never calls `process.exit()` — an abrupt exit discards
|
||||
* unflushed stdout when the CLI is on a pipe.
|
||||
*
|
||||
* @param {string} absPath - Resolved absolute target path.
|
||||
* @returns {Promise<boolean>} true when the target is usable as a scan root.
|
||||
*/
|
||||
export async function requireTargetDir(absPath) {
|
||||
try {
|
||||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,17 +20,17 @@ const GRADE_CONTEXT = {
|
|||
|
||||
// --- Tier weights for utilization calculation ---
|
||||
const TIER_WEIGHTS = { t1: 3, t2: 2, t3: 1, t4: 1 };
|
||||
const TIER_COUNTS = { t1: 5, t2: 7, t3: 8, t4: 5 };
|
||||
const TOTAL_DIMENSIONS = 25;
|
||||
const TIER_COUNTS = { t1: 5, t2: 7, t3: 7, t4: 5 };
|
||||
const TOTAL_DIMENSIONS = 24;
|
||||
const MAX_WEIGHTED = Object.entries(TIER_COUNTS).reduce(
|
||||
(sum, [tier, count]) => sum + count * TIER_WEIGHTS[tier],
|
||||
0,
|
||||
); // 5*3 + 7*2 + 8*1 + 5*1 = 42
|
||||
); // 5*3 + 7*2 + 7*1 + 5*1 = 41
|
||||
|
||||
/**
|
||||
* Calculate weighted utilization from GAP scanner findings.
|
||||
* @param {object[]} gapFindings - Array of GAP scanner findings (each has .category = t1|t2|t3|t4)
|
||||
* @param {number} [totalDimensions=25]
|
||||
* @param {number} [totalDimensions=24]
|
||||
* @returns {{ score: number, overhang: number }}
|
||||
*/
|
||||
export function calculateUtilization(gapFindings, totalDimensions = TOTAL_DIMENSIONS) {
|
||||
|
|
@ -102,7 +102,7 @@ function findGapId(finding) {
|
|||
return TITLE_TO_ID[finding.title] || 'unknown';
|
||||
}
|
||||
|
||||
/** Title→ID mapping for all 25 gap checks */
|
||||
/** Title→ID mapping for all 24 gap checks */
|
||||
const TITLE_TO_ID = {
|
||||
'No CLAUDE.md file': 't1_1',
|
||||
'No permissions configured': 't1_2',
|
||||
|
|
@ -123,7 +123,6 @@ const TITLE_TO_ID = {
|
|||
'No advanced skill frontmatter': 't3_5',
|
||||
'No subagent isolation': 't3_6',
|
||||
'No dynamic skill context': 't3_7',
|
||||
'No autoMode classifier': 't3_8',
|
||||
'No project .mcp.json in git': 't4_1',
|
||||
'No custom plugin': 't4_2',
|
||||
'Agent teams not enabled': 't4_3',
|
||||
|
|
@ -166,8 +165,11 @@ const SCANNER_AREA_MAP = {
|
|||
TOK: 'Token Efficiency',
|
||||
CPS: 'Token Efficiency',
|
||||
SKL: 'Token Efficiency',
|
||||
AGT: 'Token Efficiency',
|
||||
DIS: 'Settings',
|
||||
COL: 'Plugin Hygiene',
|
||||
OST: 'Settings',
|
||||
OPT: 'CLAUDE.md',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -401,4 +403,4 @@ export function generateHealthScorecard(areaScores, opportunityCount, options =
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };
|
||||
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, TOTAL_DIMENSIONS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };
|
||||
|
|
|
|||
|
|
@ -36,6 +36,23 @@ export const DESCRIPTION_CAP = 1536;
|
|||
// The 200k/1M window constants live in context-window.mjs (single source of
|
||||
// truth, shared with the CML CLAUDE.md char-budget check); re-exported here so
|
||||
// existing importers of this module keep working.
|
||||
// D2 re-verification (2026-08-09, CC 2.1.226). CC 2.1.226's /doctor reports a
|
||||
// combined skill+command+agent listing and puts the budget near ~1% (~10,000
|
||||
// tok on a 1M window) — half of ours. Checked against the primary source
|
||||
// before touching the number: the CC changelog contains EXACTLY ONE
|
||||
// budget-fraction statement (L3786, under 2.1.32) and no later entry
|
||||
// supersedes it, so 2% stands and CA-SKL-002 is NOT a /doctor duplicate
|
||||
// carrying a stale figure. /doctor's arithmetic could not be reconciled from
|
||||
// the changelog, and /doctor discloses its own numbers as disk estimates
|
||||
// (chars÷4), so its ~1% is recorded, not adopted.
|
||||
//
|
||||
// NOT VERIFIED, deliberately left alone: L3786 says "skill CHARACTER budget
|
||||
// now scales with context window (2% of context)". We express the budget in
|
||||
// TOKENS (0.02 × 200k = 4000 tok). Whether CC's budget is 2% counted in
|
||||
// characters or in tokens is not resolvable from the changelog wording, and no
|
||||
// primary source settles it — a 4× difference rides on the answer. Changing
|
||||
// the constant on that ambiguity would be a guess; it stays until a primary
|
||||
// source decides it.
|
||||
export const BUDGET_FRACTION = 0.02;
|
||||
export const AGGREGATE_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * CONTEXT_WINDOW_ANCHOR); // 4000
|
||||
export const LARGE_CONTEXT_BUDGET_TOKENS = Math.round(BUDGET_FRACTION * LARGE_CONTEXT_WINDOW); // 20000
|
||||
|
|
@ -48,6 +65,19 @@ export const BUDGET_CALIBRATION_NOTE =
|
|||
`window; at ${withCommas(LARGE_CONTEXT_WINDOW)} context the budget is ~${withCommas(LARGE_CONTEXT_BUDGET_TOKENS)} ` +
|
||||
'tok and you are likely within it. this is an estimate, not measured telemetry';
|
||||
|
||||
// Skill-body size guidance (CA-SKL-003). A SKILL.md body over ~5,000 tokens
|
||||
// (~500 lines / ~20k chars) should split reference content into supporting files
|
||||
// (Claude Code skill-authoring guidance). Unlike the listing budget above, the
|
||||
// body is an ON-DEMAND cost: it loads only when the skill is invoked, not every
|
||||
// turn — so this is a LOW-severity efficiency signal, not an always-loaded bill.
|
||||
export const BODY_TOKEN_THRESHOLD = 5000;
|
||||
|
||||
// Honest framing for the body-size finding: distinguishes on-demand from
|
||||
// always-loaded cost and flags the figure as an estimate. Appended to evidence.
|
||||
export const BODY_CALIBRATION_NOTE =
|
||||
'this is the skill BODY (SKILL.md below the frontmatter), which loads ON DEMAND only when the ' +
|
||||
'skill is invoked - NOT every turn like the always-loaded listing. estimate (chars/4), not measured telemetry';
|
||||
|
||||
/**
|
||||
* @typedef {object} BudgetAssessment
|
||||
* @property {number} scanned - number of descriptions assessed
|
||||
|
|
@ -64,23 +94,26 @@ export const BUDGET_CALIBRATION_NOTE =
|
|||
* flags it — so the aggregate does not double-count it).
|
||||
*
|
||||
* @param {number[]} descLengths - one entry per active skill (description char count)
|
||||
* @param {number} [budgetTokens=AGGREGATE_BUDGET_TOKENS] - the listing budget to
|
||||
* measure against. Defaults to the 200k-anchored 4,000 tok; B8 passes a
|
||||
* window-calibrated budget. Defaulting keeps existing callers byte-stable.
|
||||
* @returns {BudgetAssessment}
|
||||
*/
|
||||
export function assessSkillListingBudget(descLengths) {
|
||||
export function assessSkillListingBudget(descLengths, budgetTokens = AGGREGATE_BUDGET_TOKENS) {
|
||||
let aggregateChars = 0;
|
||||
for (const len of descLengths) {
|
||||
const safe = (typeof len === 'number' && Number.isFinite(len) && len > 0) ? len : 0;
|
||||
aggregateChars += Math.min(safe, DESCRIPTION_CAP);
|
||||
}
|
||||
const aggregateTokens = estimateTokens(aggregateChars, 'markdown');
|
||||
const overBudget = aggregateTokens > AGGREGATE_BUDGET_TOKENS;
|
||||
const overBudget = aggregateTokens > budgetTokens;
|
||||
return {
|
||||
scanned: descLengths.length,
|
||||
aggregateChars,
|
||||
aggregateTokens,
|
||||
budgetTokens: AGGREGATE_BUDGET_TOKENS,
|
||||
budgetTokens,
|
||||
overBudget,
|
||||
overBy: overBudget ? aggregateTokens - AGGREGATE_BUDGET_TOKENS : 0,
|
||||
overBy: overBudget ? aggregateTokens - budgetTokens : 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +124,9 @@ export function assessSkillListingBudget(descLengths) {
|
|||
* @property {string|null} pluginName
|
||||
* @property {string} path
|
||||
* @property {number} descLength
|
||||
* @property {number} bodyChars - SKILL.md body length below the frontmatter (on-demand cost)
|
||||
* @property {number} bodyLines - body line count
|
||||
* @property {number} bodyTokens - estimateTokens(bodyChars, 'markdown')
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -99,9 +135,11 @@ export function assessSkillListingBudget(descLengths) {
|
|||
* enumerateSkills). Callers that run under test MUST override HOME (see the
|
||||
* hermetic-home helper / runScannerWithHome pattern).
|
||||
*
|
||||
* @param {number} [budgetTokens=AGGREGATE_BUDGET_TOKENS] - listing budget for the
|
||||
* aggregate assessment (B8 window-calibration); defaults keep callers byte-stable.
|
||||
* @returns {Promise<{ skills: ActiveSkillEntry[], aggregate: BudgetAssessment }>}
|
||||
*/
|
||||
export async function measureActiveSkillListing() {
|
||||
export async function measureActiveSkillListing(budgetTokens = AGGREGATE_BUDGET_TOKENS) {
|
||||
const plugins = await enumeratePlugins();
|
||||
const allSkills = await enumerateSkills(plugins);
|
||||
|
||||
|
|
@ -110,18 +148,24 @@ export async function measureActiveSkillListing() {
|
|||
if (!skill || typeof skill.path !== 'string') continue;
|
||||
const content = await readTextFile(skill.path);
|
||||
if (!content) continue;
|
||||
const fm = parseFrontmatter(content)?.frontmatter || null;
|
||||
const parsed = parseFrontmatter(content);
|
||||
const fm = parsed?.frontmatter || null;
|
||||
const desc = (fm && typeof fm.description === 'string') ? fm.description : '';
|
||||
const body = (parsed && typeof parsed.body === 'string') ? parsed.body : '';
|
||||
const bodyChars = body.length;
|
||||
skills.push({
|
||||
name: skill.name,
|
||||
source: skill.source,
|
||||
pluginName: skill.pluginName,
|
||||
path: skill.path,
|
||||
descLength: desc.length,
|
||||
bodyChars,
|
||||
bodyLines: bodyChars === 0 ? 0 : body.split('\n').length,
|
||||
bodyTokens: estimateTokens(bodyChars, 'markdown'),
|
||||
});
|
||||
}
|
||||
|
||||
const aggregate = assessSkillListingBudget(skills.map((s) => s.descLength));
|
||||
const aggregate = assessSkillListingBudget(skills.map((s) => s.descLength), budgetTokens);
|
||||
return { skills, aggregate };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,41 @@ export function isSimilar(a, b, threshold = 0.8) {
|
|||
return similarity >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Levenshtein edit distance between two strings (insertions, deletions,
|
||||
* substitutions; a transposition counts as 2). Used for typo detection on
|
||||
* settings keys. Zero external dependencies, O(a*b) with two rolling rows.
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @returns {number}
|
||||
*/
|
||||
export function levenshtein(a, b) {
|
||||
if (a === b) return 0;
|
||||
const al = a.length;
|
||||
const bl = b.length;
|
||||
if (al === 0) return bl;
|
||||
if (bl === 0) return al;
|
||||
let prev = new Array(bl + 1);
|
||||
let curr = new Array(bl + 1);
|
||||
for (let j = 0; j <= bl; j++) prev[j] = j;
|
||||
for (let i = 1; i <= al; i++) {
|
||||
curr[0] = i;
|
||||
const ac = a.charCodeAt(i - 1);
|
||||
for (let j = 1; j <= bl; j++) {
|
||||
const cost = ac === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
curr[j] = Math.min(
|
||||
prev[j] + 1, // deletion
|
||||
curr[j - 1] + 1, // insertion
|
||||
prev[j - 1] + cost, // substitution
|
||||
);
|
||||
}
|
||||
const tmp = prev;
|
||||
prev = curr;
|
||||
curr = tmp;
|
||||
}
|
||||
return prev[bl];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all key-like patterns from a settings.json or similar config.
|
||||
* @param {object} obj
|
||||
|
|
|
|||
350
scanners/lib/subtraction-prefilter.mjs
Normal file
350
scanners/lib/subtraction-prefilter.mjs
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
/**
|
||||
* subtraction-prefilter — deterministic candidate generator for the v5.13
|
||||
* subtraction lens (`/config-audit optimize --subtract`, BP-SUB-001).
|
||||
*
|
||||
* Every other command in this plugin asks an ADDITION question — what could you
|
||||
* add, what would fit a better mechanism, how expensive is what you have. This
|
||||
* module asks the inverse: **what is no longer earning its always-loaded rent?**
|
||||
*
|
||||
* It is the mirror image of `lens-prefilter` in one important way. That module
|
||||
* is recall-first, because a false candidate only costs the judge a moment's
|
||||
* thought. Here a false candidate is a proposal to DELETE something, so the
|
||||
* polarity flips: precision-first, and a hard deterministic floor
|
||||
* (`floor-exclusion`) that the judge is not allowed to override.
|
||||
*
|
||||
* ## Granularity: leaf blocks
|
||||
*
|
||||
* The one design choice the hand-built fasit deliberately left open. A block is
|
||||
* one markdown *leaf*: a list item including its wrapped continuation lines, or
|
||||
* a paragraph. Headings, table rows and fenced code are structural, never
|
||||
* candidates.
|
||||
*
|
||||
* Both halves of that choice are load-bearing, and the fasit tests both:
|
||||
* - It must SPLIT. A numbered list whose steps 2–3 are local facts and whose
|
||||
* steps 1 and 4 are filler is a mixed block; section granularity would have
|
||||
* to keep or drop all four.
|
||||
* - It must NOT split further. A bullet's load-bearing literal often sits on a
|
||||
* wrapped continuation line ("…— `coord-send` er mekanismen"). A
|
||||
* line-granular mechanism severs the first line from the fact that protects
|
||||
* it and proposes a floor block for deletion — the exact failure the gate
|
||||
* exists to prevent.
|
||||
*
|
||||
* ## Two independent guarantees, not one
|
||||
*
|
||||
* A load-bearing block fails to become a candidate for either of two reasons,
|
||||
* and both are needed:
|
||||
* 1. it is *declarative* — "Language: Norwegian for dialogue" states a fact
|
||||
* about the human and corrects no behaviour, so no detector fires; or
|
||||
* 2. `floor-exclusion` vetoes it for carrying an underivable local literal.
|
||||
* Group 1 never reaches the veto at all, which is why the contract is asserted
|
||||
* on the candidate list rather than on either mechanism alone.
|
||||
*
|
||||
* Norwegian and English are both first-class: the config this was designed
|
||||
* against is Norwegian prose carrying English identifiers.
|
||||
*
|
||||
* Zero external dependencies. Pure: input text → candidate array.
|
||||
*/
|
||||
|
||||
import { floorMarker } from './floor-exclusion.mjs';
|
||||
|
||||
/**
|
||||
* The subtraction detector, kept in its OWN table. `LENS_DETECTORS` drives the
|
||||
* plain `optimize` payload's register block, and the subtraction axis must not
|
||||
* fire on a plain run — it asks a different question and the operator has to
|
||||
* opt into it with `--subtract`.
|
||||
*/
|
||||
export const SUBTRACT_DETECTORS = Object.freeze([
|
||||
{ lensCheck: 'compensatory-instruction', registerId: 'BP-SUB-001', mechanism: 'deletion' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Absolute / insistent phrasing. An instruction that has to shout is usually
|
||||
* correcting behaviour rather than stating a fact.
|
||||
*/
|
||||
/**
|
||||
* Word boundaries that understand æ/ø/å.
|
||||
*
|
||||
* JavaScript's `\b` is ASCII-only, so `/\bunngå\b/` never matches "unngå " —
|
||||
* the trailing "å" is not a word character, so there is no boundary after it.
|
||||
* Every Norwegian keyword ending in æ/ø/å was silently dead until the dogfood
|
||||
* run surfaced it. Do not reintroduce `\b` around this vocabulary.
|
||||
*/
|
||||
export const LB = '(?<![\\wæøåÆØÅ])';
|
||||
export const RB = '(?![\\wæøåÆØÅ])';
|
||||
|
||||
const ABSOLUTE_RE = new RegExp(
|
||||
LB +
|
||||
'(?:never|always|avoid|don\'t|do not|must not|ensure|remember to|make sure|' +
|
||||
'aldri|alltid|unngå|husk|sørg for|ikke)' +
|
||||
RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Imperative verbs — the grammatical signature of telling the model how to
|
||||
* behave. Matched anywhere in the block, since Norwegian list prose puts them
|
||||
* after a colon ("…oppgaver: forstå problemet, vurder alternativer").
|
||||
*
|
||||
* Word boundaries matter more than the list length: `\bdocument\b` must not
|
||||
* match "documentation", or the declarative language-preference fact — a floor
|
||||
* block with no local literal to veto it — would become a deletion candidate.
|
||||
*/
|
||||
const IMPERATIVE_RE = new RegExp(
|
||||
LB +
|
||||
'(?:' +
|
||||
// English
|
||||
'think|write|test|commit|use|read|check|ask|verify|stop|start|summarize|' +
|
||||
'wait|match|change|fix|present|identify|keep|prefer|declare|refactor|' +
|
||||
'document|explain|split|review|' +
|
||||
// Norwegian
|
||||
'tenk|skriv|test|commit|bruk|les|sjekk|spør|verifiser|dokumenter|stopp|' +
|
||||
'start|oppsummer|vent|gjør|match|endre|fiks|presenter|identifiser|forstå|' +
|
||||
'vurder|hold|siter|jobb|gjett|push|del|sett|forklar|utfør|følg' +
|
||||
')' +
|
||||
RB,
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Minimum words for a block whose ONLY signal is an absolute marker. A bare
|
||||
* "Haiku: aldri." is a declarative policy fact wearing the word "aldri", not an
|
||||
* instruction about how to behave — the same reason "Tone: direct and technical"
|
||||
* never fires. Blocks carrying a real imperative verb are exempt from the floor,
|
||||
* so "Test inkrementelt" still surfaces at two words.
|
||||
*/
|
||||
const ABSOLUTE_ONLY_MIN_WORDS = 6;
|
||||
|
||||
const HEADING_RE = /^\s*#{1,6}\s/;
|
||||
const TABLE_RE = /^\s*\|/;
|
||||
const FENCE_RE = /^\s*(?:```|~~~)/;
|
||||
const LIST_ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
|
||||
const ORDERED_ITEM_RE = /^\s*\d+[.)]\s+/;
|
||||
const CONTINUATION_RE = /^\s+\S/;
|
||||
/** A paragraph that introduces the list beneath it ("…tre lag med hver sin ene jobb:"). */
|
||||
const STEM_RE = /:\s*$/;
|
||||
|
||||
/**
|
||||
* Split markdown into leaf blocks.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{startLine:number, endLine:number, text:string, type:string}>}
|
||||
* `type` is one of paragraph | list-item | heading | table | code.
|
||||
*/
|
||||
export function splitLeafBlocks(text) {
|
||||
const lines = String(text == null ? '' : text).split('\n');
|
||||
const blocks = [];
|
||||
let current = null;
|
||||
let inFence = false;
|
||||
|
||||
const flush = () => {
|
||||
if (current) blocks.push(current);
|
||||
current = null;
|
||||
};
|
||||
const indentOf = (line) => (line.match(/^\s*/) || [''])[0].length;
|
||||
const open = (type, i, line) => {
|
||||
current = { startLine: i + 1, endLine: i + 1, text: line, type, indent: indentOf(line) };
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (FENCE_RE.test(line)) {
|
||||
flush();
|
||||
inFence = !inFence;
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if (HEADING_RE.test(line)) {
|
||||
flush();
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'heading' });
|
||||
continue;
|
||||
}
|
||||
if (TABLE_RE.test(line)) {
|
||||
flush();
|
||||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'table' });
|
||||
continue;
|
||||
}
|
||||
if (LIST_ITEM_RE.test(line)) {
|
||||
// Structural exception 1: a paragraph ending in ':' is this list's stem —
|
||||
// it introduces the items rather than standing alone, so it merges with
|
||||
// them. Deleting a stem without its list is meaningless, and a stem often
|
||||
// carries no literal of its own to protect it.
|
||||
//
|
||||
// A list item can be a stem too ("5. …alltid eksplisitt:" over its
|
||||
// sub-bullets) — but only when the following item is nested deeper, or
|
||||
// sibling bullets would glue together.
|
||||
const isStem =
|
||||
current &&
|
||||
STEM_RE.test(current.text) &&
|
||||
(current.type === 'paragraph' || indentOf(line) > current.indent);
|
||||
if (isStem) {
|
||||
current.type = 'list-item';
|
||||
if (current.ordered === undefined) current.ordered = ORDERED_ITEM_RE.test(line);
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
current.stemMerged = true;
|
||||
current.stemIndent = indentOf(line);
|
||||
continue;
|
||||
}
|
||||
if (current && current.stemMerged && current.endLine === i && indentOf(line) >= current.stemIndent) {
|
||||
// Subsequent items of the same stemmed list join it too.
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
// Otherwise a new list item always ends the previous block, even mid-list.
|
||||
flush();
|
||||
open('list-item', i, line);
|
||||
current.ordered = ORDERED_ITEM_RE.test(line);
|
||||
continue;
|
||||
}
|
||||
if (current && CONTINUATION_RE.test(line)) {
|
||||
// Indented wrap — belongs to the block it continues.
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
if (current && current.type === 'paragraph') {
|
||||
current.endLine = i + 1;
|
||||
current.text += '\n' + line;
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
open('paragraph', i, line);
|
||||
}
|
||||
flush();
|
||||
|
||||
return blocks.sort((a, b) => a.startLine - b.startLine);
|
||||
}
|
||||
|
||||
/** Blocks that can carry a deletable instruction at all. */
|
||||
const isProse = (block) => block.type === 'paragraph' || block.type === 'list-item';
|
||||
|
||||
const wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
/**
|
||||
* Does the block instruct behaviour at all? A declarative fact does not, however
|
||||
* absolute its wording: "Haiku: aldri." and "Tone: direct and technical" both
|
||||
* state a decision rather than correcting how the model works.
|
||||
*/
|
||||
function correctsBehaviour(text) {
|
||||
if (IMPERATIVE_RE.test(text)) return true;
|
||||
return ABSOLUTE_RE.test(text) && wordCount(text) >= ABSOLUTE_ONLY_MIN_WORDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural exception 2: an ordered list is a CONTRACT. Numbered steps are a
|
||||
* sequence whose items reference each other, so a floor marker on any step
|
||||
* floors the whole run — deleting step 2 of a five-step session protocol is not
|
||||
* the same kind of act as deleting one bullet from a list of platitudes.
|
||||
*
|
||||
* Unordered lists deliberately do NOT inherit. B-32a and B-32b are opposite
|
||||
* calls inside one bullet list, and "the container decides" is precisely the
|
||||
* reasoning the fasit exists to refute.
|
||||
*
|
||||
* @returns {Set<number>} startLine of every block floored by inheritance
|
||||
*/
|
||||
function orderedContractFloor(blocks, floored) {
|
||||
const inherited = new Set();
|
||||
let run = [];
|
||||
const closeRun = () => {
|
||||
if (run.length > 1 && run.some((b) => floored.has(b.startLine))) {
|
||||
for (const b of run) inherited.add(b.startLine);
|
||||
}
|
||||
run = [];
|
||||
};
|
||||
for (const block of blocks) {
|
||||
const contiguous = run.length > 0 && block.startLine === run[run.length - 1].endLine + 1;
|
||||
if (block.ordered && (run.length === 0 || contiguous)) {
|
||||
run.push(block);
|
||||
} else {
|
||||
closeRun();
|
||||
if (block.ordered) run.push(block);
|
||||
}
|
||||
}
|
||||
closeRun();
|
||||
return inherited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compensatory-phrasing candidates that survived floor-exclusion.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{lensCheck:string, registerId:string, mechanism:string,
|
||||
* line:number, startLine:number, endLine:number, lineCount:number, text:string}>}
|
||||
*/
|
||||
export function subtractionCandidates(text) {
|
||||
const detector = SUBTRACT_DETECTORS[0];
|
||||
const out = [];
|
||||
const blocks = splitLeafBlocks(text).filter(isProse);
|
||||
|
||||
// 1. The blocking floor veto, evaluated over the WHOLE leaf block — so a
|
||||
// literal on a wrapped continuation line still protects its opening line.
|
||||
const floored = new Set();
|
||||
for (const block of blocks) {
|
||||
if (floorMarker(block.text)) floored.add(block.startLine);
|
||||
}
|
||||
// 2. …then propagated across ordered-list contracts.
|
||||
const inherited = orderedContractFloor(blocks, floored);
|
||||
|
||||
for (const block of blocks) {
|
||||
// 3. Does it correct behaviour at all? A declarative local fact does not.
|
||||
if (!correctsBehaviour(block.text)) continue;
|
||||
if (floored.has(block.startLine) || inherited.has(block.startLine)) continue;
|
||||
|
||||
out.push({
|
||||
lensCheck: detector.lensCheck,
|
||||
registerId: detector.registerId,
|
||||
mechanism: detector.mechanism,
|
||||
line: block.startLine,
|
||||
startLine: block.startLine,
|
||||
endLine: block.endLine,
|
||||
lineCount: block.endLine - block.startLine + 1,
|
||||
text: block.text.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics for the floor gate: every prose block that fired the detector but
|
||||
* was vetoed, with the marker that saved it. Not user-facing — this is how a
|
||||
* later narrowing of the veto can be checked against the fasit.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{startLine:number, endLine:number, marker:string, why:string}>}
|
||||
*/
|
||||
export function floorExcluded(text) {
|
||||
const blocks = splitLeafBlocks(text).filter(isProse);
|
||||
const floored = new Set();
|
||||
for (const block of blocks) {
|
||||
if (floorMarker(block.text)) floored.add(block.startLine);
|
||||
}
|
||||
const inherited = orderedContractFloor(blocks, floored);
|
||||
|
||||
const out = [];
|
||||
for (const block of blocks) {
|
||||
if (!correctsBehaviour(block.text)) continue;
|
||||
const marker = floorMarker(block.text);
|
||||
if (marker) {
|
||||
out.push({ startLine: block.startLine, endLine: block.endLine, marker: marker.name, why: marker.why });
|
||||
} else if (inherited.has(block.startLine)) {
|
||||
out.push({
|
||||
startLine: block.startLine,
|
||||
endLine: block.endLine,
|
||||
marker: 'ordered-contract',
|
||||
why: 'is a step of an ordered list whose sibling carries a local fact',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
242
scanners/lib/subtraction-write.mjs
Normal file
242
scanners/lib/subtraction-write.mjs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* subtraction-write — the write half of `optimize --subtract` (§C6, chunk #63).
|
||||
*
|
||||
* This is the only path in the plugin that REMOVES configuration, so the split
|
||||
* of labour matters more here than anywhere else: **the judgement is the
|
||||
* agent's, the execution is deterministic.** Everything below is mechanical —
|
||||
* it verifies that the block it was told to remove is still exactly the block
|
||||
* that is there, and refuses otherwise.
|
||||
*
|
||||
* ## Why this is not a `fix-engine` action
|
||||
*
|
||||
* Measured (#63): the subtraction axis appears nowhere in `scan-orchestrator`
|
||||
* or `optimization-lens-scanner` — it is computed inside `optimize-lens-cli`
|
||||
* under `--subtract`. `fix-engine.verifyFixes()` marks a fix `verified` when
|
||||
* the finding is absent from a re-scan, so a subtraction removal would be
|
||||
* verified **whether or not the write happened**: a success-shaped no-op, the
|
||||
* class that made `restoreBackup` silently do nothing before `parseManifest`
|
||||
* learned the second manifest format. And `planFixes` keys on
|
||||
* `finding.autoFixable` + `finding.title` from an envelope, neither of which an
|
||||
* agent prose judgement has.
|
||||
*
|
||||
* Nor is it a `plan`/`implement` step: that pipeline runs on findings, and
|
||||
* `finding-codes.mjs` declares exactly one `OPT` code — for the deterministic
|
||||
* check. Minting a code for a prose judgement breaks that module's invariant
|
||||
* that a code names a deterministic CHECK.
|
||||
*
|
||||
* ## The floor, repeated rather than moved
|
||||
*
|
||||
* §C6 forbids migrating the floor into the write path. That forbids *moving*
|
||||
* the veto, not *repeating* it: `subtraction-prefilter` still consults
|
||||
* `floor-exclusion` before anything is ever proposed, and this module refuses a
|
||||
* load-bearing block again as the last red line before an irreversible-by-
|
||||
* reading delete. A caller that hand-builds an approval therefore cannot route
|
||||
* around the floor.
|
||||
*
|
||||
* ## The archive question
|
||||
*
|
||||
* "`mv` to `_archive/`, never `rm`" is a FILE-level rule; nothing here deletes
|
||||
* a file. The timestamped backup is the recovery artifact — it holds the whole
|
||||
* pre-removal file and `rollback` already restores it. The removed text also
|
||||
* rides back in the payload so the caller can show and log it. A second archive
|
||||
* copy with no restorer behind it would be worse than none.
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { isLoadBearing } from './floor-exclusion.mjs';
|
||||
import { createBackup } from './backup.mjs';
|
||||
import { classifyWriteTarget, strongestGate } from './write-scope.mjs';
|
||||
|
||||
/** Why a removal did not happen. Every refusal carries exactly one of these. */
|
||||
export const REFUSAL_REASONS = Object.freeze({
|
||||
/** The file no longer reads the way the approval says it does. */
|
||||
BLOCK_MISMATCH: 'block-mismatch',
|
||||
/** `floor-exclusion` vetoes the block — never removable, at any layer. */
|
||||
FLOOR: 'floor',
|
||||
/** The target's scope class needs an explicit go-ahead that was not given. */
|
||||
SCOPE_GATE: 'scope-gate',
|
||||
/** The file could not be read, so it can be neither backed up nor excised. */
|
||||
UNREADABLE: 'unreadable',
|
||||
/** The backup does not cover a file the run was about to write. */
|
||||
BACKUP_INCOMPLETE: 'backup-incomplete',
|
||||
});
|
||||
|
||||
/** True for a line that is empty or whitespace only. */
|
||||
const isBlank = (line) => line === undefined || /^\s*$/.test(line);
|
||||
|
||||
/**
|
||||
* Remove approved blocks from one file's content.
|
||||
*
|
||||
* Pure: no filesystem, no clock. Every span is validated against the ORIGINAL
|
||||
* content and the removals are then applied in descending line order, so an
|
||||
* earlier removal cannot shift a later span out from under itself — the shape
|
||||
* that made `fix-engine` apply a file-rename before a fix that still addressed
|
||||
* the old path, failing with ENOENT while the run exited 0.
|
||||
*
|
||||
* @param {string} content - The file as it is on disk right now.
|
||||
* @param {Array<{line:number, endLine:number, text:string}>} removals
|
||||
* @returns {{content: string, applied: object[], refused: object[]}}
|
||||
*/
|
||||
export function exciseBlocks(content, removals) {
|
||||
const lines = content.split('\n');
|
||||
const applied = [];
|
||||
const refused = [];
|
||||
|
||||
for (const removal of removals) {
|
||||
const { line, endLine } = removal;
|
||||
const inRange =
|
||||
Number.isInteger(line) && Number.isInteger(endLine) &&
|
||||
line >= 1 && endLine >= line && endLine <= lines.length;
|
||||
|
||||
if (!inRange) {
|
||||
refused.push({ ...removal, reason: REFUSAL_REASONS.BLOCK_MISMATCH });
|
||||
continue;
|
||||
}
|
||||
|
||||
// The pre-filter reports `text: block.text.trim()`, so compare trimmed —
|
||||
// a raw slice comparison would refuse every genuine approval.
|
||||
const actual = lines.slice(line - 1, endLine).join('\n');
|
||||
if (actual.trim() !== String(removal.text ?? '').trim()) {
|
||||
refused.push({ ...removal, reason: REFUSAL_REASONS.BLOCK_MISMATCH });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Judged on what is actually in the file, not on what the caller claims is.
|
||||
if (isLoadBearing(actual)) {
|
||||
refused.push({ ...removal, reason: REFUSAL_REASONS.FLOOR });
|
||||
continue;
|
||||
}
|
||||
|
||||
applied.push({ ...removal, text: actual.trim() });
|
||||
}
|
||||
|
||||
const descending = [...applied].sort((a, b) => b.line - a.line);
|
||||
for (const { line, endLine } of descending) {
|
||||
lines.splice(line - 1, endLine - line + 1);
|
||||
// A leaf block sits between blank lines; removing it leaves two in a row.
|
||||
if (line >= 2 && isBlank(lines[line - 2]) && isBlank(lines[line - 1])) {
|
||||
lines.splice(line - 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: lines.join('\n'), applied, refused };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an approved subtraction set to disk, behind the scope gate and a
|
||||
* verified backup.
|
||||
*
|
||||
* The run is all-or-nothing across files: a target that cannot be read aborts
|
||||
* the whole set rather than applying half of one operator decision.
|
||||
*
|
||||
* @param {Array<{file:string, line:number, endLine:number, text:string}>} removals
|
||||
* @param {object} [opts]
|
||||
* @param {string|null} [opts.repoRoot] - Repo root the session stands in.
|
||||
* @param {boolean} [opts.approveScope=false] - Operator's explicit go-ahead for a `require-ok` target.
|
||||
* @param {boolean} [opts.dryRun=false]
|
||||
* @param {string} [opts.home] - Home override, for tests.
|
||||
* @returns {Promise<object>} Verdict payload — never throws for a refused write.
|
||||
*/
|
||||
export async function applySubtraction(removals, opts = {}) {
|
||||
const { repoRoot = null, approveScope = false, dryRun = false, home } = opts;
|
||||
|
||||
const normalized = removals.map((r) => ({ ...r, file: resolve(r.file) }));
|
||||
const files = [...new Set(normalized.map((r) => r.file))];
|
||||
|
||||
const classifyOpts = home ? { home } : {};
|
||||
const targets = files.map((f) => classifyWriteTarget(f, repoRoot, classifyOpts));
|
||||
const gate = strongestGate(targets);
|
||||
const disclosures = [...new Set(targets.map((t) => t.disclosure).filter(Boolean))];
|
||||
|
||||
const base = {
|
||||
gate,
|
||||
requiresApproval: gate === 'require-ok',
|
||||
disclosures,
|
||||
targets,
|
||||
dryRun,
|
||||
backupId: null,
|
||||
applied: [],
|
||||
refused: [],
|
||||
filesWritten: [],
|
||||
};
|
||||
|
||||
// The gate is a verdict about a write, not a tool failure: the caller renders
|
||||
// the disclosure and asks. Nothing is written, and nothing is exit 3 (#62).
|
||||
//
|
||||
// `!dryRun` is load-bearing. The gate guards a WRITE, and a dry run is not
|
||||
// one — refusing it early bought nothing and cost the dry run its whole
|
||||
// purpose on the machine-wide target, which is the mandatory v1 case: the
|
||||
// operator would approve a removal whose spans had never been checked, and
|
||||
// the first run able to discover a stale approval would be the one that
|
||||
// writes. `requiresApproval` is reported either way, so the caller still asks.
|
||||
if (gate === 'require-ok' && !approveScope && !dryRun) {
|
||||
return {
|
||||
...base,
|
||||
refused: normalized.map((r) => ({ ...r, reason: REFUSAL_REASONS.SCOPE_GATE })),
|
||||
};
|
||||
}
|
||||
|
||||
const contents = new Map();
|
||||
const unreadable = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
contents.set(file, await readFile(file, 'utf-8'));
|
||||
} catch {
|
||||
unreadable.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (unreadable.length > 0) {
|
||||
return {
|
||||
...base,
|
||||
refused: normalized.map((r) => ({
|
||||
...r,
|
||||
reason: unreadable.includes(r.file)
|
||||
? REFUSAL_REASONS.UNREADABLE
|
||||
: REFUSAL_REASONS.BACKUP_INCOMPLETE,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const perFile = new Map();
|
||||
const applied = [];
|
||||
const refused = [];
|
||||
for (const file of files) {
|
||||
const result = exciseBlocks(contents.get(file), normalized.filter((r) => r.file === file));
|
||||
perFile.set(file, result);
|
||||
applied.push(...result.applied.map((a) => ({ ...a, file })));
|
||||
refused.push(...result.refused.map((r) => ({ ...r, file })));
|
||||
}
|
||||
|
||||
const toWrite = files.filter((f) => perFile.get(f).applied.length > 0);
|
||||
if (dryRun || toWrite.length === 0) {
|
||||
return { ...base, applied, refused };
|
||||
}
|
||||
|
||||
// `createBackup` skips a path that does not exist and still returns a
|
||||
// manifest and an id, so "a backup was made" is not evidence that THIS file
|
||||
// is recoverable (M-BUG-31's shape). Assert coverage before writing anything.
|
||||
const backup = createBackup(toWrite);
|
||||
const covered = new Set(backup.manifest.files.map((f) => f.originalPath));
|
||||
const uncovered = toWrite.filter((f) => !covered.has(f));
|
||||
if (uncovered.length > 0) {
|
||||
return {
|
||||
...base,
|
||||
backupId: backup.backupId,
|
||||
applied: [],
|
||||
refused: normalized.map((r) => ({ ...r, reason: REFUSAL_REASONS.BACKUP_INCOMPLETE })),
|
||||
};
|
||||
}
|
||||
|
||||
const filesWritten = [];
|
||||
for (const file of toWrite) {
|
||||
await writeFile(file, perFile.get(file).content, 'utf-8');
|
||||
filesWritten.push(file);
|
||||
}
|
||||
|
||||
return { ...base, backupId: backup.backupId, applied, refused, filesWritten };
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { allFindingIds, knownScanners } from './finding-codes.mjs';
|
||||
|
||||
/**
|
||||
* Load suppressions from .config-audit-ignore files.
|
||||
|
|
@ -69,6 +70,39 @@ export function parseIgnoreFile(content) {
|
|||
return suppressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find suppression patterns that can never match anything.
|
||||
*
|
||||
* A finding ID names a check (see `finding-codes.mjs`), so an exact pin either
|
||||
* names a declared check or names nothing at all. Silently keeping a dead pin
|
||||
* would reproduce, in the other direction, the very failure the check-code
|
||||
* scheme removed: the user believes a finding is suppressed when it is not.
|
||||
*
|
||||
* Globs are validated only down to the scanner prefix — `CA-GAP-*` stays valid
|
||||
* however GAP's checks change, which is why a glob is the safe way to pin.
|
||||
*
|
||||
* @param {Array<{ pattern: string }>} suppressions
|
||||
* @returns {string[]} patterns that match no declared check
|
||||
*/
|
||||
export function unknownSuppressions(suppressions) {
|
||||
if (!suppressions || suppressions.length === 0) return [];
|
||||
|
||||
const ids = allFindingIds();
|
||||
const scanners = new Set(knownScanners());
|
||||
const unknown = [];
|
||||
|
||||
for (const { pattern } of suppressions) {
|
||||
if (pattern.endsWith('-*')) {
|
||||
const scanner = pattern.slice(3, -2); // "CA-GAP-*" → "GAP"
|
||||
if (!scanners.has(scanner)) unknown.push(pattern);
|
||||
continue;
|
||||
}
|
||||
if (!ids.has(pattern)) unknown.push(pattern);
|
||||
}
|
||||
|
||||
return unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply suppressions to a findings array.
|
||||
* @param {object[]} findings - Array of finding objects with .id
|
||||
|
|
|
|||
39
scanners/lib/write-output.mjs
Normal file
39
scanners/lib/write-output.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* write-output — the one place a scanner's `--output-file` payload is written.
|
||||
*
|
||||
* Every command in this plugin follows the same contract (`.claude/rules/ux-rules.md`):
|
||||
* run the scanner with `--output-file <path> 2>/dev/null`, check the exit code, then Read
|
||||
* the file. The path the command chooses is frequently one it has never created — e.g.
|
||||
* `commands/campaign.md` writes its report to
|
||||
* `~/.claude/config-audit/sessions/campaign-report.json`, which on a fresh machine does not
|
||||
* exist yet. That is precisely the FIRST run, the case campaign-cli otherwise handles
|
||||
* gracefully by reporting `initialized: false`.
|
||||
*
|
||||
* Before this helper existed, all 13 payload writers called `writeFile` directly and threw
|
||||
* ENOENT there. The exit code was 3, and the command's own exit-code table reads 3 as "the
|
||||
* input is missing or corrupt" — so the user was told the ledger might be corrupt and
|
||||
* warned off the one action that would have fixed anything. `saveLedger` had always created
|
||||
* its parent directory; the payload write simply never did. The asymmetry was accidental.
|
||||
*
|
||||
* Creating the parent is the honest behaviour: the caller asked for a file at a path, and
|
||||
* nothing about a missing intermediate directory is an error the caller can learn from.
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
/**
|
||||
* Write a scanner payload, creating the parent directory if needed.
|
||||
*
|
||||
* Signature-compatible with `writeFile(path, contents, encoding)` so call sites are a pure
|
||||
* rename — the encoding argument is kept rather than defaulted away.
|
||||
*
|
||||
* @param {string} path - destination file
|
||||
* @param {string} contents - serialized payload
|
||||
* @param {string} [encoding='utf-8']
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function writeOutputFile(path, contents, encoding = 'utf-8') {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, contents, encoding);
|
||||
}
|
||||
242
scanners/lib/write-scope.mjs
Normal file
242
scanners/lib/write-scope.mjs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* Write-target scope classification (M-BUG-41).
|
||||
*
|
||||
* The workflow observes configuration across repos, but every write it then
|
||||
* proposes was presented as though it landed where the session stands. Five
|
||||
* arms were measured carrying that hole: `implement` (approval prompt names no
|
||||
* path at all — only a count), `rollback` (renders repo-relative-looking paths
|
||||
* while writing to absolute originals), `fix` (`--global` mixes user-scope and
|
||||
* repo rows into one unmarked table), `plan`, and `campaign export`.
|
||||
*
|
||||
* The gate's STRENGTH comes from the target's scope class, never from which
|
||||
* command is asking. Command-owned policy would be five policies to drift apart
|
||||
* — the shape that put the lever table in five copies (#61). Both required
|
||||
* outcomes then fall out of one table without an exception rule: a plan
|
||||
* exported into another repo is *disclosed* (cross-repo is by design there),
|
||||
* while a rewrite of `~/.claude/CLAUDE.md` *requires explicit approval*,
|
||||
* because it costs in every repo on every turn.
|
||||
*
|
||||
* `silent` means "no gate of its own", not "no approval": the existing
|
||||
* confirmation surfaces stand untouched, and this module only adds location to
|
||||
* them.
|
||||
*
|
||||
* Two orderings below are load-bearing, and both were measured rather than
|
||||
* reasoned about:
|
||||
*
|
||||
* `plugin-managed` before `user-scope` — the canonical
|
||||
* `~/.claude/config-audit/` and the legacy `~/.config-audit/` both exist on a
|
||||
* real machine, and every command writes session state into them. Matched the
|
||||
* other way round, the gate fires on every write ever made and gets switched
|
||||
* off, which is worse than having no gate.
|
||||
*
|
||||
* `user-scope` before `cross-repo` — `~/.claude/.git` exists (the operator's
|
||||
* `~/.claude` is a git repo whose `.gitignore` is `*`). A plain
|
||||
* `.git`-upward-walk therefore answers "another repo" for
|
||||
* `~/.claude/CLAUDE.md`, silently downgrading the strongest gate on the
|
||||
* subtraction axis's primary target to disclosure-only.
|
||||
*
|
||||
* This module classifies. It never writes, never prompts, and never decides
|
||||
* whether an approved write is a good idea.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* True when `child` is `parent` itself or lives underneath it.
|
||||
*
|
||||
* Uses `relative()` rather than `startsWith()`: a sibling directory whose name
|
||||
* merely prefixes the parent's (`my-plugin-2` against `my-plugin`) satisfies
|
||||
* `startsWith` and would skip the gate entirely.
|
||||
*
|
||||
* @param {string} parent - Absolute directory path.
|
||||
* @param {string} child - Absolute path to test.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWithin(parent, child) {
|
||||
const rel = relative(parent, child);
|
||||
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Default repo-root test. Kept injectable so classification is testable
|
||||
* without a fixture tree.
|
||||
*
|
||||
* @param {string} dir - Absolute directory path.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function defaultIsRepoRoot(dir) {
|
||||
return existsSync(join(dir, '.git'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upwards from `absPath` looking for the nearest enclosing repo root.
|
||||
*
|
||||
* @param {string} absPath - Absolute path to start from.
|
||||
* @param {(dir: string) => boolean} isRepoRoot - Repo-root predicate.
|
||||
* @returns {string|null} The nearest repo root, or null if there is none.
|
||||
*/
|
||||
function nearestRepoRoot(absPath, isRepoRoot) {
|
||||
let dir = absPath;
|
||||
for (;;) {
|
||||
if (isRepoRoot(dir)) return dir;
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope classes, in match order.
|
||||
*
|
||||
* Declaration order IS match order — this object is the single source for the
|
||||
* class name, its gate, its disclosure wording and its predicate, so no caller
|
||||
* and no test can hold a second copy that drifts.
|
||||
*
|
||||
* @type {Record<string, {gate: 'silent'|'disclose'|'require-ok', disclosure: string|null, matches: Function}>}
|
||||
*/
|
||||
export const SCOPE_CLASSES = {
|
||||
// The plugin's own bookkeeping: session state, backups, ledgers. Not the
|
||||
// user's configuration, and written on essentially every run.
|
||||
'plugin-managed': {
|
||||
gate: 'silent',
|
||||
disclosure: null,
|
||||
matches: (target, ctx) => ctx.pluginRoots.some((root) => isWithin(root, target)),
|
||||
},
|
||||
|
||||
// Where the session stands. The ordinary case.
|
||||
'in-repo': {
|
||||
gate: 'silent',
|
||||
disclosure: null,
|
||||
matches: (target, ctx) => ctx.repoRoot !== null && isWithin(ctx.repoRoot, target),
|
||||
},
|
||||
|
||||
// Machine-wide configuration: loaded in every repo, on every turn, so the
|
||||
// cost of a change here is not confined to the project in front of the user.
|
||||
'user-scope': {
|
||||
gate: 'require-ok',
|
||||
disclosure: 'This writes to your machine-wide Claude configuration, outside this project. '
|
||||
+ 'It affects every project you open, so it needs your explicit go-ahead.',
|
||||
matches: (target, ctx) => isWithin(ctx.userConfigRoot, target),
|
||||
},
|
||||
|
||||
// A different project. Some commands do this by design; the gate is to say
|
||||
// so, not to refuse.
|
||||
'cross-repo': {
|
||||
gate: 'disclose',
|
||||
disclosure: 'This writes into a different project than the one you are working in. '
|
||||
+ 'Any directories it needs there will be created.',
|
||||
matches: (target, ctx) => {
|
||||
const root = nearestRepoRoot(target, ctx.isRepoRoot);
|
||||
return root !== null && root !== ctx.repoRoot;
|
||||
},
|
||||
},
|
||||
|
||||
// Neither this project, nor another project, nor machine-wide config.
|
||||
'outside': {
|
||||
gate: 'require-ok',
|
||||
disclosure: 'This writes to a location outside any project and outside your Claude '
|
||||
+ 'configuration, so it needs your explicit go-ahead.',
|
||||
matches: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Gate strengths, weakest first. Lives here rather than in a caller: a second
|
||||
* copy of this ordering would decide, independently, which gate a multi-target
|
||||
* write shows — the drift shape the class table itself exists to prevent.
|
||||
*/
|
||||
export const GATE_RANK = ['silent', 'disclose', 'require-ok'];
|
||||
|
||||
/**
|
||||
* The strongest gate among already-classified targets. One `require-ok` target
|
||||
* in a set drives the whole surface: a run that would write machine-wide config
|
||||
* does not get to be quiet because most of its other targets are ordinary.
|
||||
*
|
||||
* @param {Array<{gate: string}>} targets
|
||||
* @returns {string} The strongest gate, or 'silent' when there are no targets.
|
||||
*/
|
||||
export function strongestGate(targets) {
|
||||
let worst = 'silent';
|
||||
for (const t of targets) {
|
||||
if (GATE_RANK.indexOf(t.gate) > GATE_RANK.indexOf(worst)) worst = t.gate;
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a whole write SET and reduce it to one verdict (Q1).
|
||||
*
|
||||
* Every gated arm needs the same four things — classify each target, take the
|
||||
* strongest gate, de-duplicate the disclosures, report whether approval is owed
|
||||
* — and `lib/subtraction-write.mjs` was the only arm that had them, written
|
||||
* inline. Four more call sites copying those four lines is precisely the shape
|
||||
* `SCOPE_CLASSES` exists to prevent one level down: the copies drift, and the
|
||||
* drift is invisible because each one still looks correct on its own.
|
||||
*
|
||||
* This decides nothing about whether a write is a good idea, and it never
|
||||
* writes. It answers "what does this set of targets oblige you to say?".
|
||||
*
|
||||
* Note the strict default: `sessionRepoRoot` of `null` means the `in-repo`
|
||||
* class can never match, so an omitted repo root fails toward MORE disclosure,
|
||||
* not less. A caller that forgets to pass it gets a noisier gate rather than a
|
||||
* silent one.
|
||||
*
|
||||
* @param {string[]} paths - Paths about to be written. Duplicates are fine.
|
||||
* @param {string|null} sessionRepoRoot - Repo root of the current session.
|
||||
* @param {object} [options] - Forwarded to `classifyWriteTarget`.
|
||||
* @returns {{gate: string, requiresApproval: boolean, disclosures: string[], targets: object[]}}
|
||||
*/
|
||||
export function evaluateWriteTargets(paths, sessionRepoRoot, options = {}) {
|
||||
const unique = [...new Set(paths.map((p) => resolve(p)))];
|
||||
const targets = unique.map((p) => classifyWriteTarget(p, sessionRepoRoot, options));
|
||||
const gate = strongestGate(targets);
|
||||
|
||||
return {
|
||||
gate,
|
||||
requiresApproval: gate === 'require-ok',
|
||||
disclosures: [...new Set(targets.map((t) => t.disclosure).filter(Boolean))],
|
||||
targets,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a write target relative to the repo the session stands in.
|
||||
*
|
||||
* @param {string} targetPath - The path that is about to be written.
|
||||
* @param {string|null} sessionRepoRoot - Repo root of the current session.
|
||||
* @param {object} [options]
|
||||
* @param {(dir: string) => boolean} [options.isRepoRoot] - Repo-root predicate.
|
||||
* @param {string} [options.home] - Override for the home directory.
|
||||
* @returns {{scopeClass: string, gate: string, disclosure: string|null, target: string}}
|
||||
*/
|
||||
export function classifyWriteTarget(targetPath, sessionRepoRoot, options = {}) {
|
||||
const home = options.home ?? homedir();
|
||||
const isRepoRoot = options.isRepoRoot ?? defaultIsRepoRoot;
|
||||
|
||||
const target = resolve(targetPath);
|
||||
const ctx = {
|
||||
repoRoot: sessionRepoRoot === null || sessionRepoRoot === undefined
|
||||
? null
|
||||
: resolve(sessionRepoRoot),
|
||||
userConfigRoot: join(home, '.claude'),
|
||||
// Both roots are live: `backup.mjs` prefers `~/.claude/config-audit/` and
|
||||
// falls back to the legacy `~/.config-audit/`.
|
||||
pluginRoots: [
|
||||
join(home, '.claude', 'config-audit'),
|
||||
join(home, '.config-audit'),
|
||||
],
|
||||
isRepoRoot,
|
||||
};
|
||||
|
||||
for (const [scopeClass, spec] of Object.entries(SCOPE_CLASSES)) {
|
||||
if (spec.matches(target, ctx)) {
|
||||
return { scopeClass, gate: spec.gate, disclosure: spec.disclosure, target };
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable: `outside` matches unconditionally. Kept so a future edit that
|
||||
// narrows the last predicate fails loudly instead of returning undefined.
|
||||
throw new Error(`write-scope: no class matched ${target}`);
|
||||
}
|
||||
|
|
@ -34,17 +34,51 @@ export function parseSimpleYaml(yaml) {
|
|||
let currentKey = null;
|
||||
let multiLineValue = '';
|
||||
let inMultiLine = false;
|
||||
// Block-sequence state: an empty-valued key may be a YAML block sequence
|
||||
// key:
|
||||
// - item
|
||||
// - item
|
||||
// We defer the decision until the next line: a `- item` line starts an array;
|
||||
// anything else (or end-of-input) leaves the empty-valued key as `null` —
|
||||
// indistinguishable from a plain null value, preserving backwards compatibility.
|
||||
let seqKey = null; // key awaiting/collecting block-sequence items
|
||||
let seqItems = null; // null until the first `- ` item is seen
|
||||
let seqPending = false; // an empty-valued key was seen; items may follow
|
||||
|
||||
// Commit the current block-sequence (or empty-valued key) to result.
|
||||
const commitSeq = () => {
|
||||
if (seqKey !== null) {
|
||||
result[normalizeKey(seqKey)] = seqItems !== null ? seqItems : null;
|
||||
}
|
||||
seqKey = null;
|
||||
seqItems = null;
|
||||
seqPending = false;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
// Skip comments and empty lines (these do NOT terminate a block sequence)
|
||||
if (line.trim().startsWith('#') || line.trim() === '') {
|
||||
if (inMultiLine) multiLineValue += '\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block-sequence item: only while a sequence is pending or active
|
||||
const seqMatch = (seqPending || seqItems !== null) && !inMultiLine
|
||||
? line.match(/^\s+-\s*(.*)$/)
|
||||
: null;
|
||||
if (seqMatch) {
|
||||
if (seqItems === null) seqItems = [];
|
||||
const itemVal = parseValue(seqMatch[1].trim());
|
||||
if (itemVal !== null && itemVal !== '') seqItems.push(itemVal);
|
||||
seqPending = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-value pair
|
||||
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
|
||||
if (kvMatch && !inMultiLine) {
|
||||
// A new key terminates any pending/active block sequence.
|
||||
commitSeq();
|
||||
if (currentKey && multiLineValue) {
|
||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||
}
|
||||
|
|
@ -58,6 +92,15 @@ export function parseSimpleYaml(yaml) {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
// Defer: this may be a block sequence (next line) or a null value.
|
||||
seqKey = currentKey;
|
||||
seqItems = null;
|
||||
seqPending = true;
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[normalizeKey(currentKey)] = parseValue(value);
|
||||
currentKey = null;
|
||||
continue;
|
||||
|
|
@ -79,6 +122,13 @@ export function parseSimpleYaml(yaml) {
|
|||
currentKey = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A non-item, non-key line while a sequence is pending/active terminates it
|
||||
// (then the line is ignored, as before the fix).
|
||||
if (seqPending || seqItems !== null) {
|
||||
commitSeq();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +136,8 @@ export function parseSimpleYaml(yaml) {
|
|||
if (inMultiLine && currentKey) {
|
||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||
}
|
||||
// Flush a trailing block sequence / empty-valued key
|
||||
commitSeq();
|
||||
|
||||
// Normalize arrays for known list fields
|
||||
for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) {
|
||||
|
|
|
|||
|
|
@ -9,13 +9,29 @@
|
|||
* {
|
||||
* meta: { repoPath, generatedAt, durationMs },
|
||||
* sources: [
|
||||
* { kind: 'claude-md'|'plugin'|'skill'|'mcp-server'|'hook',
|
||||
* name: string, source: string, estimated_tokens: number },
|
||||
* { kind: 'claude-md'|'skill'|'rule'|'agent'|'output-style'|'mcp-server'|'hook',
|
||||
* name: string, source: string, estimated_tokens: number,
|
||||
* loadPattern: 'always'|'on-demand'|'external'|'unknown',
|
||||
* survivesCompaction: 'yes'|'no'|'n/a',
|
||||
* derivationConfidence: 'confirmed'|'inferred' },
|
||||
* ...
|
||||
* ],
|
||||
* summary: {
|
||||
* always: { tokens, count }, // enter context every turn before you type
|
||||
* onDemand: { tokens, count }, // loaded on invoke / on file read
|
||||
* external: { tokens, count }, // run outside the context window (hooks)
|
||||
* unknown: { tokens, count },
|
||||
* },
|
||||
* total: <sum of sources.estimated_tokens>
|
||||
* }
|
||||
*
|
||||
* v5.6 B — load-pattern accounting. Sources are component-level: the coarse
|
||||
* "plugin" roll-up was dropped because a plugin's contributions (skills, rules,
|
||||
* agents, output styles, hooks, MCP) are each enumerated once on their own —
|
||||
* keeping the roll-up double-counted them and corrupted the always-loaded
|
||||
* subtotal. Every record now carries the load pattern derived from the
|
||||
* published Claude Code loading model (deriveLoadPattern).
|
||||
*
|
||||
* Usage:
|
||||
* node manifest.mjs [path] [--json] [--output-file <path>]
|
||||
*
|
||||
|
|
@ -24,65 +40,198 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile, stat } from 'node:fs/promises';
|
||||
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = { boolean: ['--json', '--raw'], value: ['--output-file'] };
|
||||
|
||||
// CLAUDE.md cascade files are all discovered by walking UP from the repo, so
|
||||
// each one is always-loaded; the scope only changes the derivation confidence.
|
||||
const CLAUDE_MD_SCOPE_KIND = {
|
||||
project: 'claude-md-root',
|
||||
local: 'claude-md-root',
|
||||
user: 'claude-md-user',
|
||||
managed: 'claude-md-managed',
|
||||
import: 'claude-md-import',
|
||||
};
|
||||
|
||||
/** Spread the three load-pattern fields onto a source record. */
|
||||
function withLoadPattern(record, lp) {
|
||||
return {
|
||||
...record,
|
||||
loadPattern: lp.loadPattern,
|
||||
survivesCompaction: lp.survivesCompaction,
|
||||
derivationConfidence: lp.derivationConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceLabel = (item, fallback) =>
|
||||
item.pluginName ? `plugin:${item.pluginName}` : item.source || fallback;
|
||||
|
||||
/**
|
||||
* Flatten an activeConfig snapshot into a single ranked array of sources.
|
||||
* Flatten an activeConfig snapshot into a single ranked array of sources, each
|
||||
* tagged with its load pattern, plus a load-pattern summary.
|
||||
*/
|
||||
export function buildManifest(activeConfig) {
|
||||
const sources = [];
|
||||
|
||||
for (const f of activeConfig.claudeMd?.files || []) {
|
||||
const tokens = estimateClaudeMdEntryTokens(f, activeConfig);
|
||||
sources.push({
|
||||
const kind = CLAUDE_MD_SCOPE_KIND[f.scope] || 'claude-md-root';
|
||||
sources.push(withLoadPattern({
|
||||
kind: 'claude-md',
|
||||
name: f.path,
|
||||
source: f.scope,
|
||||
estimated_tokens: tokens,
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of activeConfig.plugins || []) {
|
||||
sources.push({
|
||||
kind: 'plugin',
|
||||
name: p.name,
|
||||
source: p.path,
|
||||
estimated_tokens: p.estimatedTokens || 0,
|
||||
});
|
||||
}, deriveLoadPattern(kind)));
|
||||
}
|
||||
|
||||
// Skills: the measured tokens are the skill BODY (full file), paid on invoke.
|
||||
// The always-loaded part (name+description listing) is small and tracked
|
||||
// separately (skill-listing-budget / posture), so the body is tagged
|
||||
// on-demand here rather than inflating the always-loaded subtotal.
|
||||
for (const s of activeConfig.skills || []) {
|
||||
sources.push({
|
||||
sources.push(withLoadPattern({
|
||||
kind: 'skill',
|
||||
name: s.name,
|
||||
source: s.pluginName ? `plugin:${s.pluginName}` : s.source || 'user',
|
||||
source: sourceLabel(s, 'user'),
|
||||
estimated_tokens: s.estimatedTokens || 0,
|
||||
});
|
||||
}, deriveLoadPattern('skill-body')));
|
||||
}
|
||||
|
||||
// Rules / agents / output styles — the foundation enumeration already derived
|
||||
// the load pattern (rules vary by `scoped`), so propagate it verbatim.
|
||||
for (const r of activeConfig.rules || []) {
|
||||
sources.push(withLoadPattern({
|
||||
kind: 'rule',
|
||||
name: r.name,
|
||||
source: sourceLabel(r, 'project'),
|
||||
estimated_tokens: r.estimatedTokens || 0,
|
||||
}, r));
|
||||
}
|
||||
|
||||
for (const a of activeConfig.agents || []) {
|
||||
sources.push(withLoadPattern({
|
||||
kind: 'agent',
|
||||
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));
|
||||
}
|
||||
|
||||
for (const o of activeConfig.outputStyles || []) {
|
||||
sources.push(withLoadPattern({
|
||||
kind: 'output-style',
|
||||
name: o.name,
|
||||
source: sourceLabel(o, 'project'),
|
||||
estimated_tokens: o.estimatedTokens || 0,
|
||||
}, o));
|
||||
}
|
||||
|
||||
for (const m of activeConfig.mcpServers || []) {
|
||||
if (m && m.enabled === false) continue;
|
||||
sources.push({
|
||||
sources.push(withLoadPattern({
|
||||
kind: 'mcp-server',
|
||||
name: m.name,
|
||||
source: m.source || 'unknown',
|
||||
estimated_tokens: m.estimatedTokens || 0,
|
||||
});
|
||||
}, deriveLoadPattern('mcp')));
|
||||
}
|
||||
|
||||
for (const h of activeConfig.hooks || []) {
|
||||
sources.push({
|
||||
sources.push(withLoadPattern({
|
||||
kind: 'hook',
|
||||
name: `${h.event}${h.matcher ? `:${h.matcher}` : ''}`,
|
||||
source: h.source || h.sourcePath || 'unknown',
|
||||
estimated_tokens: h.estimatedTokens || 0,
|
||||
});
|
||||
}, deriveLoadPattern('hook')));
|
||||
}
|
||||
|
||||
sources.sort((a, b) => b.estimated_tokens - a.estimated_tokens);
|
||||
const total = sources.reduce((s, x) => s + (x.estimated_tokens || 0), 0);
|
||||
return { sources, total };
|
||||
const summary = summarizeByLoadPattern(sources);
|
||||
return { sources, total, summary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket sources by load pattern into {tokens, count} subtotals. The `always`
|
||||
* bucket is the headline: tokens that enter context every turn before the user
|
||||
* types anything.
|
||||
*/
|
||||
export function summarizeByLoadPattern(sources) {
|
||||
const mk = () => ({ tokens: 0, count: 0 });
|
||||
const summary = { always: mk(), onDemand: mk(), external: mk(), unknown: mk() };
|
||||
const BUCKET = { always: 'always', 'on-demand': 'onDemand', external: 'external' };
|
||||
for (const s of sources) {
|
||||
const key = BUCKET[s.loadPattern] || 'unknown';
|
||||
summary[key].tokens += s.estimated_tokens || 0;
|
||||
summary[key].count += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source strings (the `source` field buildManifest stamps) that belong to the
|
||||
* SHARED GLOBAL layer — config paid once per machine and identical in every
|
||||
* repo: the global ~/.claude CLAUDE.md (`user`) and managed enterprise policy
|
||||
* (`managed`). Installed plugins are also shared but are matched by the
|
||||
* `plugin:` prefix below, not by this set.
|
||||
*
|
||||
* Deliberately NOT here: `~/.claude.json:projects`. Although that file lives in
|
||||
* HOME, `readClaudeJsonProjectSlice` returns the slice keyed to the SPECIFIC
|
||||
* repo path — those MCP servers are per-repo, load only in their own project,
|
||||
* and differ across repos, so they are a delta (folding them into the
|
||||
* once-counted shared layer would drop every repo's slice but the first). The
|
||||
* only machine-global MCP is plugin-provided (caught by the `plugin:` prefix).
|
||||
*/
|
||||
const SHARED_GLOBAL_SOURCES = Object.freeze(new Set(['user', 'managed']));
|
||||
|
||||
/**
|
||||
* Classify one manifest source as part of the once-counted shared global layer
|
||||
* or a per-repo delta (v5.9 B2b). Anything not positively identified as global
|
||||
* (project / local / .mcp.json / ~/.claude.json:projects / @import / unrecognized)
|
||||
* falls to `delta`, so a source is never silently folded into the shared layer —
|
||||
* a wrong fold would HIDE machine-wide cost, whereas a wrong delta is at worst
|
||||
* attributed visibly to a repo.
|
||||
* @param {string} source
|
||||
* @returns {'shared'|'delta'}
|
||||
*/
|
||||
export function classifyOwnership(source) {
|
||||
if (typeof source === 'string') {
|
||||
if (source.startsWith('plugin:')) return 'shared'; // installed plugins are machine-global
|
||||
if (SHARED_GLOBAL_SOURCES.has(source)) return 'shared';
|
||||
}
|
||||
return 'delta';
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition manifest sources by ownership for the machine-wide token roll-up,
|
||||
* returning two load-pattern summaries in the exact shape `summarizeByLoadPattern`
|
||||
* emits ({always,onDemand,external,unknown:{tokens,count}}), so the campaign
|
||||
* ledger setters (`setSharedGlobal` / `setRepoTokens`) consume them verbatim.
|
||||
*
|
||||
* - `shared`: the global layer, identical across repos — set ONCE on the ledger
|
||||
* root so the roll-up counts it exactly once (the structural double-count guard).
|
||||
* - `delta`: this repo's own project/local contribution beyond the shared layer.
|
||||
*
|
||||
* The split is total: every source lands in exactly one layer.
|
||||
* @param {Array<{source:string, loadPattern:string, estimated_tokens:number}>} sources
|
||||
* @returns {{shared:object, delta:object}}
|
||||
*/
|
||||
export function splitManifestByOwnership(sources) {
|
||||
const shared = [];
|
||||
const delta = [];
|
||||
for (const s of sources || []) {
|
||||
(classifyOwnership(s.source) === 'shared' ? shared : delta).push(s);
|
||||
}
|
||||
return { shared: summarizeByLoadPattern(shared), delta: summarizeByLoadPattern(delta) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,6 +249,7 @@ function estimateClaudeMdEntryTokens(file, activeConfig) {
|
|||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
|
|
@ -119,11 +269,13 @@ async function main() {
|
|||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
process.exit(3);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
process.exit(3);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
|
@ -138,13 +290,14 @@ async function main() {
|
|||
durationMs: Date.now() - start,
|
||||
},
|
||||
sources: manifest.sources,
|
||||
summary: manifest.summary,
|
||||
total: manifest.total,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(output, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
|
||||
if (jsonMode || rawMode || !outputFile) {
|
||||
|
|
@ -156,6 +309,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
process.exitCode = 3;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ const VALID_SERVER_TYPES = new Set(['stdio', 'http', 'sse']);
|
|||
// not a per-server .mcp.json field. Verified against code.claude.com/docs 2026-06-18.
|
||||
const VALID_SERVER_FIELDS = new Set([
|
||||
'type', 'command', 'args', 'env', 'url', 'headers', 'timeout',
|
||||
// alwaysLoad: exempt a server from MCP tool-schema deferral (CC v2.1.121+).
|
||||
// Verified against code.claude.com/docs/en/mcp.md#exempt-a-server-from-deferral 2026-06-23.
|
||||
'alwaysLoad',
|
||||
]);
|
||||
|
||||
// Match only bare ${IDENTIFIER} references. POSIX expansions like ${VAR%pattern}
|
||||
|
|
@ -53,6 +56,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!parsed) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in MCP config',
|
||||
description: `${file.relPath}: Failed to parse as JSON.`,
|
||||
|
|
@ -72,6 +76,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (config.type && !VALID_SERVER_TYPES.has(config.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-server-type',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Unknown MCP server type',
|
||||
description: `${file.relPath}: Server "${name}" has unknown type "${config.type}".`,
|
||||
|
|
@ -85,6 +90,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (config.type === 'sse') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'sse-transport',
|
||||
severity: SEVERITY.info,
|
||||
title: 'SSE server type — consider HTTP',
|
||||
description: `${file.relPath}: Server "${name}" uses "sse" type. The "http" type is the current standard.`,
|
||||
|
|
@ -107,6 +113,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!hasEnvBlock) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unreferenced-env-var',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unreferenced env var in args',
|
||||
description: `${file.relPath}: Server "${name}" references \${${varName}} in args but has no env block defining it.`,
|
||||
|
|
@ -124,6 +131,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!VALID_SERVER_FIELDS.has(key)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-server-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unknown MCP server field',
|
||||
description: `${file.relPath}: Server "${name}" has unknown field "${key}".`,
|
||||
|
|
|
|||
147
scanners/optimization-lens-scanner.mjs
Normal file
147
scanners/optimization-lens-scanner.mjs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* OPT Scanner — Optimization Lens / mechanism-fit (v5.7 Fase 1 Chunk 2a)
|
||||
*
|
||||
* The first detector of the "is the config OPTIMAL?" axis (vs. the existing
|
||||
* "is it CORRECT?" scanners). It reads the machine-readable best-practices
|
||||
* register (knowledge/best-practices.json) and flags config that works but uses
|
||||
* a mechanism a better one would fit — the deterministic half of the hybrid
|
||||
* motor (the opus analyzer for prose-judgment cases is Chunk 2b).
|
||||
*
|
||||
* CA-OPT-001 A multi-step procedure in CLAUDE.md should be a SKILL (BP-MECH-003).
|
||||
* CLAUDE.md is for facts Claude holds every turn; a procedure there
|
||||
* costs always-loaded tokens whether or not you run it, and a skill's
|
||||
* body loads only on invoke. Detection is deliberately CONSERVATIVE
|
||||
* (a run of >= 6 consecutive numbered steps) to keep precision high —
|
||||
* the negative corpus in the tests proves null false-positives.
|
||||
* Framed as a Missed opportunity (humanizer), severity LOW.
|
||||
*
|
||||
* Provenance: the recommendation + claim come from the register entry (only a
|
||||
* `confirmed` entry is used user-facing — Verifiseringsplikt); an inline default
|
||||
* is the graceful fallback if the register is unavailable. Fixture-gated: the
|
||||
* marketplace-medium CLAUDE.md has no numbered lists, so it emits nothing (SC-5
|
||||
* byte-stable; the additive OPT scanner entry is stripped from frozen baselines).
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||
|
||||
const SCANNER = 'OPT';
|
||||
const STEP_THRESHOLD = 6;
|
||||
const STEP_RE = /^\s*\d+\.\s+\S/;
|
||||
const PROCEDURE_TITLE = 'A multi-step procedure in CLAUDE.md belongs in a skill';
|
||||
|
||||
// Graceful fallback if the register is missing/unreadable (the register is the
|
||||
// source of truth; this keeps the scanner working without it).
|
||||
const DEFAULT_MECH_003 = {
|
||||
claim:
|
||||
'A multi-step procedure in CLAUDE.md should be a skill — CLAUDE.md is for facts Claude ' +
|
||||
'should hold all the time; procedures belong in skills.',
|
||||
recommendation:
|
||||
'Extract the procedure into .claude/skills/; its body then loads only on invoke instead ' +
|
||||
'of every turn.',
|
||||
};
|
||||
|
||||
/** Return the confirmed register entry for `id`, or null (→ caller uses default). */
|
||||
function confirmedEntry(id) {
|
||||
try {
|
||||
const e = getEntry(loadRegister(), id);
|
||||
return e && e.confidence === 'confirmed' ? e : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest run of consecutive numbered-list items. Blank lines and indented
|
||||
* continuation lines neither extend nor break a run; any other non-step line
|
||||
* breaks it. Conservative by design (a wrapped, non-indented step line ends the
|
||||
* run → undercount, never overcount).
|
||||
* @param {string} text
|
||||
* @returns {{count:number, startIndex:number, firstStep:string}}
|
||||
*/
|
||||
function longestNumberedRun(text) {
|
||||
const lines = String(text).split('\n');
|
||||
let maxRun = 0;
|
||||
let maxStartIdx = 0;
|
||||
let maxFirstStep = '';
|
||||
let run = 0;
|
||||
let runStartIdx = 0;
|
||||
let runFirstStep = '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (STEP_RE.test(line)) {
|
||||
if (run === 0) {
|
||||
runStartIdx = i;
|
||||
runFirstStep = line.trim();
|
||||
}
|
||||
run++;
|
||||
if (run > maxRun) {
|
||||
maxRun = run;
|
||||
maxStartIdx = runStartIdx;
|
||||
maxFirstStep = runFirstStep;
|
||||
}
|
||||
} else if (line.trim() === '' || /^\s+\S/.test(line)) {
|
||||
// blank or indented continuation — part of the list, neither step nor break
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
return { count: maxRun, startIndex: maxStartIdx, firstStep: maxFirstStep };
|
||||
}
|
||||
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const findings = [];
|
||||
const claudeMdFiles = ((discovery && discovery.files) || []).filter((f) => f.type === 'claude-md');
|
||||
|
||||
const entry = confirmedEntry('BP-MECH-003');
|
||||
const claim = (entry && entry.claim) || DEFAULT_MECH_003.claim;
|
||||
const recommendation = (entry && entry.recommendation) || DEFAULT_MECH_003.recommendation;
|
||||
|
||||
let filesScanned = 0;
|
||||
for (const file of claudeMdFiles) {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(file.absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
filesScanned++;
|
||||
|
||||
const parsed = parseFrontmatter(content);
|
||||
const body = parsed.body || content;
|
||||
const bodyStartLine = parsed.bodyStartLine || 1;
|
||||
const run = longestNumberedRun(body);
|
||||
|
||||
if (run.count >= STEP_THRESHOLD) {
|
||||
findings.push(
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
code: 'procedure-should-be-skill',
|
||||
severity: SEVERITY.low,
|
||||
title: PROCEDURE_TITLE,
|
||||
description: claim,
|
||||
file: file.relPath || file.absPath,
|
||||
line: bodyStartLine + run.startIndex,
|
||||
evidence: run.firstStep,
|
||||
recommendation,
|
||||
category: 'mechanism-fit',
|
||||
details: {
|
||||
mechanism: 'skill',
|
||||
steps: run.count,
|
||||
register: entry ? entry.id : null,
|
||||
source: entry ? entry.source.url : null,
|
||||
confidence: entry ? entry.confidence : null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
284
scanners/optimize-lens-cli.mjs
Normal file
284
scanners/optimize-lens-cli.mjs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* optimize-lens CLI — feeds the v5.7 optimization lens (CA-OPT) `/config-audit
|
||||
* optimize` command. It produces the two halves of the hybrid motor as one JSON
|
||||
* payload:
|
||||
*
|
||||
* 1. `deterministic` — the OPT scanner's high-precision findings (CA-OPT-001:
|
||||
* a long numbered procedure in CLAUDE.md → skill). Already part of the
|
||||
* orchestrated audit; surfaced here so /optimize is a complete view.
|
||||
* 2. `candidates` — recall-oriented prose-judgment candidates from the
|
||||
* lens-prefilter (lifecycle → hook, unscoped path-specific → rule, "never"
|
||||
* → permission), each stamped with the CONFIRMED register entry it might fit
|
||||
* (claim / recommendation / source / severity). The opus
|
||||
* optimization-lens-agent is the precision gate over these.
|
||||
*
|
||||
* Only CONFIRMED register entries are attached (Verifiseringsplikt); a candidate
|
||||
* whose register rule is missing or unconfirmed is dropped, so the agent never
|
||||
* sees an unverifiable recommendation.
|
||||
*
|
||||
* Usage:
|
||||
* node optimize-lens-cli.mjs [path] [--output-file <path>] [--global]
|
||||
* [--subtract [--for-model <name>]]
|
||||
*
|
||||
* `--for-model <name>` annotates the subtraction candidates a named model
|
||||
* documents as redundant (BP-PROMPT-001). It never widens the candidate set,
|
||||
* and there is deliberately no auto-detection: a CLAUDE.md has no frontmatter
|
||||
* and no statically-resolvable target model, so the model must be named.
|
||||
*
|
||||
* Exit codes: 0=ok, 3=unrecoverable error. Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
|
||||
import { subtractionCandidates, SUBTRACT_DETECTORS } from './lib/subtraction-prefilter.mjs';
|
||||
import { matchModelScope, normalizeModel } from './lib/prompting-model-scope.mjs';
|
||||
import { scan as optScan } from './optimization-lens-scanner.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = {
|
||||
boolean: ['--global', '--subtract'],
|
||||
// `--for-model` is a VALUE flag, so a bare `--for-model` is reported as
|
||||
// "needs a value" rather than "unknown flag" — the two are different failures
|
||||
// and reporting the wrong one hides which mistake the caller made.
|
||||
value: ['--output-file', '--for-model'],
|
||||
};
|
||||
|
||||
// Files under `.claude/plugins/` are shipped by an installed plugin — vendored
|
||||
// CLAUDE.md plus its bundled tests/fixtures and examples. They are not the user's
|
||||
// authored config, so a mechanism-fit suggestion against them is not actionable
|
||||
// (the user can't edit a file the plugin overwrites on update). Excluded from the
|
||||
// lens regardless of active/stale version. (M-BUG-11; mirrors the M-BUG-2 rule
|
||||
// that keeps plugin-bundled config out of the conflict detector.)
|
||||
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||
const isPluginBundled = (file) => (file.absPath || '').includes(PLUGIN_TREE_MARKER);
|
||||
|
||||
/** Confirmed register entry for `id`, or null. */
|
||||
function confirmedEntry(register, id) {
|
||||
const e = getEntry(register, id);
|
||||
return e && e.confidence === 'confirmed' ? e : null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let includeGlobal = false;
|
||||
let subtract = false;
|
||||
let targetModel = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--global') includeGlobal = true;
|
||||
else if (args[i] === '--subtract') subtract = true;
|
||||
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||
else if (args[i] === '--for-model' && args[i + 1]) targetModel = args[++i];
|
||||
else if (!args[i].startsWith('-')) targetPath = args[i];
|
||||
}
|
||||
|
||||
const absPath = resolve(targetPath);
|
||||
try {
|
||||
const s = await stat(absPath);
|
||||
if (!s.isDirectory()) {
|
||||
process.stderr.write(`Error: ${absPath} is not a directory\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the register once; tolerate its absence (deterministic half still runs).
|
||||
let register = null;
|
||||
try {
|
||||
register = loadRegister();
|
||||
} catch {
|
||||
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)),
|
||||
};
|
||||
|
||||
// ── Deterministic half: the OPT scanner (CA-OPT-001) ──
|
||||
const opt = await optScan(absPath, discovery);
|
||||
|
||||
// ── Recall half: prose-judgment candidates from the pre-filter ──
|
||||
const claudeMdFiles = (discovery.files || []).filter((f) => f.type === 'claude-md');
|
||||
const candidates = [];
|
||||
// Opt-in only: the subtraction axis asks a different question and must not
|
||||
// fire on a plain `/config-audit optimize` run (brief §7 q3).
|
||||
const subtractCands = [];
|
||||
const subtractEntry = subtract && register ? confirmedEntry(register, 'BP-SUB-001') : null;
|
||||
// Model-scoped annotation entries (BP-PROMPT-001 and any later sibling). These
|
||||
// never produce candidates of their own — they only tag candidates the
|
||||
// BP-SUB-001 detector above already surfaced.
|
||||
const promptEntries =
|
||||
subtract && register
|
||||
? (register.entries || []).filter(
|
||||
(e) => e.category === 'prompting-fit' && e.confidence === 'confirmed',
|
||||
)
|
||||
: [];
|
||||
// `recognized` is reported separately from the match count so a typo'd model
|
||||
// name is distinguishable from a config that genuinely carries nothing.
|
||||
const modelRecognized =
|
||||
!!targetModel &&
|
||||
promptEntries.some((e) =>
|
||||
(e.modelScope || []).some((m) => normalizeModel(m) === normalizeModel(targetModel)),
|
||||
);
|
||||
let modelMatchedCount = 0;
|
||||
|
||||
for (const file of claudeMdFiles) {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(file.absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseFrontmatter(content);
|
||||
const body = parsed.body || content;
|
||||
const bodyStartLine = parsed.bodyStartLine || 1;
|
||||
|
||||
if (subtractEntry) {
|
||||
for (const cand of subtractionCandidates(body)) {
|
||||
const modelScope = matchModelScope(cand.text, targetModel, promptEntries);
|
||||
if (modelScope) modelMatchedCount++;
|
||||
subtractCands.push({
|
||||
file: file.absPath,
|
||||
line: bodyStartLine - 1 + cand.startLine,
|
||||
endLine: bodyStartLine - 1 + cand.endLine,
|
||||
lineCount: cand.lineCount,
|
||||
lensCheck: cand.lensCheck,
|
||||
mechanism: cand.mechanism,
|
||||
signalText: cand.text,
|
||||
register: {
|
||||
id: subtractEntry.id,
|
||||
claim: subtractEntry.claim,
|
||||
recommendation: subtractEntry.recommendation || null,
|
||||
severity: subtractEntry.severity || 'low',
|
||||
source: subtractEntry.source,
|
||||
},
|
||||
// Spread only when matched: a run without --for-model must not grow
|
||||
// even a key set to undefined.
|
||||
...(modelScope ? { modelScope } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const cand of prefilterClaudeMd(body)) {
|
||||
const entry = register ? confirmedEntry(register, cand.registerId) : null;
|
||||
if (!entry) continue; // never surface an unverifiable recommendation
|
||||
candidates.push({
|
||||
// Absolute path: unique + readable. relPath collides across scopes
|
||||
// (a repo-root `CLAUDE.md` and the user-global `~/.claude/CLAUDE.md`
|
||||
// both relPath to `CLAUDE.md`), which would send the agent's Read() to
|
||||
// the wrong file. (M-BUG-11)
|
||||
file: file.absPath,
|
||||
line: bodyStartLine - 1 + cand.line,
|
||||
lensCheck: cand.lensCheck,
|
||||
mechanism: cand.mechanism,
|
||||
signalText: cand.text,
|
||||
register: {
|
||||
id: entry.id,
|
||||
claim: entry.claim,
|
||||
recommendation: entry.recommendation || null,
|
||||
severity: entry.severity || 'low',
|
||||
source: entry.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The CONFIRMED prose-judgment entries, so the agent has full provenance even
|
||||
// for a detector class that produced no candidates this run.
|
||||
const registerEntries = register
|
||||
? LENS_DETECTORS.map((d) => confirmedEntry(register, d.registerId))
|
||||
.filter(Boolean)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
lensCheck: e.lensCheck,
|
||||
claim: e.claim,
|
||||
recommendation: e.recommendation || null,
|
||||
mechanism: e.mechanism || null,
|
||||
severity: e.severity || 'low',
|
||||
source: e.source,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const payload = {
|
||||
status: 'ok',
|
||||
target: absPath,
|
||||
deterministic: opt.findings || [],
|
||||
candidates,
|
||||
register: registerEntries,
|
||||
counts: {
|
||||
deterministic: (opt.findings || []).length,
|
||||
candidates: candidates.length,
|
||||
byLensCheck: candidates.reduce((acc, c) => {
|
||||
acc[c.lensCheck] = (acc[c.lensCheck] || 0) + 1;
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Additive ONLY under --subtract: a plain run's payload must stay byte-identical.
|
||||
if (subtract) {
|
||||
payload.subtract = {
|
||||
enabled: true,
|
||||
candidates: subtractCands,
|
||||
register: subtractEntry
|
||||
? [
|
||||
{
|
||||
id: subtractEntry.id,
|
||||
lensCheck: subtractEntry.lensCheck,
|
||||
claim: subtractEntry.claim,
|
||||
recommendation: subtractEntry.recommendation || null,
|
||||
mechanism: subtractEntry.mechanism || null,
|
||||
severity: subtractEntry.severity || 'low',
|
||||
source: subtractEntry.source,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
detectors: SUBTRACT_DETECTORS.map((d) => ({ ...d })),
|
||||
};
|
||||
// Present ONLY when a model was named — a plain --subtract run stays
|
||||
// byte-identical to the pre-flag payload.
|
||||
if (targetModel) {
|
||||
payload.subtract.forModel = {
|
||||
requested: targetModel,
|
||||
recognized: modelRecognized,
|
||||
matchedCount: modelMatchedCount,
|
||||
};
|
||||
}
|
||||
payload.counts.subtractCandidates = subtractCands.length;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
if (!outputFile) {
|
||||
process.stdout.write(json + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
});
|
||||
}
|
||||
182
scanners/output-style-scanner.mjs
Normal file
182
scanners/output-style-scanner.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* OST Scanner — Output-style validation (v5.6 C)
|
||||
*
|
||||
* Output styles are live (the standalone `/output-style` command was removed in
|
||||
* v2.1.91; styles are now managed via `/config`). They are the most surprising
|
||||
* steering surface because they rewrite the system prompt:
|
||||
*
|
||||
* CA-OST-001 A custom (user/project) output style that does NOT set
|
||||
* `keep-coding-instructions: true` → when active, Claude Code
|
||||
* REMOVES its built-in software-engineering instructions (how to
|
||||
* scope changes, write comments, verify work) and keeps only the
|
||||
* style's text. `keep-coding-instructions` defaults to false, so
|
||||
* this is the headline footgun. Severity medium.
|
||||
*
|
||||
* CA-OST-002 A PLUGIN output style with `force-for-plugin: true` → Claude
|
||||
* Code auto-applies it whenever the plugin is enabled, OVERRIDING
|
||||
* the user's selected `outputStyle`. If several enabled plugins set
|
||||
* it, the first loaded wins. Severity low (awareness). Note:
|
||||
* `force-for-plugin` is plugin-styles-only per the docs, so this
|
||||
* keys on `source === 'plugin'` — a user/project style cannot
|
||||
* trigger the override (it would simply be ignored).
|
||||
*
|
||||
* CA-OST-003 A settings `outputStyle` value that matches no built-in and no
|
||||
* discovered custom style → dead config: Claude Code falls back to
|
||||
* the default style, so the configured behavior is silently not
|
||||
* applied. Severity medium.
|
||||
*
|
||||
* Every claim traces to a CONFIRMED row of docs/v5.5-steering-model-plan.md
|
||||
* (V9/V10/V11/V12), verified against code.claude.com/docs/en/output-styles and
|
||||
* .../plugins-reference. The scanner is fixture-gated: with no output styles and
|
||||
* no `outputStyle` setting it emits nothing (keeps the SC-5 snapshot byte-stable).
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
||||
import { parseFrontmatter, parseJson } from './lib/yaml-parser.mjs';
|
||||
|
||||
const SCANNER = 'OST';
|
||||
|
||||
// Built-in output styles, verified against code.claude.com/docs/en/output-styles.
|
||||
// Compared case-insensitively so OST-003 never false-flags a valid built-in.
|
||||
const BUILTIN_STYLES = ['default', 'explanatory', 'learning', 'proactive'];
|
||||
|
||||
/**
|
||||
* Read + parse the frontmatter of each enumerated output style once.
|
||||
* @param {Array<object>} styles - readActiveConfig().outputStyles entries
|
||||
*/
|
||||
async function withFrontmatter(styles) {
|
||||
const out = [];
|
||||
for (const s of styles) {
|
||||
let frontmatter = null;
|
||||
try {
|
||||
frontmatter = parseFrontmatter(await readFile(s.path, 'utf-8')).frontmatter;
|
||||
} catch { /* unreadable → treat as no frontmatter */ }
|
||||
out.push({ ...s, frontmatter });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective `outputStyle` setting from the cascade (user → project →
|
||||
* local; later scope wins). Returns null when unset everywhere.
|
||||
* @param {object} activeConfig
|
||||
* @returns {Promise<{value:string, scope:string, path:string} | null>}
|
||||
*/
|
||||
async function resolveOutputStyleSetting(activeConfig) {
|
||||
const cascade = (activeConfig.settings && activeConfig.settings.cascade) || [];
|
||||
let resolved = null;
|
||||
for (const entry of cascade) {
|
||||
if (!entry.exists || !entry.path) continue;
|
||||
let json = null;
|
||||
try { json = parseJson(await readFile(entry.path, 'utf-8')); } catch { continue; }
|
||||
if (json && typeof json.outputStyle === 'string' && json.outputStyle.trim()) {
|
||||
resolved = { value: json.outputStyle.trim(), scope: entry.scope, path: entry.path };
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main scanner entry point.
|
||||
* @param {string} targetPath - repo root to scan
|
||||
* @param {object} _discovery - unused (OST reads the active config cascade itself)
|
||||
*/
|
||||
export async function scan(targetPath, _discovery) {
|
||||
const start = Date.now();
|
||||
const findings = [];
|
||||
|
||||
const activeConfig = await readActiveConfig(targetPath);
|
||||
const styles = await withFrontmatter(activeConfig.outputStyles || []);
|
||||
|
||||
// CA-OST-001 — user/project custom style missing keep-coding-instructions:true.
|
||||
for (const s of styles) {
|
||||
if (s.source !== 'project' && s.source !== 'user') continue;
|
||||
const kci = s.frontmatter ? s.frontmatter.keep_coding_instructions : undefined;
|
||||
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:
|
||||
`The ${s.source} output style "${s.name}" does not set ` +
|
||||
'`keep-coding-instructions: true`. While this style is active, Claude Code ' +
|
||||
'drops its built-in software-engineering instructions — how to scope changes, ' +
|
||||
'write comments, and verify work — and keeps only this style\'s text. The ' +
|
||||
'frontmatter flag defaults to false, so the strip is easy to miss.',
|
||||
file: s.path,
|
||||
evidence:
|
||||
`output_style="${s.name}"; source=${s.source}; ` +
|
||||
`keep-coding-instructions=${kci === undefined ? 'unset (default false)' : String(kci)}`,
|
||||
recommendation:
|
||||
'To keep Claude Code\'s software-engineering behavior while applying this style, ' +
|
||||
'add `keep-coding-instructions: true` to the frontmatter. If the strip is ' +
|
||||
'intentional (a non-coding persona), no change is needed.',
|
||||
category: 'output-styles',
|
||||
}));
|
||||
}
|
||||
|
||||
// CA-OST-002 — plugin output style with force-for-plugin:true (overrides user choice).
|
||||
for (const s of styles) {
|
||||
if (s.source !== 'plugin') continue;
|
||||
const ffp = s.frontmatter ? s.frontmatter.force_for_plugin : undefined;
|
||||
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:
|
||||
`The plugin "${s.pluginName}" ships an output style "${s.name}" with ` +
|
||||
'`force-for-plugin: true`, so Claude Code applies it automatically whenever the ' +
|
||||
'plugin is enabled — overriding whatever `outputStyle` you selected. When more ' +
|
||||
'than one enabled plugin does this, the first one loaded wins.',
|
||||
file: s.path,
|
||||
evidence: `output_style="${s.name}"; source=plugin:${s.pluginName}; force-for-plugin=true`,
|
||||
recommendation:
|
||||
'If you did not expect this style, disable the plugin or remove ' +
|
||||
'`force-for-plugin: true` from its output style. This is awareness only — the ' +
|
||||
'plugin is behaving as designed.',
|
||||
category: 'output-styles',
|
||||
}));
|
||||
}
|
||||
|
||||
// CA-OST-003 — settings outputStyle resolving to a non-existent style (dead config).
|
||||
const resolved = await resolveOutputStyleSetting(activeConfig);
|
||||
if (resolved) {
|
||||
const known = new Set([
|
||||
...BUILTIN_STYLES,
|
||||
...styles.map(s => String(s.name).toLowerCase()),
|
||||
]);
|
||||
if (!known.has(resolved.value.toLowerCase())) {
|
||||
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:
|
||||
`Your ${resolved.scope} settings set \`outputStyle: "${resolved.value}"\`, but no ` +
|
||||
'built-in or discovered custom style has that name. Claude Code falls back to the ' +
|
||||
'default style, so the output behavior you configured is silently never applied.',
|
||||
file: resolved.path,
|
||||
evidence:
|
||||
`outputStyle="${resolved.value}"; scope=${resolved.scope}; ` +
|
||||
`builtins=[${BUILTIN_STYLES.join(', ')}]; ` +
|
||||
`known_custom=[${customNames.join(', ')}]`,
|
||||
recommendation:
|
||||
'Fix the value to match an existing style (built-ins: Default, Explanatory, ' +
|
||||
'Learning, Proactive), create the missing style under `.claude/output-styles/`, ' +
|
||||
'or remove the `outputStyle` setting.',
|
||||
category: 'output-styles',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, styles.length, Date.now() - start);
|
||||
}
|
||||
|
|
@ -9,8 +9,10 @@
|
|||
*/
|
||||
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { join, basename, resolve, sep } from 'node:path';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { humanizeFindings } from './lib/humanizer.mjs';
|
||||
|
|
@ -19,18 +21,43 @@ const SCANNER = 'PLH';
|
|||
|
||||
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
|
||||
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
|
||||
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores)
|
||||
|
||||
// A CLAUDE.md need only document the component types the plugin actually ships. Mirrors the
|
||||
// optional-frontmatter rule: do not demand docs for commands/agents/hooks that do not exist.
|
||||
async function pluginShipsComponent(pluginDir, section) {
|
||||
if (section === 'hooks') {
|
||||
try { await readFile(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8'); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
try {
|
||||
const entries = await readdir(join(pluginDir, section));
|
||||
return entries.some(f => f.endsWith('.md'));
|
||||
} catch { return false; }
|
||||
}
|
||||
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores).
|
||||
// Field requirements are pinned to the primary docs, NOT to "every field a plugin could set":
|
||||
// - Commands/skills (code.claude.com/docs slash-commands): "All fields are optional. Only
|
||||
// `description` is recommended." `name` defaults to the directory name; `model` and
|
||||
// `allowed-tools` are optional. So only `description` is flagged.
|
||||
// - Subagents (code.claude.com/docs sub-agents): "Only `name` and `description` are required."
|
||||
// `model` (defaults to `inherit`) and `tools` (inherits all) are optional.
|
||||
const REQUIRED_COMMAND_FRONTMATTER = [
|
||||
{ key: 'name', display: 'name' },
|
||||
{ key: 'description', display: 'description' },
|
||||
{ key: 'model', display: 'model' },
|
||||
{ key: 'allowed_tools', display: 'allowed-tools' },
|
||||
];
|
||||
const REQUIRED_AGENT_FRONTMATTER = [
|
||||
{ key: 'name', display: 'name' },
|
||||
{ key: 'description', display: 'description' },
|
||||
{ key: 'model', display: 'model' },
|
||||
{ key: 'tools', display: 'tools' },
|
||||
];
|
||||
|
||||
// Plugin subagents silently ignore these frontmatter keys — they are honored
|
||||
// ONLY for user/project agents in .claude/agents/ (code.claude.com/docs
|
||||
// sub-agents, "ignored for plugin subagents"). Setting them in a plugin agent
|
||||
// is dead config; permissionMode is MEDIUM because it implies a restriction
|
||||
// that Claude Code does not actually apply (false sense of security).
|
||||
const PLUGIN_AGENT_IGNORED_FIELDS = [
|
||||
{ key: 'permissionMode', severity: SEVERITY.medium },
|
||||
{ key: 'hooks', severity: SEVERITY.low },
|
||||
{ key: 'mcpServers', severity: SEVERITY.low },
|
||||
];
|
||||
|
||||
// Component-path keys that REPLACE the default folder (per code.claude.com/docs
|
||||
|
|
@ -195,6 +222,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-plugin-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid plugin.json',
|
||||
description: `plugin.json is not valid JSON in ${pluginName}`,
|
||||
|
|
@ -211,6 +239,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!parsed[field]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-required-field',
|
||||
severity: SEVERITY.high,
|
||||
title: `Missing required field in plugin.json: ${field}`,
|
||||
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
|
||||
|
|
@ -232,6 +261,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!(await dirExists(join(pluginDir, defaultDir)))) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'plugin-json-shadows-default',
|
||||
severity: SEVERITY.medium,
|
||||
title: `plugin.json "${key}" path shadows the default ${defaultDir}/ folder`,
|
||||
description:
|
||||
|
|
@ -271,6 +301,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
const m = SKILLS_ENTRY_MESSAGES[problem];
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skills-array-entry',
|
||||
severity: SEVERITY.medium,
|
||||
title: m.title(entry),
|
||||
description: `Plugin "${pluginName}": ${m.description}`,
|
||||
|
|
@ -286,6 +317,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-plugin-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Missing plugin.json',
|
||||
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
|
||||
|
|
@ -301,6 +333,9 @@ async function scanSinglePlugin(pluginDir) {
|
|||
const lower = content.toLowerCase();
|
||||
|
||||
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
|
||||
// Only require a section for a component the plugin actually ships (mirrors the
|
||||
// optional-frontmatter rule — no docs demanded for absent commands/agents/hooks).
|
||||
if (!(await pluginShipsComponent(pluginDir, section))) continue;
|
||||
// Look for markdown table header or section header
|
||||
const hasSection = lower.includes(`## ${section}`) ||
|
||||
lower.includes(`| ${section}`) ||
|
||||
|
|
@ -308,6 +343,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!hasSection) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'claude-md-missing-section',
|
||||
severity: SEVERITY.medium,
|
||||
title: `CLAUDE.md missing ${section} section`,
|
||||
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
|
||||
|
|
@ -319,6 +355,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-claude-md',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing CLAUDE.md',
|
||||
description: `Plugin "${pluginName}" has no CLAUDE.md`,
|
||||
|
|
@ -342,11 +379,12 @@ 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`,
|
||||
file: filePath,
|
||||
recommendation: 'Add YAML frontmatter with name, description, model',
|
||||
recommendation: 'Add YAML frontmatter with a description (other command fields are optional)',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
|
@ -355,6 +393,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter[key]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'command-missing-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Command missing frontmatter field: ${display}`,
|
||||
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
||||
|
|
@ -381,11 +420,12 @@ 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`,
|
||||
file: filePath,
|
||||
recommendation: 'Add YAML frontmatter with name, description, model, tools',
|
||||
recommendation: 'Add YAML frontmatter with name and description (model and tools are optional)',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
|
@ -394,6 +434,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter[key]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'agent-missing-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Agent missing frontmatter field: ${display}`,
|
||||
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
||||
|
|
@ -402,6 +443,24 @@ async function scanSinglePlugin(pluginDir) {
|
|||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin subagents ignore hooks/mcpServers/permissionMode frontmatter (V15)
|
||||
// — dead config (permissionMode = medium: false sense of restriction).
|
||||
for (const { key, severity } of PLUGIN_AGENT_IGNORED_FIELDS) {
|
||||
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'}.`,
|
||||
file: filePath,
|
||||
evidence: `${key}: ${JSON.stringify(frontmatter[key])}`,
|
||||
recommendation: `Remove "${key}" from the agent frontmatter, or ship the agent as a user/project agent in .claude/agents/, where ${key} is honored.`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no agents dir */ }
|
||||
|
||||
|
|
@ -414,6 +473,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-invalid-structure',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hooks.json structure',
|
||||
description: `hooks.json in "${pluginName}" missing "hooks" object`,
|
||||
|
|
@ -423,6 +483,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} else if (Array.isArray(parsed.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-array',
|
||||
severity: SEVERITY.high,
|
||||
title: 'hooks.json uses array instead of object',
|
||||
description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
|
||||
|
|
@ -433,6 +494,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-invalid',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hooks.json',
|
||||
description: `hooks.json is not valid JSON in "${pluginName}"`,
|
||||
|
|
@ -445,11 +507,18 @@ async function scanSinglePlugin(pluginDir) {
|
|||
const pluginMetaDir = join(pluginDir, '.claude-plugin');
|
||||
try {
|
||||
const entries = await readdir(pluginMetaDir);
|
||||
const known = new Set(['plugin.json']);
|
||||
// `marketplace.json` belongs here: it is the documented, required location
|
||||
// for a marketplace catalog (code.claude.com/docs plugin-marketplaces —
|
||||
// "Create `.claude-plugin/marketplace.json` in your repository root"), and a
|
||||
// marketplace entry with `"source": "./"` makes the repo root its own
|
||||
// plugin. Such a repo legitimately carries both files, so flagging the
|
||||
// catalog as an unknown file was a false positive.
|
||||
const known = new Set(['plugin.json', 'marketplace.json']);
|
||||
for (const entry of entries) {
|
||||
if (!known.has(entry)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-plugin-file',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Unknown file in .claude-plugin/',
|
||||
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
|
||||
|
|
@ -463,27 +532,62 @@ async function scanSinglePlugin(pluginDir) {
|
|||
return { name: pluginName, declaredName, findings, commandCount, agentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-plugin score and grade. Single source for both the terminal report and
|
||||
* the --output-file payload — the grade formula used to live only inside
|
||||
* `formatPluginHealthReport`, which nothing called.
|
||||
* @param {number} issueCount
|
||||
* @returns {{ score: number, grade: string }}
|
||||
*/
|
||||
export function pluginGrade(issueCount) {
|
||||
const score = Math.max(0, 100 - issueCount * 10);
|
||||
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
||||
return { score, grade };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan one or more plugins and return aggregated results.
|
||||
*
|
||||
* The envelope is frozen at the v5.0.0 shape (byte-stable `--raw`/`--json`), so
|
||||
* per-plugin rows and the cross-plugin/per-plugin split are NOT in it. Callers
|
||||
* that need those — the `--output-file` payload, and therefore
|
||||
* `/config-audit plugin-health` — use `scanDetailed`.
|
||||
*
|
||||
* @param {string} targetPath - Plugin dir or marketplace root
|
||||
* @returns {Promise<object>} Scanner result
|
||||
*/
|
||||
export async function scan(targetPath) {
|
||||
return (await scanDetailed(targetPath)).result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan, and also return what `scan()`'s frozen envelope cannot carry: one row
|
||||
* per plugin (name, declared namespace, component counts, grade) and the
|
||||
* cross-plugin findings as a distinct set.
|
||||
*
|
||||
* @param {string} targetPath - Plugin dir or marketplace root
|
||||
* @returns {Promise<{ result: object, plugins: object[], crossPluginFindings: object[] }>}
|
||||
*/
|
||||
export async function scanDetailed(targetPath) {
|
||||
const start = Date.now();
|
||||
resetCounter();
|
||||
|
||||
const pluginDirs = await discoverPlugins(resolve(targetPath));
|
||||
|
||||
if (pluginDirs.length === 0) {
|
||||
return scannerResult(SCANNER, 'ok', [
|
||||
return {
|
||||
result: scannerResult(SCANNER, 'ok', [
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-plugins-found',
|
||||
severity: SEVERITY.info,
|
||||
title: 'No plugins found',
|
||||
description: `No Claude Code plugins found under ${targetPath}`,
|
||||
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
|
||||
}),
|
||||
], 0, Date.now() - start);
|
||||
], 0, Date.now() - start),
|
||||
plugins: [],
|
||||
crossPluginFindings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const allFindings = [];
|
||||
|
|
@ -495,6 +599,12 @@ export async function scan(targetPath) {
|
|||
allFindings.push(...result.findings);
|
||||
}
|
||||
|
||||
// Everything pushed from here on is a cross-plugin finding — the boundary the
|
||||
// payload uses to split them out (they are flattened into `findings` in the
|
||||
// frozen envelope, where `category: 'plugin-hygiene'` cannot tell them apart
|
||||
// from the per-plugin shadow/skills findings that share it).
|
||||
const crossPluginStart = allFindings.length;
|
||||
|
||||
// Cross-plugin checks: command-name ambiguity across DIFFERENT plugin namespaces.
|
||||
// Commands are namespaced by the plugin's declared name (/name:command), so a
|
||||
// shared command name across DIFFERENT plugins is ambiguity — not a hard
|
||||
|
|
@ -527,6 +637,7 @@ export async function scan(targetPath) {
|
|||
const namespaceList = entries.map(e => e.namespace).join(', ');
|
||||
allFindings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'command-name-collision',
|
||||
severity: SEVERITY.low,
|
||||
title: `Command name "${cmdName}" used by multiple plugins`,
|
||||
description:
|
||||
|
|
@ -562,6 +673,7 @@ export async function scan(targetPath) {
|
|||
if (dirs.length < 2) continue;
|
||||
allFindings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'namespace-collision',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Plugin namespace collision: "${declaredName}"`,
|
||||
description:
|
||||
|
|
@ -587,7 +699,19 @@ export async function scan(targetPath) {
|
|||
}));
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
|
||||
return {
|
||||
result: scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start),
|
||||
plugins: pluginResults.map((p, idx) => ({
|
||||
name: p.name,
|
||||
declaredName: p.declaredName,
|
||||
path: pluginDirs[idx],
|
||||
commandCount: p.commandCount,
|
||||
agentCount: p.agentCount,
|
||||
findingCount: p.findings.length,
|
||||
...pluginGrade(p.findings.length),
|
||||
})),
|
||||
crossPluginFindings: allFindings.slice(crossPluginStart),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -604,9 +728,7 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
|
|||
lines.push('');
|
||||
|
||||
for (const p of pluginResults) {
|
||||
const issueCount = p.findings.length;
|
||||
const score = Math.max(0, 100 - issueCount * 10);
|
||||
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
||||
const { score, grade } = pluginGrade(p.findings.length);
|
||||
const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
|
||||
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
|
||||
}
|
||||
|
|
@ -630,27 +752,55 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
|
|||
}
|
||||
|
||||
// --- CLI entry point ---
|
||||
const BOOL_FLAGS = ['--json', '--raw'];
|
||||
const VALUE_FLAGS = ['--output-file'];
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
let jsonMode = false;
|
||||
let rawMode = false;
|
||||
let outputFile = null;
|
||||
|
||||
// M-BUG-21, third arm: this loop used to end in
|
||||
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
|
||||
// unknown-flag branch. An unrecognised flag was dropped silently and its
|
||||
// VALUE became the scan target, so `--output-file /tmp/x.json` scanned
|
||||
// /tmp/x.json. Unlike drift-cli, the result LOOKS fine: a non-existent path
|
||||
// discovers no plugins, so the scanner reported "No plugins found" (info) and
|
||||
// exit 0 — a green answer to a question nobody asked. Now it fails loudly.
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--raw') {
|
||||
rawMode = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
const arg = args[i];
|
||||
if (BOOL_FLAGS.includes(arg)) {
|
||||
if (arg === '--json') jsonMode = true;
|
||||
else if (arg === '--raw') rawMode = true;
|
||||
} else if (VALUE_FLAGS.includes(arg)) {
|
||||
const value = args[i + 1];
|
||||
if (value === undefined || value.startsWith('-')) {
|
||||
throw new Error(`Option ${arg} requires a value.`);
|
||||
}
|
||||
outputFile = value;
|
||||
i++;
|
||||
} else if (arg.startsWith('-')) {
|
||||
throw new Error(
|
||||
`Unknown option: ${arg}\n` +
|
||||
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
||||
);
|
||||
} else {
|
||||
targetPath = arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await requireTargetDir(resolve(targetPath)))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
const humanizedProgress = !jsonMode && !rawMode;
|
||||
process.stderr.write(humanizedProgress ? `Plugin Health v2.1.0\n` : `Plugin Health Scanner v2.1.0\n`);
|
||||
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
|
||||
|
||||
const result = await scan(targetPath);
|
||||
const { result, plugins, crossPluginFindings } = await scanDetailed(targetPath);
|
||||
|
||||
if (jsonMode || rawMode) {
|
||||
// --json and --raw both write the v5.0.0-shape result (byte-identical).
|
||||
|
|
@ -663,6 +813,24 @@ async function main() {
|
|||
for (const f of findings) {
|
||||
process.stderr.write(` [${f.severity}] ${f.title}\n`);
|
||||
}
|
||||
|
||||
// ux-rules rule 2: the command runs with `2>/dev/null`, so anything it must
|
||||
// ACT on has to ride in the --output-file payload. Everything above this
|
||||
// point is stderr, i.e. invisible to `/config-audit plugin-health`.
|
||||
if (outputFile) {
|
||||
const crossIds = new Set(crossPluginFindings.map(f => f.id));
|
||||
for (const f of findings) {
|
||||
if (crossIds.has(f.id)) f.crossPlugin = true;
|
||||
}
|
||||
const payload = {
|
||||
...result,
|
||||
findings,
|
||||
plugins,
|
||||
cross_plugin_findings: findings.filter(f => crossIds.has(f.id)),
|
||||
};
|
||||
await writeOutputFile(outputFile, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -670,6 +838,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
process.exitCode = 3;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
import {
|
||||
calculateUtilization,
|
||||
determineMaturityLevel,
|
||||
|
|
@ -20,6 +23,12 @@ import {
|
|||
generateHealthScorecard,
|
||||
} from './lib/scoring.mjs';
|
||||
|
||||
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
|
||||
const ARG_SPEC = {
|
||||
boolean: ['--json', '--raw', '--global', '--full-machine', '--include-fixtures'],
|
||||
value: ['--output-file', '--context-window'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Run posture assessment and return structured result.
|
||||
* @param {string} targetPath
|
||||
|
|
@ -57,16 +66,20 @@ 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;
|
||||
let rawMode = false;
|
||||
let includeGlobal = false;
|
||||
let fullMachine = false;
|
||||
let contextWindow = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--output-file' && args[i + 1]) {
|
||||
outputFile = args[++i];
|
||||
} else if (args[i] === '--context-window' && args[i + 1]) {
|
||||
contextWindow = args[++i];
|
||||
} else if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--raw') {
|
||||
|
|
@ -82,6 +95,11 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
if (!(await requireTargetDir(resolve(targetPath)))) {
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
|
||||
const filterFixtures = !args.includes('--include-fixtures');
|
||||
const humanizedProgress = !jsonMode && !rawMode;
|
||||
const result = await runPosture(targetPath, {
|
||||
|
|
@ -89,6 +107,7 @@ async function main() {
|
|||
fullMachine,
|
||||
filterFixtures,
|
||||
humanizedProgress,
|
||||
contextWindow,
|
||||
});
|
||||
|
||||
// stdout JSON path: --json and --raw both write the v5.0.0-shape result
|
||||
|
|
@ -110,8 +129,15 @@ async function main() {
|
|||
}
|
||||
|
||||
if (outputFile) {
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
// Consumers (feature-gap.md, posture.md) read scannerEnvelope.scanners[].findings
|
||||
// and group on humanizer fields. posture's result nests the envelope under
|
||||
// `scannerEnvelope`, so humanize THAT (not `result`, which has no top-level
|
||||
// `scanners` array — humanizeEnvelope would no-op). --json/--raw stay raw.
|
||||
const fileEnv = (jsonMode || rawMode)
|
||||
? result
|
||||
: { ...result, scannerEnvelope: humanizeEnvelope(result.scannerEnvelope) };
|
||||
const json = JSON.stringify(fileEnv, null, 2);
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
}
|
||||
|
|
@ -121,6 +147,10 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
// 3, not 1. Every command in this plugin is told that 0/1/2 are normal grades
|
||||
// (PASS/WARNING/FAIL) and only 3 is a real error, so exiting 1 here made a crash
|
||||
// indistinguishable from a WARNING — and the command went on to Read a payload file
|
||||
// that was never written.
|
||||
process.exitCode = 3;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
208
scanners/rollback-cli.mjs
Normal file
208
scanners/rollback-cli.mjs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Config-Audit Rollback CLI — the runnable entry to the backup/restore engine.
|
||||
*
|
||||
* `rollback-engine.mjs` has verified checksums before AND after each write,
|
||||
* resolved the pre-v2.2.0 backup root and reported `createdNotRemoved` since
|
||||
* M-BUG-22/M-BUG-25. None of it was reachable: measured at the head of this
|
||||
* chunk, 16 files under `scanners/` carried a `process.argv` entry and the
|
||||
* engine was not one of them. `commands/rollback.md` drove the restore as model
|
||||
* prose — an ESM `import` block a template cannot execute, with ad-hoc `cp`
|
||||
* offered underneath as the runnable alternative and "(checksum verified)"
|
||||
* pre-rendered in the success output. `cp` establishes no checksum, so the
|
||||
* verification was a property of the template rather than of the run (R1).
|
||||
*
|
||||
* `--create` lives here for the same reason `--restore` does. The implement
|
||||
* pipeline used to build its backup by hand — `mkdir`, `cp`, a `date`-derived
|
||||
* id and a manifest typed out in the template — while `parseManifest` knew one
|
||||
* frozen sample of that format, pinned by a hand-written fixture rather than by
|
||||
* the template's own text. One side of that contract was maintained by editing
|
||||
* prose (R2). Now both sides are `lib/backup.mjs`.
|
||||
*
|
||||
* Usage:
|
||||
* node rollback-cli.mjs [--list]
|
||||
* node rollback-cli.mjs --restore <backup-id> [--dry-run] [--approve-scope]
|
||||
* node rollback-cli.mjs --delete <backup-id>
|
||||
* node rollback-cli.mjs --create --target <path> [--target <path> ...]
|
||||
* [--created <path> ...] [--backup-id <id>]
|
||||
* ... plus [--repo <session-root>] [--output-file <path>] [--json]
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 done, nothing owed
|
||||
* 1 the run completed but something is outstanding — a restore the scope
|
||||
* gate will not perform without `--approve-scope` (nothing was written),
|
||||
* or a backup that covered fewer targets than it was given
|
||||
* 2 at least one file failed to restore (checksum mismatch or write error)
|
||||
* 3 the CLI could not do its job — malformed argv, or a backup id that
|
||||
* resolves in neither root
|
||||
*
|
||||
* A gated restore is exit 1, not 3: "this write leaves your project" is a
|
||||
* verdict about a write that WAS examined, and it rides in the payload where a
|
||||
* command can act on it. Anything that only reaches stderr is invisible to a
|
||||
* command running under `2>/dev/null` (F3's class).
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireValidArgs } from './lib/cli-args.mjs';
|
||||
import { createBackup, getBackupDir, getLegacyBackupDir } from './lib/backup.mjs';
|
||||
import { listBackups, restoreBackup, deleteBackup } from './rollback-engine.mjs';
|
||||
|
||||
/** Flag surface. Anything else is exit 3. */
|
||||
const ARG_SPEC = {
|
||||
boolean: ['--list', '--create', '--dry-run', '--approve-scope', '--json'],
|
||||
value: ['--restore', '--delete', '--target', '--created', '--backup-id', '--repo', '--output-file'],
|
||||
};
|
||||
|
||||
/** The mode flags, in the order a diagnostic should name them. */
|
||||
const MODES = ['--list', '--create', '--restore', '--delete'];
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exitCode = 3;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!requireValidArgs(args, ARG_SPEC)) return;
|
||||
|
||||
const targets = [];
|
||||
const created = [];
|
||||
let mode = null;
|
||||
let backupId = null;
|
||||
let dryRun = false;
|
||||
let approveScope = false;
|
||||
let jsonMode = false;
|
||||
let outputFile = null;
|
||||
let overrideId = null;
|
||||
// The session's root, never a path derived from the backup (#63). A restore
|
||||
// writes to the ABSOLUTE originals recorded at backup time, so reading the
|
||||
// root off those paths would call a machine-wide write "in-repo" and silence
|
||||
// the strongest gate exactly where it matters.
|
||||
let repoRoot = process.cwd();
|
||||
|
||||
const setMode = (flag) => {
|
||||
if (mode !== null && mode !== flag) return false;
|
||||
mode = flag;
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (MODES.includes(a)) {
|
||||
if (!setMode(a)) {
|
||||
// Silently letting the last one win is how a `--delete` rides along
|
||||
// behind a `--list` the caller thought it was running.
|
||||
fail(`only one mode may be given (${MODES.join(', ')}); saw "${mode}" and "${a}"`);
|
||||
return;
|
||||
}
|
||||
if (a === '--restore' || a === '--delete') backupId = args[++i];
|
||||
} else if (a === '--target') targets.push(args[++i]);
|
||||
else if (a === '--created') created.push(args[++i]);
|
||||
else if (a === '--backup-id') overrideId = args[++i];
|
||||
else if (a === '--repo') repoRoot = args[++i];
|
||||
else if (a === '--output-file') outputFile = args[++i];
|
||||
else if (a === '--dry-run') dryRun = true;
|
||||
else if (a === '--approve-scope') approveScope = true;
|
||||
else if (a === '--json') jsonMode = true;
|
||||
}
|
||||
|
||||
if (mode === null) mode = '--list';
|
||||
|
||||
const meta = {
|
||||
mode: mode.slice(2),
|
||||
backupRoot: getBackupDir(),
|
||||
legacyBackupRoot: getLegacyBackupDir(),
|
||||
repo: resolve(repoRoot),
|
||||
};
|
||||
|
||||
let payload;
|
||||
let lines = [];
|
||||
|
||||
if (mode === '--list') {
|
||||
const { backups } = await listBackups();
|
||||
payload = { meta, count: backups.length, backups };
|
||||
lines = backups.map((b) => `${b.id}\t${b.files.length}\t${b.legacy ? 'legacy' : 'current'}`);
|
||||
} else if (mode === '--create') {
|
||||
if (targets.length === 0) {
|
||||
fail('--create needs at least one --target');
|
||||
return;
|
||||
}
|
||||
const result = createBackup(targets, {
|
||||
...(overrideId ? { backupId: overrideId } : {}),
|
||||
created,
|
||||
});
|
||||
payload = {
|
||||
meta,
|
||||
backupId: result.backupId,
|
||||
backupPath: result.backupPath,
|
||||
files: result.manifest.files,
|
||||
created: result.manifest.created,
|
||||
skipped: result.skipped,
|
||||
};
|
||||
lines = [`${result.backupId}\t${result.manifest.files.length}\t${result.skipped.length}`];
|
||||
// A backup that covers fewer files than it was asked for is the state the
|
||||
// caller must not mistake for a clean one: it is about to edit a file it
|
||||
// cannot roll back.
|
||||
if (result.skipped.length > 0) process.exitCode = 1;
|
||||
} else if (mode === '--delete') {
|
||||
const result = await deleteBackup(backupId);
|
||||
if (!result.deleted) {
|
||||
fail(result.error);
|
||||
return;
|
||||
}
|
||||
payload = { meta, backupId, deleted: true, error: null };
|
||||
lines = [`${backupId}\tdeleted`];
|
||||
} else {
|
||||
let result;
|
||||
try {
|
||||
result = await restoreBackup(backupId, { dryRun, approveScope, repoRoot });
|
||||
} catch (err) {
|
||||
// "Backup not found" and "unreadable manifest" are both the CLI failing to
|
||||
// do its job, never a verdict about a restore that happened.
|
||||
fail(err.message);
|
||||
return;
|
||||
}
|
||||
payload = {
|
||||
meta,
|
||||
backupId,
|
||||
dryRun,
|
||||
gate: result.gate,
|
||||
requiresApproval: result.requiresApproval,
|
||||
disclosures: result.disclosures,
|
||||
restored: result.restored,
|
||||
failed: result.failed,
|
||||
refused: result.refused,
|
||||
createdNotRemoved: result.createdNotRemoved ?? [],
|
||||
legacy: result.legacy ?? false,
|
||||
};
|
||||
lines = [
|
||||
...result.restored.map((r) => `${r.status}\t${r.originalPath}`),
|
||||
...result.failed.map((f) => `${f.status}\t${f.originalPath}`),
|
||||
...result.refused.map((r) => `${r.status}\t${r.originalPath}`),
|
||||
];
|
||||
if (result.failed.length > 0) process.exitCode = 2;
|
||||
else if (payload.requiresApproval && !approveScope && !dryRun) process.exitCode = 1;
|
||||
}
|
||||
|
||||
const json = `${JSON.stringify(payload, null, 2)}\n`;
|
||||
if (outputFile) {
|
||||
await writeOutputFile(outputFile, json);
|
||||
// Nothing on stdout when writing to a file — a command rendering this would
|
||||
// otherwise show the user the raw payload (ux-rules rule 1).
|
||||
} else if (jsonMode) {
|
||||
process.stdout.write(json);
|
||||
} else {
|
||||
for (const line of lines) process.stdout.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (err) {
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
process.exitCode = 3;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue