Compare commits

...

45 commits

Author SHA1 Message Date
1182f85767 fix(drift): validate the arguments and the baseline anchor drift never checked
Dogfooding `/config-audit drift` against the machine. Fasit written before the
run; 6/6 predictions plus both F7 arms confirmed, 0 deviations.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-30 09:10:07 +02:00
1d63492617 fix(acr): optimize lens scopes out plugin-bundled CLAUDE.md + unique candidate paths (M-BUG-11)
The optimize lens CLI fed its precision-gate agent every CLAUDE.md that discovery
returned, including the 256 files under ~/.claude/plugins/ — vendored plugin
CLAUDE.md across every cached version (7 config-audit, 6 ms-ai-architect, 5 okr,
...) plus their bundled tests/fixtures and examples. Running `optimize --global`
on this machine produced 454 candidates across 92 "files", ~250 of them sourced
from plugin-internal files a user cannot act on (the plugin overwrites them on
update). Same class as M-BUG-2: plugin-bundled config is not the user's cascade.

Second defect: candidates were keyed by `relPath || absPath`, but relPath
collides across scopes — a repo-root `CLAUDE.md` and the user-global
`~/.claude/CLAUDE.md` both relPath to `CLAUDE.md`. The two files that actually
matter were merged into one indistinguishable bucket (21 candidates), the agent's
Read(file) would resolve the wrong one, and cache-file relPaths were not readable
relative to cwd at all.

Fix (lens-CLI-local, surgical):
- Filter isPluginBundled (absPath under `.claude/plugins/`) from discovery for
  BOTH halves of the motor (candidate loop + the OPT scanner, which reads
  discovery.files directly). Drops vendored files regardless of active/stale
  version, so excludeCache is unnecessary here.
- Key each candidate by absPath: unique + readable.
No change to file-discovery.mjs or the OPT scanner, so their byte-stable
snapshots are untouched.

Suite 1348/0 (+4: candidate scoping, real-config survives, deterministic scoping,
absolute-path identity). Frozen v5.0.0 + SC-5 snapshots untouched (the lens CLI
has no snapshot; the command is agent-driven, not byte-stable). Dogfood ~/.claude
`optimize --global`: candidates 454->45, deterministic 2->0 (both were stale
plugin-cache copies), distinct files 92->11, repo vs user-global now distinct
(12 + 9 = 21). Residual 45 includes config-audit's own tests/fixtures CLAUDE.md
(repo-specific dogfooding artifact, not a general bug — left alone).
2026-06-30 06:44:36 +02:00
96e32df87b docs(claude-md): trim project CLAUDE.md to invariants (−662 always-tok)
The plugin's own CLAUDE.md is loaded every turn while working in this repo
(measured 2,178 always-loaded tokens via `manifest`, the largest slice of the
2,745-tok project delta). Much of it was reference-grade prose that duplicates
README.md, `/config-audit help`, and docs/ — verbose per-command feature lists,
the full plain-language-output spec, the session-dir ASCII tree — none of which
is invariant for working on the plugin.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq
2026-06-26 00:19:27 +02:00
a1e786ba4f release: v5.12.2 — "Honest census" (M-BUG-1: honest plugin enumeration)
enumeratePlugins 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/. 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. 1301 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYFipiVaRtbimkDjDnnKvY
2026-06-24 14:59:43 +02:00
be1056aac0 fix(acr): enumeratePlugins honors enabledPlugins + polyrepo cache installPaths (M-BUG-1)
active-config-reader walked ~/.claude/plugins/marketplaces and ignored both the
enabledPlugins toggle and the polyrepo cache layout. On a polyrepo machine it
counted disabled/uninstalled marketplaces plugins as "active" while MISSING the
actually-enabled plugins installed under plugins/cache. This corrupted the agent
listing and every pluginList consumer (manifest, AGT, whats-active, hooks, rules).

Now: when installed_plugins.json is present, inject only plugins that are in the
manifest AND enabledPlugins[key]===true, each resolved to its active installPath
(incl. cache/). When the manifest is absent (fixtures/pre-v2 installs), fall back
to the historic marketplaces walk rather than silently dropping config — mirrors
file-discovery.mjs's "trust installed_plugins.json" contract.

Verified on real machine: agent listing 114->104, ghost plugins (newsletter,
content-machine, harness, kiur, ...) gone, voyage/linkedin/ms-ai/okr now correctly
counted. Full suite 1301/0; byte-stable snapshots untouched (hermetic empty HOME).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrTb8ktf1XZWEVwgz5MTTo
2026-06-24 10:50:28 +02:00
0f9e319c85 release: v5.12.1 — "Footgun guard" (Pattern H live-session caveat)
Version-sync for the Pattern H recommendation fix shipped in 45efed3:
- plugin.json 5.12.0 -> 5.12.1
- README version badge + version-history row (1297 tests)
- CHANGELOG [5.12.1] entry

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 frozen v5.0.0 + SC-5 + default-output snapshots are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:15:54 +02:00
45efed3dbf fix(tok): live-session caveat in Pattern H stale-cache recommendation
Pattern H ("Stale plugin-cache versions") recommended deleting stale
version dirs under ~/.claude/plugins/cache 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
footgun that broke a live session during C4: deleting the dir pulls the
files out from under the running session, which then breaks and must
/exit + restart.

Extend the recommendation text with the live-session caveat. No new
finding ID/scanner, no token counts changed (recommendation string only)
-> --json/--raw stay byte-stable, frozen v5.0.0 + SC-5 + default-output
snapshots untouched. +1 test (1297).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:04:47 +02:00
6bb08cc84d release: v5.12.0 — "Auto-calibration" (B8b)
Version-sync for the B8b model→window auto-probe shipped in cf75249:
- plugin.json 5.11.0 -> 5.12.0
- README version badge + version-history row (1296 tests)
- CHANGELOG [5.12.0] entry

Scanner count stays 16, agents 7, commands 21. No new finding ID or scanner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:29:21 +02:00
cf75249b5e feat(skl,cml): --context-window auto model→window probe (v5.12 B8b) [skip-docs]
Completes the deferred B8 half. `--context-window auto` now probes the
configured model and calibrates SKL/CML budgets to its real window instead
of always falling back to the conservative advisory anchor.

- lib/context-window.mjs: 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; unknown/unconfirmed -> null
  (caller keeps the conservative anchor). resolveContextWindow() auto branch
  now probes opts.model: recognized -> auto-probed (not advisory); unknown
  or unpinned -> auto-unresolved (advisory, pre-B8b behavior).
- lib/active-model.mjs (new): resolveActiveModel() reads the model the way
  Claude Code resolves it — shell ANTHROPIC_MODEL override, then settings
  cascade local > project > user. Injectable env, hermetic under test HOME.
- scan-orchestrator: resolves the active model only when the flag is `auto`
  and threads it into resolveContextWindow; posture inherits via runAllScanners.

Default (no flag) and explicit --context-window <n> paths ignore the model
and stay byte-stable; frozen v5.0.0 + SC-5 snapshots untouched. TDD: 17 new
tests (context-window mapping/probe + active-model cascade). Suite 1296 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:29:12 +02:00
ad1eceb76a release: v5.11.0 — "Precision polish" (B7+B8)
Version-sync: plugin.json 5.10.0→5.11.0; README version badge + tests badge
1257+→1279+ + new version-history row; CLAUDE.md test counts 1257/71→1279/72
(22→23 lib test files); CHANGELOG [5.11.0]; README SKL row documents CA-SKL-003
+ the --context-window flag.

B7 (CA-SKL-003) and B8 (--context-window calibration) shipped as feat commits
2798880 + 2082b7d. Scanner/agent/command counts unchanged (16/7/21); --json/--raw
byte-stable; frozen v5.0.0 + SC-5 snapshots untouched. Suite 1279 green.
self-audit --check-readme: PASS (badges == filesystem).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:49:32 +02:00
2082b7d112 feat(skl,cml): --context-window calibration, advisory when unknown (v5.11 B8) [skip-docs]
SKL-002 (skill-listing budget) and CML char-budget now calibrate to a
resolved context window instead of always anchoring at 200k:

- resolveContextWindow(): --context-window <n> calibrates; 'auto' keeps the
  conservative 200k anchor but marks advisory (model→window probing deferred
  to B8b); no flag → 200k anchor, byte-identical to pre-B8 default.
- scaleForWindow(): linear off the 200k anchor (identity at the anchor).
- SKL + CML each keep an untouched default branch (window===200k && !advisory)
  for byte-stability and a calibrated branch; advisory downgrades the budget
  finding from a breach (low/medium) to info.
- Flag wired through scan-orchestrator + posture; runAllScanners resolves once
  and threads { contextWindow } to scanners (others ignore the 3rd arg).
- CPS intentionally excluded: it has no window-anchored budget (fixed
  150-line volatility heuristic), so there is nothing to calibrate.

15 new tests; e2e CLI verified (1M suppresses SKL-002, auto → info, default
unchanged); full suite 1279 green; snapshots byte-stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:44:52 +02:00
27988801be feat(skl): flag oversized skill bodies on demand (v5.11 B7) [skip-docs]
New CA-SKL-003 (low): a SKILL.md body over ~5,000 tokens (~500 lines)
should split reference content into supporting files / use context: fork.

- measureActiveSkillListing() now returns body metrics (chars/lines/tokens);
  the body was already read in full, only the frontmatter was parsed before.
- Honest framing: BODY_CALIBRATION_NOTE marks this as ON-DEMAND cost (loads
  only when the skill is invoked, NOT every turn like the always-loaded
  listing) and an estimate — hence low severity.
- 5 new tests; full suite 1262 green; snapshots byte-stable (default branch
  untouched; new finding fires only on bodies >5k tok, none in fixtures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:36:20 +02:00
c2e3a56a20 release: v5.10.0 — "Deferral & injection hygiene" (B4+B5+B6)
Three additive hardening levers extending existing scanners toward a tighter
always-loaded prefix (scanner count stays 16, agents 7, commands 21):
  - B4 (CA-TOK-006): MCP tool-schema deferral check + CLI-over-MCP lever
  - B5: hook additionalContext-injection advisory + filter-before lever
  - B6: CPS @import volatile-content scan

Version sync: plugin.json 5.10.0, README version/tests badges (1215+ -> 1257+)
+ version-history row, tokens pattern-count 7 -> 8 (README + CLAUDE.md),
CLAUDE.md test tally 1215/68 -> 1257/71 (36 -> 39 scanner files), CHANGELOG
[5.10.0]. self-audit --check-readme green; suite 1257 pass / 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:02:14 +02:00
fa1ddd963a feat(cps): scan @imported files for volatile cached-prefix content (v5.10 B6) [skip-docs]
CPS originally inspected only files discovery classifies as claude-md, but a
CLAUDE.md can pull arbitrary files into the cached prefix via @import — and
those targets (e.g. @shared/conventions.md) are usually not claude-md in
discovery, so their inlined content was never scanned. Neither TOK Pattern A
(top-30 of cascade files) nor the in-file CPS scan reaches past the importing
file, so volatility inside 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, evidence naming the importer.

Scope boundaries (deliberate):
- One hop only; imports-of-imports stay with IMP (deep-chain owner).
- No lines-1-30 skip for imported content — that exclusion is root-file-specific
  to avoid Pattern A overlap, which never reaches imported files.
- No double-reporting: an import resolving to a discovered claude-md is skipped
  (own iteration); a reportedImports set dedupes a target imported by several
  CLAUDE.md files.

Dropped from B6 (per plan verdict): confident behavioral cache-buster detection
(opusplan/model-switch is runtime, not static config) and jq-transcript
automation. "No overstated behavioral finding ships" — even the permitted
opusplan info-advisory was left out; the @import extension is the whole of B6.

Byte-stability: the in-file finding keeps the same condition + byte-identical
evidence/description (continue-skip refactored to if-emit, behaviour-preserving);
new findings fire only on a volatile import, which no frozen v5.0.0 fixture has.
docs: README + scanner-internals CPS rows + full B6 note; CLAUDE.md kept lean
([skip-docs]). Suite 1254 -> 1257 green; snapshots + SC-5 untouched.
Version/badges/CHANGELOG wait for the v5.10 release cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:45:12 +02:00
d2c45a3bb8 feat(hooks): additionalContext injection advisory + filter-before lever (v5.10 B5) [skip-docs]
HKV now flags hooks that build hookSpecificOutput.additionalContext from
un-grepped command output as an INFO advisory (weight 0, never severity-bearing
— excluded from the self-audit nonInfo set). That field enters Claude's context
every time the hook fires (plain stdout on exit 0 does not), so an unfiltered
payload is a recurring per-turn token cost.

- New lib scanners/lib/hook-additional-context.mjs: pure assessHookAdditionalContext
  (unit-tested, no IO) + IO wrapper assessHookContextForRepo (walk hooks->scripts).
  Heuristic: additionalContext + verbose-prone capture (cat/git log/execSync/…) &&
  no filter (grep/head/jq/.slice). Deliberately low precision -> advisory only.
- HKV: emits the advisory inline on scripts it already reads (after the M5 verbose
  check). Additive, info severity -> frozen v5.0.0 + SC-5 snapshots untouched.
- feature-gap: new filterHookLeverFinding companion, fires ONLY when >=1 chatty
  hook is detected — surfaces the filter-before-Claude-reads lever (CC
  filter-test-output.sh). Silent otherwise (opportunity, not noise).
- docs: README HKV + GAP scanner rows; scanner-internals.md HKV row + full B5
  implementation note. CLAUDE.md kept lean ([skip-docs]); B5 fully documented in
  README + scanner-internals.

Mechanism verified 2026-06-23 against code.claude.com/docs context-window.md
(additionalContext enters context; plain stdout does not). Suite 1239 -> 1254 green.
Version/badges/CHANGELOG wait for the v5.10 release cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:28:33 +02:00
8f7e196046 feat(tokens): MCP tool-schema deferral check + CLI-over-MCP lever (v5.10 B4)
By default Claude Code defers MCP tool schemas (names-only, ~120 tok; full
schemas 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)
  - configured model is a Haiku model                      (medium)
  - per-server .mcp.json alwaysLoad:true (CC v2.1.121+)    (high)
auto[:N] is threshold mode (info, not a trigger).

New engine lib/mcp-deferral.mjs: pure assessMcpDeferral({settings,mcpServers})
(unit-tested, no IO) + thin IO wrapper assessMcpDeferralForRepo shared by TOK and
GAP. Severity scales with aggregate forced-upfront tokens (medium-confidence
reasons cap at medium). feature-gap cliOverMcpLeverFinding fires only as a
companion to CA-TOK-006 (prefer gh/aws/gcloud over MCP for common ops).

Honest scoping (Verifiseringsplikt): triggers on config files ONLY — never
process.env shell vars. Vertex / custom ANTHROPIC_BASE_URL / runtime /model
switch are launch state (would flap snapshots machine-dependently), so they are
DISCLOSED in every finding, not triggered. Tool-level anthropic/alwaysLoad and
claude.ai connectors likewise disclosed. 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.

alwaysLoad added to CA-MCP VALID_SERVER_FIELDS (no longer flagged as unknown).
active-config-reader surfaces per-server alwaysLoad. Byte-stable: CA-TOK-006
fires only on new conditions; frozen v5.0.0 + SC-5 snapshots untouched.
Tests 1215 -> 1239 (engine 16, integration 5, lever 2, mcp-field guard 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:59:14 +02:00
96 changed files with 7071 additions and 339 deletions

View file

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

3
.gitignore vendored
View file

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

View file

@ -5,6 +5,450 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- **`M-BUG-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.
**1410** tests (+12). 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

View file

@ -1,13 +1,8 @@
# 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
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
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.
## Commands
@ -15,15 +10,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 (7 patterns: cache-breaking, redundant perms, deep imports, oversized cascade, bloated SKILL.md desc, MCP tool-schema budget, stale plugin-cache disk-cleanup), each ranked hotspot tagged with its load pattern (always / on-demand / external) — **cache-aware** (stale `~/.claude/plugins/cache` versions excluded by default; only each plugin's active version counts; `--no-exclude-cache` for the full walk), optional `--accurate-tokens` API calibration, `--with-telemetry-recipe` cache-hit recipe pointer |
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens, each tagged with its load pattern (always-loaded / on-demand / external) + an always-loaded subtotal ("tokens that enter context every turn") |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact (incl. a conditional `disableBundledSkills` lever when the active skill listing is over budget — remediation companion to SKL `CA-SKL-002`) |
| `/config-audit optimize` | Optimization lens (mechanism-fit) — config that works but fits a better mechanism: procedure→skill (CA-OPT-001, deterministic), lifecycle→hook / unscoped path→rule / "never"→permission (prose-judgment via opus `optimization-lens-agent`). Hybrid motor; every finding cites a best-practices-register rule. Agent-driven, **not byte-stable** |
| `/config-audit` | Full audit with auto-scope detection |
| `/config-audit posture` | A-F health scorecard (10 quality areas) |
| `/config-audit tokens` | Prompt-cache-aware token hotspots, each tagged with its load pattern; cache-aware |
| `/config-audit manifest` | Ranked table of every token source + always-loaded subtotal |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable**. `--subtract` adds the subtraction axis (what no longer earns its always-loaded rent, `BP-SUB-001`) — opt-in, proposes only |
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
| `/config-audit rollback` | Restore configuration from backup |
| `/config-audit plan` | Create action plan from audit findings |
| `/config-audit plan` | Create action plan from findings |
| `/config-audit implement` | Execute plan with backups + auto-verify |
| `/config-audit help` | Show all commands |
@ -33,9 +28,9 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|---------|-------------|
| `/config-audit drift` | Compare current config against saved baseline |
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
| `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) |
| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — deterministic stale check (sources older than ~90d) + web candidate poll (CC changelog + Anthropic blog); **human-approved writes only** (Verifiseringsplikt). The "living" half of the knowledge base. Web/judgment-driven, **not byte-stable** |
| `/config-audit campaign` | Machine-wide audit campaign — durable ledger ABOVE sessions: per-repo lifecycle (pending→audited→planned→implemented) + machine-wide roll-up by severity + **machine-wide always-loaded token bill** (`refresh-tokens` live cross-repo sweep — shared global layer counted once + per-repo deltas) + cross-repo prioritized backlog + plan **export** (drop a planned repo's plan into its own `docs/`), resumable across sessions. Read-only report (campaign-cli) + **human-approved** writes via deterministic write/export CLIs. THIN: tracks+routes state, reuses existing implement/rollback for execution. Judgment-driven, **not byte-stable** |
| `/config-audit whats-active` | Read-only inventory of active plugins/skills/MCP/hooks/CLAUDE.md (with token estimates) |
| `/config-audit knowledge-refresh` | Refresh the best-practices register (stale check + web poll). Human-approved writes; **not byte-stable** |
| `/config-audit campaign` | Machine-wide audit ledger + token bill across repos. Human-approved writes; **not byte-stable** |
| `/config-audit discover` | Run discovery phase only |
| `/config-audit analyze` | Run analysis phase only |
| `/config-audit interview` | Gather user preferences (opt-in) |
@ -51,84 +46,59 @@ Analyzes and optimizes Claude Code configuration across three pillars:
| planner-agent | Create action plan | opus | yellow | Read, Glob, Write |
| implementer-agent | Execute changes | sonnet | magenta | Read, Write, Edit, Bash, Glob |
| verifier-agent | Verify results | sonnet | purple | Read, Glob, Grep |
| feature-gap-agent | Context-aware feature recommendations | opus | green | Read, Glob, Grep, Write |
| optimization-lens-agent | Mechanism-fit precision gate (prose-judgment lens cases) | opus | orange | Read, Glob, Grep, Write |
| feature-gap-agent | Feature recommendations | opus | green | Read, Glob, Grep, Write |
| optimization-lens-agent | Mechanism-fit precision gate | opus | orange | Read, Glob, Grep, Write |
## Hooks
| Event | Script | Purpose |
|-------|--------|---------|
| PreToolUse | `auto-backup-config.mjs` | Auto-backup config files before Edit/Write |
| PostToolUse | `post-edit-verify.mjs` | Verify config files after Edit/Write, block on new critical/high |
| SessionStart | `session-start.mjs` | Checks for active (unfinished) sessions |
| Stop | `stop-session-reminder.mjs` | Reminds about current session phase |
| PreToolUse | `auto-backup-config.mjs` | Backup config files before Edit/Write |
| PostToolUse | `post-edit-verify.mjs` | Verify after Edit/Write, block on new critical/high |
| SessionStart | `session-start.mjs` | Check for active (unfinished) sessions |
| Stop | `stop-session-reminder.mjs` | Remind about current session phase |
## Reference docs (read on demand)
- **Scanner inventory, lib modules, action engines, knowledge base, per-scanner/per-block implementation notes:** `docs/scanner-internals.md`
- **Plain-language output (v5.1.0), humanizer vocabularies, output modes:** `docs/humanizer.md`
- `docs/scanner-internals.md` — scanner inventory, lib modules, action engines, knowledge base, per-scanner/per-block implementation notes (design rationale, primary-source verification, byte-stability lessons)
- `docs/humanizer.md` — plain-language output (v5.1.0), humanizer vocabularies, output modes
## Plain-Language Output (v5.1.0) — summary
## Plain-Language Output (v5.1.0)
Default output of all 18 commands routes through `humanizeEnvelope` from `lib/humanizer.mjs`. Findings get three decorated fields:
- `userImpactCategory` — Configuration mistake / Conflict / Wasted tokens / Dead config / Missed opportunity
- `userActionLanguage` — Fix this now / Fix soon / Fix when convenient / Optional cleanup / FYI (derived from severity)
- `relevanceContext``affects-everyone` (default) / `affects-this-machine-only` (`*.local.*` files) / `test-fixture-no-impact`
`--raw` bypasses the humanizer for byte-stable v5.0.0 output. `--json` is also byte-stable. Full detail and Wave 5 lessons: `docs/humanizer.md`.
Default output of all commands routes through `humanizeEnvelope` (`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-DIS-001`, `CA-COL-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-SKL-001`, `CA-OST-001`, `CA-OPT-001`, `CA-AGT-001`.
## Conventions
Enforced project conventions live in `.claude/rules/` (auto-loaded as project instructions):
- `ux-rules.md` — output/narration/formatting rules for all commands (never dump raw JSON, narrate before each step, space-separated command suggestions)
Enforced conventions live in `.claude/rules/` (auto-loaded as project instructions):
- `ux-rules.md` — output/narration/formatting for all commands (never dump raw JSON, narrate before each step, space-separated command suggestions)
- `command-development.md` — required command frontmatter + `plugin:action` naming
- `agent-development.md` — agent frontmatter + "when to use" conventions
- `state-management.md` — update `state.yaml` after every workflow phase
Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{SCANNER}-{NNN}` ID format; byte-stable CLIs are verified against frozen `tests/snapshots/v5.0.0/` baselines.
**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.
## Testing
```bash
node --test 'tests/**/*.test.mjs'
```
1215 tests across 68 test files (22 lib + 36 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
Per-scanner and per-build-block implementation notes (design rationale, primary-source verification, byte-stability lessons) live in `docs/scanner-internals.md`**Implementation notes**.
Test fixtures in `tests/fixtures/`. Per-scanner and per-build-block implementation notes (design rationale, primary-source verification, byte-stability lessons) live in `docs/scanner-internals.md`**Implementation notes**.
## Gotchas
- Session directories accumulate — use `/config-audit cleanup` to manage
- Scanners run on Node.js >= 18 (uses node:test, node:fs/promises)
- Scanners run on Node.js 18 (uses node:test, node:fs/promises)
- Plugin CLAUDE.md files in node_modules should be excluded via scope

View file

@ -6,13 +6,13 @@
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
![Version](https://img.shields.io/badge/version-5.9.0-blue)
![Version](https://img.shields.io/badge/version-5.13.0-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Scanners](https://img.shields.io/badge/scanners-16-cyan)
![Commands](https://img.shields.io/badge/commands-21-green)
![Agents](https://img.shields.io/badge/agents-7-orange)
![Hooks](https://img.shields.io/badge/hooks-4-red)
![Tests](https://img.shields.io/badge/tests-1215+-brightgreen)
![Tests](https://img.shields.io/badge/tests-1410-brightgreen)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 16 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, output-style, and always-loaded agent-listing-budget detection. Zero external dependencies.
@ -275,10 +275,11 @@ 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, each tagged with its load pattern (always / on-demand / external); 7 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 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, proposes only, never writes. Pair with `--global` to reach the user-level CLAUDE.md, where the always-loaded cost actually sits |
| `/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 |
@ -316,17 +317,17 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
|---------|--------|-----------------|
| `claude-md-linter.mjs` | CML | Oversized files (line count **plus** a context-window-scaled char budget mirroring Claude Code's ~40.0k-char startup warning), missing sections, broken @imports, duplicates, stale TODOs |
| `settings-validator.mjs` | SET | Schema violations, unknown/deprecated keys, type mismatches, permission issues |
| `hook-validator.mjs` | HKV | Invalid format, missing scripts, wrong event names, timeout risks |
| `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 |
| `feature-gap-scanner.mjs` | GAP | 25 feature checks shown as opportunities, not grades — plus a conditional `disableBundledSkills` recommendation when the active skill listing is over budget, and a conditional **filter-before-Claude-reads** lever when a hook injects unfiltered output into `additionalContext` (companion to the HKV advisory; cites the documented `filter-test-output.sh` pattern) |
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascades, bloated skill descriptions, MCP tool-schema budget, and stale `~/.claude/plugins/cache` versions (disk-cleanup, zero live-context impact) — cache-aware ranking excludes superseded plugin versions by default (`--no-exclude-cache` to include) |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of the CLAUDE.md cascade — beyond the cache-prefix window but still re-loaded every turn |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of the CLAUDE.md cascade — beyond Pattern A's top-30 window but still re-loaded every turn — **plus** volatile content inside `@import`-ed files (inlined into the cached prefix, one hop, otherwise invisible to per-file scans) |
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries: (1) tools in BOTH `permissions.deny` and `permissions.allow` — deny wins (incl. the `Tool(*)` deny-all glob, equivalent to a bare deny); (2) unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — valid only as `mcp__<server>__*`; (3) `Tool(param:value)` rules whose key is the tool's own canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/`url`) — CC ignores these and emits a startup warning |
| `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 |
@ -595,6 +596,32 @@ date, and a `confidence`. It is the source of truth for the optimization lens (O
`scanners/lib/best-practices-register.mjs` (zero-dependency, native JSON). See
`docs/v5.7-optimization-lens-plan.md`.
### The subtraction floor
`optimize --subtract` is the only lens that proposes *removing* configuration, so it carries a
guarantee the others do not need: **a load-bearing block is never a candidate.** Precision here
is asymmetric — a missed dead line costs a few tokens per turn, while a deleted load-bearing
line costs a wrong remote or a broken script — so the floor is decided in code
(`scanners/lib/floor-exclusion.mjs`), before the opus judge sees anything, rather than being
left to prose judgement.
A block is floored when it carries an underivable local literal (inline code span, rooted path,
domain, concrete filename, version pin), when it states a policy invariant (secrets,
credentials, production, prompt-injection boundaries — floor *by decision*, not by
classification), or when it names a capitalized entity the mechanism cannot resolve without a
dictionary. That last rule is a deliberate conservative default: it declines to decide and
keeps the block, paying in recall rather than risk.
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
@ -662,6 +689,15 @@ This plugin is cautious by design — configuration files are important, and a b
| Version | Date | Highlights |
|---------|------|-----------|
| **5.13.0** | 2026-07-31 | "Pipeline hardening" — the batch release of everything found by dogfooding the plugin against the maintainer's real machine and by walking the `analyze → plan → implement → rollback` pipeline end-to-end on a throwaway repo copy: one new lens mode plus **14 bugs** (`M-BUG-11``M-BUG-20`, `M-BUG-22``M-BUG-25`). **Added — `optimize --subtract` (`BP-SUB-001`):** the subtraction axis, asking what no longer earns its always-loaded rent. Opt-in, proposes only, and the only lens that removes config — so a **load-bearing block is never a candidate**, decided in code (`scanners/lib/floor-exclusion.mjs`) *before* the judge runs, never in prose. Verified against a hand-built ground truth written before any classifier existed: **zero load-bearing blocks proposed**, 11/18 groups, ~756 tok ≈ 18% of a ~4300-token file. **Fixed — `rollback` (`M-BUG-22/23/24/25`):** nothing agreed on where a backup lives; `listBackups()` returned 9 phantom test backups and 0 of 4 real ones, and `restoreBackup` returned `{restored:[],failed:[]}` — a **success-shaped no-op** — because `parseManifest` knew only one of the two manifest spellings in use. Canonical root now, legacy kept readable, unparseable manifests **throw**. **`M-BUG-19`:** `globToRegex` corrupted mid-pattern `/**/`, flagging live rules dead. **`M-BUG-18`/`M-BUG-20`:** the subagent harness won't write report-shaped `.md` (analyze now persists the returned report), and parallel agents clobbered the shared log with `Write` (pinned to Bash `>>`). **`M-BUG-11`/`M-BUG-13`:** `optimize` and `feature-gap` scanned vendored plugin config a user cannot act on — `optimize` candidates **454→45**, `feature-gap` **~0 (masked) → 18** opportunities. **`M-BUG-12`/`M-BUG-14`/`M-BUG-15`/`M-BUG-16`/`M-BUG-17`:** plain-language output that contradicted its own evidence (posture's `--output-file` never humanized; four finding types with no humanizer entry). Known, deliberately unfixed: `rollback` cannot delete files `implement` *created* — it now reports them (`createdNotRemoved`) instead of failing silently; automatic deletion of user files gets its own design. No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); frozen v5.0.0 untouched, SC-5 regenerated once for two humanized titles. **1398** tests (+54). |
| **5.12.5** | 2026-06-26 | "Dogfood denoise" — a samle-release of the Fase-3 scanner false-positive batch (`M-BUG-2/6/7/8/10`, all dogfooding finds on the maintainer's real machine). Five scanners stop counting non-user / non-live config as the user's: **CNF** (`M-BUG-2`) excludes files under `.claude/plugins/` from conflict analysis (`isPluginBundled`) — installed plugins' bundled settings/hooks/fixtures are not a user-resolvable cascade (dogfood **339→0**, Conflicts was an F on pure plugin noise). **file-discovery** (`M-BUG-8`) adds `backups` to `SKIP_DIRS` — a `backups/` tree holds frozen copies, never live config (dogfood files-under-`/backups/` **36→0**, 717 live retained). **token estimator** (`M-BUG-6`) strips block-level `<!-- -->` HTML comments from CLAUDE.md sizing — CC strips them before injection, so they were never always-loaded tokens (dogfood ~3386→3301, ~85 tok). **CPS** (`M-BUG-7`) skips fenced/inline code and whitelists CC-stable path vars (`${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}`) before cache-buster matching (dogfood **5→2**). **SET** (`M-BUG-10`) typo-gates the unknown-settings-key finding — the CC schema is passthrough (verified against the 2.1.193 binary), so an unknown key is forward-compatible, not an error; it now flags only a near-miss of a known key (levenshtein ≤2), severity medium→low, +6 binary-verified `KNOWN_KEYS` (dogfood **6→0**). No count change (scanners **16**, agents **7**, commands **21**, hooks **4**); all five are byte-stable — frozen v5.0.0 + SC-5 + default-output snapshots untouched, no re-seed (each fixture's findings are genuinely unchanged). **1344** tests (+37). |
| **5.12.4** | 2026-06-26 | "Rooted rules" — fixes `M-BUG-9` (dogfooding find) in `scanners/rules-validator.mjs`: the RUL "Rule path pattern matches no files" check now resolves a rule's `paths:`/`globs:` pattern against the rule's **own project root** (the dir containing its `.claude/`), not the outer scan root. Previously `countGlobMatches` globbed against the scan target and `collectProjectFiles`' `depth>4` cutoff never reached deep matching files, so a live rule in a **nested repo** (e.g. a marketplace checkout under `~/.claude`) was wrongly flagged "never activates" (high) — a false F-grade for anyone with rules in a nested repo. The fix derives each rule's project root, collects+globs per root (cached), and skips the check for user-global rules (`root === $HOME`), which scope against the active project at runtime. Same scope-conflation family as `M-BUG-1/2`. No count change (scanners **16**, agents **7**, commands **21**); the fix is a no-op when `projectRoot === targetPath` (the common single-repo scan), so frozen v5.0.0 + default-output snapshots stay byte-stable. **1307** tests (+2 TDD: nested-repo false-positive + HOME guard). |
| **5.12.3** | 2026-06-26 | "Phantom agents" — fixes `M-BUG-3/4/5` (dogfooding finds) in `scanners/lib/active-config-reader.mjs`: `enumerateAgents` now counts only the agents Claude Code actually **registers**. Per the official subagents doc, an agent needs valid `name`+`description` frontmatter, and CC scans recursively and silently skips frontmatter-less files. The reader previously (`M-BUG-5`) counted every `.md` regardless of frontmatter, (`M-BUG-3`) never recursed into agent subdirs, and (`M-BUG-4`) double-counted when the project dir equals the user dir (scanning `$HOME` — root cause, also affecting rules/output-styles). Real-machine verify: user-agent count **13→0** (all 12 user agents + `REMEMBER.md` are frontmatter-less → CC registers none), HOME `project`-dup **13→0**; corrected always-loaded baseline ≈ **53** (was 66). Agent enumeration is machine-dependent and absent from the frozen snapshots, so the v5.0.0 + SC-5 + default-output snapshots stay byte-stable; no count change (scanners **16**, agents **7**, commands **21**). **1305** tests. |
| **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 |

View file

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

View file

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

View file

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

View file

@ -60,12 +60,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 +93,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"`.

View file

@ -29,10 +29,10 @@ Tell the user: **"Saving current configuration as baseline..."**
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --save --name <baseline-name> $RAW_FLAG 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --save --name <baseline-name> --json $RAW_FLAG 2>/dev/null
```
Read stdout for confirmation. Tell the user:
`--save` writes its human confirmation to **stderr**, which `2>/dev/null` discards — pass `--json` so the `{saved, name, path}` object lands on stdout. Read stdout for confirmation. Tell the user:
```markdown
### Baseline Saved
@ -50,10 +50,16 @@ Tell the user: **"Comparing current configuration against baseline..."**
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --baseline <name> $RAW_FLAG 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <path> --baseline <name> --output-file /tmp/config-audit-drift.json $RAW_FLAG 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:

View file

@ -59,6 +59,21 @@ mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/nul
Copy each file to be modified. Generate `manifest.yaml` with checksums.
The manifest is what `/config-audit rollback` reads, so it MUST carry both lists:
```yaml
files: # pre-existing files this run will MODIFY
- backup: files/root/CLAUDE.md
original: /abs/path/CLAUDE.md
sha256: <sha256 of the pre-change content>
created: # files this run will CREATE (no backup can exist)
- /abs/path/.claude/rules/post-quality.md
```
Record every `create`-type action under `created:`. Rollback cannot restore a
file that never existed, but it must be able to tell the user which files it is
leaving behind — a half-restored target is only dangerous when it is silent.
Tell the user: **"Backup created. Implementing actions..."**
### Step 4: Execute actions
@ -78,6 +93,8 @@ Agent(subagent_type: "config-audit:implementer-agent")
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:

View file

@ -24,6 +24,7 @@ is hybrid: a cheap deterministic pre-filter finds candidates, then the opus
- **Lifecycle phrasing → hooks** (BP-MECH-001)
- **Unscoped path-specific instructions → path-scoped rules** (BP-MECH-002)
- **Absolute "never" prohibitions → permissions / hooks** (BP-MECH-004)
- **`--subtract`:** instructions that no longer earn their always-loaded rent (BP-SUB-001)
Each finding cites its register rule + source URL. A clean CLAUDE.md returns "no
opportunities" — that is a good result, not a failure.
@ -34,7 +35,21 @@ opportunities" — that is a good result, not a failure.
Split `$ARGUMENTS` into a path (first non-flag argument; default: current working
directory) and flags. Recognized flags: `--global` (include the user `~/.claude`
cascade in discovery).
cascade in discovery) and `--subtract` (add the subtraction axis, below).
**`--subtract` — the inverse question.** Every other lens asks what to *add* or
*move*; this one asks what no longer earns its always-loaded rent. It is opt-in
because it asks something different, and because deleting is not undoable by
reading. Pair it with `--global` to reach the user-level CLAUDE.md, where the
always-loaded cost actually sits (it loads in every repo, every session).
If `--subtract` is present, say so up front:
```
Also running the subtraction axis — instructions that cost tokens every turn
without telling me anything I couldn't work out. Load-bearing local facts
(remotes, versions, paths, policy) are excluded before anything is judged.
```
Tell the user:
@ -52,7 +67,9 @@ Generate a session ID (`YYYYMMDD_HHmmss`) if no active session exists.
mkdir -p ~/.claude/config-audit/sessions/{session-id} 2>/dev/null
GLOBAL_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--global"; then GLOBAL_FLAG="--global"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG 2>/dev/null; echo $?
SUBTRACT_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--subtract"; then SUBTRACT_FLAG="--subtract"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/optimize-lens-cli.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/optimize-lens.json $GLOBAL_FLAG $SUBTRACT_FLAG 2>/dev/null; echo $?
```
Exit code 0 is normal. Only exit code 3 is a real error → "The lens couldn't run.
@ -64,8 +81,13 @@ Read `~/.claude/config-audit/sessions/{session-id}/optimize-lens.json` with the
Read tool. It has `deterministic` (already-confirmed OPT findings), `candidates`
(pre-filter candidates with register provenance), `register`, and `counts`.
**Early exit:** if `counts.deterministic === 0` and `counts.candidates === 0`,
skip the agent and tell the user plainly:
Under `--subtract` it also has a `subtract` block (`candidates`, `register`) and
`counts.subtractCandidates`. Each subtraction candidate spans `line``endLine`
(a whole block). Include the whole `subtract` block when spawning the agent.
**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.
@ -117,6 +139,11 @@ End with context-sensitive next steps, explaining WHY each is useful:
- This command is **agent-driven and not byte-stable** — its output is a
human-facing report, deliberately outside the deterministic snapshot suite.
- `--subtract` **proposes, never writes.** Nothing is deleted; act on a finding
via `/config-audit plan``/config-audit implement` (backup + rollback).
- The subtraction floor is deterministic and runs *before* the agent, so a
load-bearing block is never a candidate. It errs toward keeping: on a
well-maintained config this axis is mostly a no-op, and that is a good result.
- The deterministic half (CA-OPT-001) also rides in the normal orchestrated
audit; this command adds the prose-judgment half on top.
- No files are modified. To act on a finding, use `/config-audit plan`

View file

@ -64,6 +64,18 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
- hooks/hooks.json (checksum verified)
- .claude/rules/typescript.md (checksum verified)
```
5. **Report what rollback cannot undo.** A backup only holds files that already
existed, so files the implement step CREATED survive the restore. If the
manifest has a `created:` section (or `restoreBackup()` returns a non-empty
`createdNotRemoved`), list those paths and say plainly that they remain:
```
Left in place — created by implement, no backup exists:
- .claude/rules/post-quality.md
- guidelines/posting-rhythm.md
Remove them manually if you want the pre-implement state exactly.
```
Never finish a restore without this section when the list is non-empty; a
silently half-restored target reads as a clean rollback.
### Delete mode
@ -74,9 +86,14 @@ If user says "delete" after listing, confirm and remove the backup directory.
Use the backup and rollback libraries directly:
```javascript
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
import { parseManifest } from '../scanners/lib/backup.mjs';
import { parseManifest, getBackupDir } from '../scanners/lib/backup.mjs';
```
Both read `~/.claude/config-audit/backups` and fall back to the pre-v2.2.0
`~/.config-audit/backups`, so a backup made before the move still resolves;
`listBackups()` flags those with `legacy: true`. Prefer this API over ad-hoc
`cp` — it verifies the checksum before and after each write.
Or via Bash:
```bash
# List backups

View file

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

View file

@ -12,14 +12,14 @@ 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 31150 of CLAUDE.md cascade (beyond Pattern A's top-30 window) |
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascade, bloated SKILL.md descriptions, MCP tool-schema budget, MCP tool-schema deferral (CA-TOK-006), stale plugin-cache disk-cleanup (prompt-cache patterns) |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of CLAUDE.md cascade (beyond Pattern A's top-30 window); plus volatile content inside `@import`-ed files (v5.10 B6, one hop) |
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries (low). (1) Tools in BOTH `permissions.deny` AND `permissions.allow` — deny wins; dominance is param-aware and treats the `Tool(*)` deny-all glob as equivalent to a bare deny (covers a bare allow). (2) Unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — CC accepts allow globs only after a literal glob-free `mcp__<server>__` prefix. Predicates shared with CNF live in `lib/permission-rules.mjs` |
| `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) |
@ -146,6 +146,147 @@ compare (proves the original schema is byte-identical), and the **SC-5 default-o
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).
### 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-130 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
@ -358,6 +499,56 @@ orchestrates pre-filter→agent→report. **Agent-driven → deliberately NOT by
outside the snapshot suite); the pre-filter lib *is* unit-tested (13 tests). No new orchestrated
scanner → scanner count stays 15; agents 6→7, commands 18→19, suite 1055→1068.
### Subtraction lens (`optimize --subtract`, `BP-SUB-001`) — the inverse axis
**Why a mode, not a command or scanner.** Every other command asks an addition question; nothing
asked what is no longer earning its rent. A hand-built ground truth over a real 250-line global
CLAUDE.md put the honest payoff at ~8501400 always-loaded tokens of ~4300 (~20 %, not the source
anecdote's 80 %), with 26 of 34 blocks load-bearing. That proportion justifies a fourth `lensCheck`
on the existing hybrid motor — not a new scanner (no badge bump 16→17, no frozen-snapshot risk) and
not a 22nd command. Cross-session state is what would have forced a command, and the earned-re-add
*ledger* is deliberately out of v1.
**Polarity is flipped from `lens-prefilter`.** That module is recall-first because a false candidate
only costs the judge a moment. Here a false candidate is a proposal to *delete*, so
`subtraction-prefilter.mjs` is precision-first and carries a blocking deterministic guarantee.
**Floor-exclusion is a separate module on purpose** (`lib/floor-exclusion.mjs`). §6.0's asymmetry —
a missed dead line costs a few tokens per turn, a deleted load-bearing line costs a wrong remote —
means the guarantee must not rest on a probabilistic judge, so the floor runs *before* the agent
and is legible as its own unit. Markers: code span, URL/host, filename, rooted path, version pin,
policy invariant, and `unresolved-entity` (a mixed-case capitalized word mid-sentence). The last is
a deliberate conservative default — resolving "Forgejo" from an ordinary capitalized word needs a
dictionary, so the mechanism declines and keeps the block.
**Granularity: leaf block + two structural exceptions.** (1) A paragraph ending in `:` merges with
the list it introduces — a stem often carries no literal of its own, and deleting it without its
list is meaningless. (2) An *ordered* list is a contract: steps inherit floor from any sibling,
because deleting step 2 of a five-step protocol is not like dropping one platitude. Unordered lists
do **not** inherit — a load-bearing bullet and a disposable one routinely share a list, and
container-reasoning is exactly the error the ground truth was built to catch.
**Three lessons from the dogfood run**, all invisible to the synthesized fixture and worth keeping:
- **JS `\b` is ASCII-only.** `/\bunngå\b/` never matches — the trailing `å` is not a word character,
so there is no boundary after it. Every Norwegian keyword ending in æ/ø/å was silently dead. Use
the `LB`/`RB` lookaround constants, never `\b`, around that vocabulary.
- **A bare `word/word` is not a path.** `pros/cons` vetoed the single largest deletable block until
`PATH_RE` was tightened to rooted paths and globs; real filenames are `FILENAME_RE`'s job.
- **"Mid-sentence" must key on a preceding lowercase letter**, not on "anything that is not a full
stop". The loose version read `**Bold labels:**` and quoted openers (`"Som AI kan jeg ikke…"`) as
entities and cost 4 of 11 deletable groups.
**Measured against the ground truth:** zero load-bearing blocks proposed (the blocking §8 gate),
11/18 deletable groups surfaced, ≈756 tok ≈ 18 % of the file — inside the pre-registered band. The
misses are all the conservative default working as designed (entity names, code spans, and
declarative/infinitive phrasing that carries no imperative). No new scanner: scanners stay 16,
commands 21, agents 7; suite 1365→1382.
**Note for a future narrowing of the veto:** the two failure modes the agent prompt hardens against
— staleness-is-not-deletion and tier-2-is-not-tier-3 — are currently *also* covered by exclusion
(both example blocks carry code spans/version pins and never reach the judge). The prompt language
is the only protection if those markers are ever loosened.
**Test-isolation fix (this session):** `token-hotspots.test.mjs` `runScanner` now wraps `scan()` in
the shared `withHermeticHome` helper — the suite is green on BOTH a real and a clean `HOME` (the OPT
section's old «run with clean HOME» caveat is resolved). Snapshot/byte tests were already hermetic.

View file

@ -0,0 +1,161 @@
# v5.13 Plan — Model Routing, Effort Awareness, Dead References
> **Target version is now v5.14.** The `5.13.0` slot was consumed by the pipeline-hardening batch
> (`optimize --subtract` is a feature, so that release could not be a patch). The filename is kept so
> existing references resolve; only the target moved.
Derived from an external video analysis ("The Model Isn't the Moat", 2026-07) cross-checked
against primary sources and against what config-audit already encodes. Every claim acted on
here was verified against Anthropic's own docs; video-only claims are explicitly rejected below.
## Source verification (done 2026-07-14)
| Claim from video | Verdict | Source |
|---|---|---|
| Orchestrator + cheaper worker models is a supported, recommended pattern | VERIFIED | code.claude.com/docs/en/sub-agents ("Control costs by routing tasks to faster, cheaper models like Haiku"), code.claude.com/docs/en/workflows |
| Reasoning effort is tunable per settings / session / launch / **per-agent frontmatter** / SDK; levels `low, medium, high, xhigh, max` | VERIFIED | code.claude.com/docs/en/model-config#adjust-effort-level, sub-agents doc |
| Leaked Fable 5 system prompt principles ("partial recognition ≠ current knowledge"; "a prompt implying a file is present doesn't mean one is"; answer-first-then-one-question; tool-call scaling 1 / 35 / 510) | VERIFIED near-verbatim, **provenance unconfirmed** (third-party leak repo, not Anthropic-confirmed) | github.com/asgeirtj/system_prompts_leaks `Anthropic/claude-fable-5.md` |
| "Fable 5 on low ≈ Opus 4.8 on high, slightly higher cost/quality" score-vs-cost chart | **CONTRADICTED** — no such chart/statement on Anthropic's pages; GPT-5.5 appears only in a testimonial | anthropic.com/news/claude-fable-5-mythos-5 |
## Already covered — no action
| Video idea | Existing coverage |
|---|---|
| "Process is the moat" (config/harness > raw model) | The plugin's entire thesis |
| Extract repeated procedure into a skill | BP-MECH-003 + CA-OPT-001 (`optimization-lens-scanner.mjs:121`) |
| CLAUDE.md size/ownership discipline | BP-SIZE-001 + CA-CML line/size checks (`claude-md-linter.mjs:109/:120/:140`) |
| Check that referenced imports exist | CA-IMP broken `@import` (`lib/import-resolver.mjs:88`) — but **only** `@import`, see Chunk 3 |
| Plugin's own agents are model-routed | Agents table already pins sonnet for mechanical, opus for judgment |
## Gaps → chunks
Verified gap summary (register-mapper sweep, 2026-07-14): no scanner audits per-agent
`model:`/`effort:` frontmatter; effort has validity-check only (`settings-validator.mjs:195`),
no recommendation; no dead-reference check for prose file mentions in CLAUDE.md; no
adversarial/failure-mode requirement in planner-agent; `fix-engine.mjs:26` effort list
omits `xhigh` (settings-validator has all five).
### Chunk 1 — Register entries: model routing + effort (dogfoods `knowledge-refresh`)
Add to `knowledge/best-practices.json` via the knowledge-refresh flow (human-approved write):
- **BP-MODEL-001** (`category: model-fit`): subagents doing mechanical/read-only work can pin a
cheaper model via `model:` frontmatter; orchestrator keeps the strong model. Source:
code.claude.com/docs/en/sub-agents → `confidence: confirmed`.
- **BP-MODEL-002** (`category: model-fit`): reasoning effort is tunable at five levels in five
places (settings `effortLevel`, `/effort`, `--effort`, per-agent `effort` frontmatter, SDK);
default `high`; higher effort is not universally better for simple tasks. Source:
code.claude.com/docs/en/model-config → `confidence: confirmed`.
Schema per `scanners/lib/best-practices-register.mjs:42-102` (id/claim/confidence/source.url/
source.verified required). This chunk doubles as the DEL B dogfood of `/config-audit
knowledge-refresh` (each chunk is also a plugin test).
### Chunk 2 — fix-engine effort hygiene (tiny, TDD)
`fix-engine.mjs:26` `VALID_EFFORT_LEVELS = ['low','medium','high','max']` — missing `xhigh`.
Consequence: nearest-match "fix" for a typo like `xhig` corrects to `high`, not `xhigh`.
Red test first: `findNearestEffortLevel('xhig') === 'xhigh'`. Align list with
`settings-validator.mjs:75`.
### Chunk 3 — CA-CML dead prose references (new deterministic check)
The strongest video-derived principle ("a prompt implying a file is present doesn't mean one
is") applied to CLAUDE.md quality: flag file paths mentioned in CLAUDE.md **prose** that do not
exist on disk. Today only `@import` targets are existence-checked; stale pointers like
`docs/foo.md` or `scripts/bar.sh` rot silently and burn always-loaded tokens on misdirection.
Conservative v1 to control false positives:
- Only backtick-quoted tokens that look like relative file paths (contain `/` or a known
extension), resolved against the CLAUDE.md's own directory.
- Skip URLs, globs (`*`), placeholders (`{...}`, `<...>`, `$VAR`, `${...}`), absolute and
`~/` paths (machine-specific), and paths under `.gitignore`d dirs if cheap to determine.
- Severity: low. New CA-CML-NNN (verify next free NNN at implementation — IDs are dynamic).
Byte-stability: follow [[adding-scanner-byte-stability]] steps for a new finding type in an
EXISTING scanner — frozen `tests/snapshots/v5.0.0/` must stay untouched; default-output
snapshots regenerate (`UPDATE_SNAPSHOT=1`) only if a fixture actually carries the new type;
humanizer step 7 (M-16/M-17 lessons): `TRANSLATIONS`-static entry for the new RAW title
(CML category mapping already exists).
### Chunk 4 — feature-gap + inventory: model/effort awareness
- New T3 opportunity check in `feature-gap-scanner.mjs`: authored agents
(`isAuthoredConfig`, M-BUG-13 lesson) where **no** agent sets `model:` or `effort:`
"all agents inherit the session model/effort — mechanical agents can be routed cheaper /
effort-calibrated" citing BP-MODEL-001/002. Fires only when authored agents exist
(M-BUG-15 lesson: no enhancement-check on empty collections). Opportunity framing, never
failure — deliberate max-model setups are a valid choice; finding is suppressable
(`.config-audit-ignore`).
- `whats-active` / `manifest`: surface `model`/`effort` per agent in the inventory tables.
- Humanizer wiring step 7 for the new GAP finding; verify via direct `scan()` output, not the
self-suppressed default output ([[agent-commands-need-scanner-scoping]]).
- feat commit → docs-gate: README + CLAUDE.md diffs required.
### Chunk 5 — planner-agent adversarial gate (do AFTER DEL B pipeline dogfood)
Add a required "Failure modes" section to `agents/planner-agent.md`'s action-plan contract:
before an action plan is emitted, list what could go wrong per change + rollback trigger.
Mirrors the video's scoping-vs-devil's-advocate distinction; currently absent (zero
adversarial requirements in agents/). **Sequencing constraint:** DEL B step 3.2 judges
planner-agent against a fasit — change the agent only after that dogfood pass, or the
fasit target moves mid-evaluation.
## Explicitly rejected (do not revisit without new evidence)
1. **"Fable low ≈ Opus high" cost/score framing** — contradicted by Anthropic's own pages.
Never encode in register, copy, or recommendations.
2. **Tool-call-count effort scaling (1 / 35 / 510) as a register entry** — source is an
unconfirmed third-party leak → would be `confidence: inferred`, never surfaced. Not worth
carrying.
3. **Cost/intelligence/"taste" model-routing table generator** — subjective scores don't fit
the deterministic, provenance-gated design. BP-MODEL-001 covers the actionable core.
4. **"Fable mode" skill** — a user-level skill, not configuration auditing. Out of plugin scope.
5. **CLAUDE.md prose contradiction detection** — real gap (CA-CNF only covers
settings/permissions/hooks) but not video-driven; keep this plan surgical.
## Known tension (named, not resolved here)
The operator's own global policy is Opus/max-effort for ALL subagents, never Haiku — the
opposite of Chunk 4's recommendation. Both are legitimate: the docs-backed routing advice
optimizes cost at equal quality for the general user; the operator deliberately buys maximum
quality. Chunk 4's copy must respect that (opportunity framing + suppressability), and on this
machine the finding will simply be suppressed or ignored. The plugin serves general users;
the operator's setup is not the target of the check.
## Verification
Global, after every chunk:
- `node --test 'tests/**/*.test.mjs'` → green (baseline 1359/0; count grows with new tests)
- `git status --porcelain tests/snapshots/v5.0.0/` → empty (frozen untouched)
- TDD: red test exists and fails BEFORE each production change
Per chunk:
- **C1:** `node --test tests/lib/best-practices-register.test.mjs` green;
`node scanners/knowledge-refresh-cli.mjs` classifies BP-MODEL-001/002 as fresh
- **C2:** `findNearestEffortLevel('xhig')``xhigh` (red first); `grep xhigh scanners/fix-engine.mjs` non-empty
- **C3:** fixture CLAUDE.md referencing `docs/missing.md` → finding fires; existing file /
URL / glob / placeholder / `~/` path → silent; humanized output has non-contradictory copy
- **C4:** authored-agent fixture without model/effort → opportunity fires; with either set →
silent; zero authored agents → silent; `userImpactCategory``Other` end-to-end via direct scan()
- **C5:** dogfood plan run produces a Failure-modes section; DEL B 3.2 fasit judged BEFORE the change
## Key assumptions (test before/at implementation)
1. **Per-agent `effort` frontmatter is official** — verified 2026-07-14 against
code.claude.com/docs/en/sub-agents + /model-config; re-fetch both pages at implementation
(docs move).
2. **New finding type in existing scanner leaves frozen snapshots untouched** — M-17 precedent
says yes when no v5.0.0 fixture carries the type; verify by running the suite and inspecting
which snapshots differ before committing.
3. **Next free CA-CML/CA-GAP finding numbers** — IDs are built dynamically; grep tests +
snapshots for the highest used NNN before assigning.
## Sequencing vs DEL B (one plan, no relitigation)
This plan does NOT preempt the active DEL B sequence. Recommended order:
1. DEL B step 3 pipeline dogfood (`analyze → plan → implement → rollback`) — unchanged, next.
2. Batch patch release M-11→M-17 — unchanged.
3. v5.13 chunks 1→5 (chunk 1 doubles as the `knowledge-refresh` dogfood already queued in
DEL B "Resten"; chunk 5 explicitly waits for step 3.2). Release as minor v5.13.0 via
`release-plugin.mjs` when all chunks land.

View file

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

View file

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

View file

@ -136,6 +136,18 @@
"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" }
}
]
}

View file

@ -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,16 +89,33 @@ 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])) {
out.push({
line: i + 1,
label,
snippet: lines[i].length > 120 ? lines[i].slice(0, 117) + '...' : lines[i],
});
break;
}
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: line.length > 120 ? line.slice(0, 117) + '...' : line,
});
break;
}
}
return out;
@ -75,40 +132,90 @@ 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 130) —
// CPS' value is in the 31150 range. Pattern A handles 130.
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,
severity: SEVERITY.medium,
title: 'Volatile content inside cached prefix breaks reuse',
description:
`${f.relPath || f.absPath} contains ${beyondTopThirty.length} volatile ` +
`entr${beyondTopThirty.length === 1 ? 'y' : 'ies'} between lines 31 and ` +
`${CACHED_PREFIX_LINES}. The prompt cache covers the file's prefix; ` +
'any volatility forces a fresh cache write from that line down on every turn.',
file: f.absPath,
evidence,
recommendation:
'Move volatile sections (timestamps, !shell-exec, ${VAR} substitutions, dated logs) ' +
`below line ${CACHED_PREFIX_LINES} or extract them to an @import-ed file outside the ` +
'cached prefix. Stable content above, volatile content below.',
category: 'token-efficiency',
}));
}
const evidence =
beyondTopThirty.slice(0, 5)
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
.join('; ');
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Volatile content inside cached prefix breaks reuse',
description:
`${f.relPath || f.absPath} contains ${beyondTopThirty.length} volatile ` +
`entr${beyondTopThirty.length === 1 ? 'y' : 'ies'} between lines 31 and ` +
`${CACHED_PREFIX_LINES}. The prompt cache covers the file's prefix; ` +
'any volatility forces a fresh cache write from that line down on every turn.',
file: f.absPath,
evidence,
recommendation:
'Move volatile sections (timestamps, !shell-exec, ${VAR} substitutions, dated logs) ' +
`below line ${CACHED_PREFIX_LINES} or extract them to an @import-ed file outside the ` +
'cached prefix. Stable content above, volatile content below.',
category: 'token-efficiency',
}));
// --- 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-130 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,
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);

View file

@ -9,13 +9,18 @@ import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
import { lineCount, truncate } from './lib/string-utils.mjs';
import { 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 } from 'node:path';
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
@ -39,10 +44,20 @@ 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({
@ -122,17 +137,35 @@ 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) {
findings.push(finding({
scanner: SCANNER,
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.',
autoFixable: false,
}));
if (chars > charThreshold) {
if (isDefaultWindow) {
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
findings.push(finding({
scanner: SCANNER,
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: 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,
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 ---

View file

@ -5,6 +5,7 @@
* Finding IDs: CA-CNF-NNN
*/
import { sep } from 'node:path';
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
@ -17,6 +18,22 @@ const SCANNER = 'CNF';
// Keys checked separately or not meaningful to compare
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
// Files under `.claude/plugins/` are shipped by installed plugins — the plugin's
// own settings.json/hooks.json plus bundled test fixtures and examples. They are
// not the user's authored cascade and a "conflict" between them is not something
// the user can resolve, so they must be excluded from cross-scope conflict
// analysis. (Other scanners still need active plugin config, so this exclusion is
// CNF-local, not a discovery-level skip. M-BUG-2.)
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
/**
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
* @returns {boolean} true if the file is shipped by an installed plugin
*/
function isPluginBundled(file) {
return file.absPath.includes(PLUGIN_TREE_MARKER);
}
/**
* Flatten an object's top-level keys into a simple keyvalue map.
* Only first level we compare top-level settings, not nested.
@ -63,10 +80,10 @@ export async function scan(targetPath, discovery) {
const start = Date.now();
const findings = [];
// Collect settings files
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
// Collect hooks files
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
// Collect settings files (excluding plugin-bundled — see PLUGIN_TREE_MARKER)
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json' && !isPluginBundled(f));
// Collect hooks files (excluding plugin-bundled)
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json' && !isPluginBundled(f));
const totalFiles = settingsFiles.length + hooksFiles.length;

View file

@ -5,17 +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 { writeFile } from 'node:fs/promises';
import { runAllScanners } from './scan-orchestrator.mjs';
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
const BOOL_FLAGS = ['--save', '--list', '--json', '--raw', '--global'];
const VALUE_FLAGS = ['--name', '--baseline', '--output-file'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
@ -25,24 +30,39 @@ 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;
}
}
@ -106,6 +126,26 @@ async function main() {
process.exit(1);
}
// 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
const current = await runAllScanners(targetPath, {
includeGlobal,
@ -115,22 +155,39 @@ async function main() {
// Diff
const diff = diffEnvelopes(baseline, current);
// Default mode: humanize finding-bearing diff fields before report rendering.
// `_baselineAnchor` rides here and NOT in the raw shape: commands/drift.md runs
// the CLI under `2>/dev/null`, so the stderr warning above never reaches the
// caller that has to act on it. --json/--raw stay v5.0.0-shaped.
const humanizedDiff = {
...diff,
_baselineAnchor: { matches: anchorMatches, baselineTarget, currentTarget },
newFindings: humanizeFindings(diff.newFindings || []),
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
movedFindings: humanizeFindings(diff.movedFindings || []),
};
if (jsonMode || rawMode) {
// --json and --raw both write the raw v5.0.0-shape diff (byte-identical).
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
} else {
// Default mode: humanize finding-bearing diff fields before report rendering.
const humanizedDiff = {
...diff,
newFindings: humanizeFindings(diff.newFindings || []),
resolvedFindings: humanizeFindings(diff.resolvedFindings || []),
unchangedFindings: humanizeFindings(diff.unchangedFindings || []),
movedFindings: humanizeFindings(diff.movedFindings || []),
};
const report = formatDiffReport(humanizedDiff);
process.stderr.write('\n' + report + '\n');
}
// ux-rules rule 2: every scanner Bash call uses `--output-file <path>` and the
// command reads the file with the Read tool. drift-cli had no such flag, and
// its default-mode report goes to stderr — which commands/drift.md discarded
// via `2>/dev/null` while instructing the agent to "read stdout". The command
// captured nothing. Matches posture.mjs: raw diff in --json/--raw, humanized
// otherwise; stdout is unaffected.
if (outputFile) {
const fileDiff = (jsonMode || rawMode) ? diff : humanizedDiff;
await writeFile(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);

View file

@ -7,12 +7,14 @@
* 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 +46,68 @@ function isTargetLocal(ctx, f) {
return f.absPath.startsWith(ctx.targetPath);
}
// Files that are test/demo/vendored config — NOT part of the user's authored
// cascade — must not satisfy "is feature X present?" checks, or they mask real
// gaps. The canonical case: this plugin's own examples/optimal-setup sets
// outputStyle/statusLine/worktree/model/keybindings/.lsp.json, and (because GAP
// always runs includeGlobal) its copies vendored under ~/.claude/plugins/cache
// drive every tier-3 presence check to "present" — hiding the user's real gaps
// on ANY target. Two classes to exclude:
// - plugin-bundled: anything under ~/.claude/plugins/ (absPath marker, mirrors
// the CNF conflict-detector exclusion from M-BUG-2).
// - nested demo/test data: a file whose path RELATIVE TO THE SCAN TARGET sits
// under an examples/ or tests/fixtures/ subtree. relPath (not absPath) is
// deliberate: a fixture scanned AS the target keeps its own files, so the
// frozen v5.0.0 byte-snapshots (scanned from tests/fixtures/marketplace-medium)
// are untouched. (M-BUG-13)
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
/**
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
* @returns {boolean} true if the file is part of the user's authored config
*/
function isAuthoredConfig(file) {
if (file.absPath.includes(PLUGIN_TREE_MARKER)) return false;
const segs = (file.relPath || '').split(sep);
if (segs.includes('examples')) return false;
const ti = segs.indexOf('tests');
if (ti !== -1 && segs[ti + 1] === 'fixtures') return false;
return true;
}
/**
* Read the userprojectlocal settings cascade directly from the filesystem.
* The settings-key gap checks ask "does the USER's resolved config set X?" a
* question the includeGlobal discovery answers unreliably on a real machine: the
* top-level ~/.claude/settings.json is missed (its relPath carries no `.claude`
* segment when the walk root IS ~/.claude) and, when many vendored plugins flood
* the walk, dropped by the discovery file cap. Reading the canonical cascade
* paths directly is immune to both. Merged INTO (not replacing) the discovery
* settings so any non-canonical project settings still count and the frozen
* snapshots stay byte-stable. (M-BUG-13)
* @param {string} targetPath
* @returns {Promise<Array<{ key: string, parsed: object }>>}
*/
async function readSettingsCascade(targetPath) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const paths = [];
if (home) {
paths.push(['user', join(home, '.claude', 'settings.json')]);
paths.push(['user-local', join(home, '.claude', 'settings.local.json')]);
}
paths.push(['project', join(targetPath, '.claude', 'settings.json')]);
paths.push(['local', join(targetPath, '.claude', 'settings.local.json')]);
const out = [];
for (const [scope, p] of paths) {
const content = await readTextFile(p);
if (!content) continue;
const parsed = parseJson(content);
if (parsed && typeof parsed === 'object') out.push({ key: `cascade:${scope}:${p}`, parsed });
}
return out;
}
const TIER_SEVERITY = {
t1: SEVERITY.medium,
t2: SEVERITY.low,
@ -134,6 +198,81 @@ 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,
title: 'Prefer CLI over MCP for common operations',
description:
`Your active project MCP tool schemas (~${assessment.aggregateTokens} tokens) are forced into the ` +
'always-loaded prefix every turn rather than deferred (see CA-TOK-006). CLI tools (gh, aws, gcloud, …) ' +
'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,
title: 'Filter hook output before it enters context',
description:
`${hooks.length} active hook${hooks.length === 1 ? '' : 's'} build hookSpecificOutput.additionalContext ` +
"from un-grepped command output (see HKV advisory). That field enters Claude's context on every fire, " +
'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',
});
}
/** @type {GapCheck[]} */
const GAP_CHECKS = [
// --- Tier 1: Foundation ---
@ -402,18 +541,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(),
@ -441,6 +592,19 @@ 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);
const filesScanned = discovery.files.length;
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}

View file

@ -8,6 +8,7 @@ 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';
@ -248,6 +249,35 @@ 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,
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,
}));
}
}
}
}

View file

@ -53,6 +53,76 @@ export function estimateTokens(bytes, kind = 'markdown', opts = {}) {
return Math.ceil(bytes / 4);
}
/**
* Strip block-level HTML comments (`<!-- ... -->`) that lie OUTSIDE fenced code
* blocks. Claude Code strips these before injecting a CLAUDE.md / memory file
* into context (code.claude.com/docs/en/memory: "block-level HTML comments are
* stripped before the content is injected"), preserving them only inside fenced
* code blocks (``` / ~~~). A byte-accurate token estimate must therefore discount
* them. (M-BUG-6)
*
* Conservative scope only *block-level* comments are removed (a comment that
* occupies its own line(s)); inline comments sharing a line with other text are
* retained, since the verified CC behavior covers block-level stripping only.
*
* @param {string} content
* @returns {string} content with out-of-fence block comments removed
*/
export function stripInjectedHtmlComments(content) {
if (typeof content !== 'string' || content === '') return '';
const lines = content.split('\n');
const out = [];
let inFence = false;
let inComment = false;
for (const line of lines) {
if (inComment) {
// Inside a multi-line block comment: drop lines until the closing `-->`,
// keeping any real content that trails the close on the same line.
const end = line.indexOf('-->');
if (end !== -1) {
inComment = false;
const rest = line.slice(end + 3);
if (rest.trim() !== '') out.push(rest);
}
continue;
}
// Fence delimiters (``` / ~~~) toggle a preserve-verbatim region.
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
out.push(line);
continue;
}
if (inFence) {
out.push(line);
continue;
}
// Whole line is a single self-contained block comment → CC strips it.
if (/^\s*<!--[\s\S]*?-->\s*$/.test(line)) continue;
// Block comment opening with nothing but whitespace before it and no close
// on this line → runs onto following lines.
const openIdx = line.indexOf('<!--');
if (openIdx !== -1 && line.indexOf('-->', openIdx) === -1 && line.slice(0, openIdx).trim() === '') {
inComment = true;
continue;
}
out.push(line);
}
return out.join('\n');
}
/**
* Effective injected byte length of a CLAUDE.md / memory source: raw UTF-8 bytes
* minus the block-level HTML comments CC strips before injection. Used wherever a
* CLAUDE.md token estimate must reflect what actually enters context. (M-BUG-6)
*
* @param {string} content
* @returns {number}
*/
export function effectiveMemoryBytes(content) {
if (typeof content !== 'string') return 0;
return Buffer.byteLength(stripInjectedHtmlComments(content), 'utf8');
}
// ─────────────────────────────────────────────────────────────────────────
// Load-pattern model (v5.6 Foundation)
// ─────────────────────────────────────────────────────────────────────────
@ -202,7 +272,11 @@ export async function walkClaudeMdCascade(repoPath) {
const totalBytes = files.reduce((sum, f) => sum + f.bytes, 0);
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
const estimatedTokens = estimateTokens(totalBytes, 'markdown');
// Token estimate is computed from the *effective* (injected) byte count — CC
// strips block-level HTML comments before injection — while totalBytes stays
// the honest on-disk figure. (M-BUG-6)
const effectiveBytes = files.reduce((sum, f) => sum + (f.effectiveBytes ?? f.bytes), 0);
const estimatedTokens = estimateTokens(effectiveBytes, 'markdown');
return { files, totalBytes, totalLines, estimatedTokens };
}
@ -217,6 +291,7 @@ async function tryAddClaudeMd(absPath, scope, parent, files, seen) {
path: absPath,
scope,
bytes: s.size,
effectiveBytes: effectiveMemoryBytes(content),
lines: lineCount(content),
parent,
};
@ -327,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) {
@ -471,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 });
@ -561,6 +743,11 @@ export async function enumerateSkills(pluginList = []) {
// Rules, agents, output styles (v5.6 Foundation enumeration)
// ─────────────────────────────────────────────────────────────────────────
/** True when `v` is a non-empty, non-whitespace string (a usable frontmatter field). */
function hasText(v) {
return typeof v === 'string' && v.trim().length > 0;
}
/**
* Build the project/user/plugin directory list for a per-kind enumerator.
* Project + user dirs live under `.claude/<dir>`; plugins under each of the
@ -568,8 +755,16 @@ export async function enumerateSkills(pluginList = []) {
*/
function configDirs(repoPath, pluginList, subdir, pluginSubdirs = [subdir]) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const dirs = [{ dir: join(repoPath, '.claude', subdir), source: 'project', pluginName: null }];
if (home) dirs.push({ dir: join(home, '.claude', subdir), source: 'user', pluginName: null });
const projectDir = join(repoPath, '.claude', subdir);
const userDir = home ? join(home, '.claude', subdir) : null;
const dirs = [];
// M-BUG-4: when repoPath === $HOME (the `manifest --global` self-scan), the
// project dir resolves to the same path as the user dir. Count it once, as
// user scope, instead of enumerating the same directory twice.
if (!(userDir && userDir === projectDir)) {
dirs.push({ dir: projectDir, source: 'project', pluginName: null });
}
if (userDir) dirs.push({ dir: userDir, source: 'user', pluginName: null });
for (const p of pluginList) {
for (const sub of pluginSubdirs) {
dirs.push({ dir: join(p.path, sub), source: 'plugin', pluginName: p.name });
@ -629,8 +824,17 @@ export async function enumerateAgents(repoPath, pluginList = []) {
const lp = deriveLoadPattern('agent');
const dirs = configDirs(repoPath, pluginList, 'agents');
for (const { dir, source, pluginName } of dirs) {
const files = await listMarkdownFiles(dir);
const files = await listMarkdownFiles(dir, true); // M-BUG-3: CC scans agents dirs recursively
for (const f of files) {
// M-BUG-5: CC registers a subagent only when its frontmatter declares both
// `name` and `description` (docs: identity comes only from `name`; both are
// required). Frontmatter-less / incomplete files are registration no-ops
// that cost zero always-loaded tokens — don't count them as agents.
let frontmatter;
try {
({ frontmatter } = parseFrontmatter(await readFile(f.path, 'utf-8')));
} catch { continue; }
if (!hasText(frontmatter && frontmatter.name) || !hasText(frontmatter && frontmatter.description)) continue;
out.push({
name: basename(f.path).replace(/\.md$/, ''),
source,
@ -788,6 +992,7 @@ export async function readActiveMcpServers(repoPath, claudeJsonSlice = null, plu
toolCount,
toolCountUnknown: detected.toolCountUnknown,
estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }),
alwaysLoad: def?.alwaysLoad === true,
});
}
@ -817,6 +1022,7 @@ async function collectMcpFromFile(path, source, disabled, out, repoPath) {
toolCount,
toolCountUnknown: detected.toolCountUnknown,
estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }),
alwaysLoad: def?.alwaysLoad === true,
});
}
}
@ -1015,7 +1221,7 @@ export async function readActiveConfig(repoPath, opts = {}) {
detectGitRoot(absRepoPath),
walkClaudeMdCascade(absRepoPath),
readClaudeJsonProjectSlice(absRepoPath),
enumeratePlugins(),
enumeratePlugins(absRepoPath),
readSettingsCascade(absRepoPath),
]);

View 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;
}

View file

@ -10,15 +10,29 @@ import { join, basename } from 'node:path';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
const BACKUP_ROOT = join(homedir(), '.config-audit', 'backups');
const MAX_BACKUPS = 10;
/**
* Get the backup root directory path.
*
* Canonical location is `~/.claude/config-audit/backups` the path every
* command, agent and doc uses. `CONFIG_AUDIT_BACKUP_ROOT` overrides it so tests
* never write into the operator's real home.
* @returns {string}
*/
export function getBackupDir() {
return BACKUP_ROOT;
return process.env.CONFIG_AUDIT_BACKUP_ROOT
|| join(homedir(), '.claude', 'config-audit', 'backups');
}
/**
* Get the pre-v2.2.0 backup root. Read-only: nothing writes here any more, but
* backups made before the move must stay listable and restorable.
* @returns {string}
*/
export function getLegacyBackupDir() {
return process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT
|| join(homedir(), '.config-audit', 'backups');
}
/**
@ -63,7 +77,7 @@ export function checksum(content) {
*/
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 });
@ -128,7 +142,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 +150,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 +168,45 @@ export function parseManifest(content) {
}
}
// Parse file entries — implement-flow format. `commands/implement.md` has the
// agent hand-build the backup dir, so real manifests on disk use unquoted
// `- backup:` / `original:` / `sha256:`. Reading only the engine format made
// restoreBackup a success-shaped no-op on every backup implement produced.
if (result.files.length === 0) {
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
for (const block of implBlocks) {
const bpMatch = block.match(/^\s*(\S+)/);
const origMatch = block.match(/original:\s*(\S+)/);
const csMatch = block.match(/sha256:\s*(\S+)/);
if (origMatch && bpMatch && csMatch) {
result.files.push({
originalPath: origMatch[1],
backupPath: bpMatch[1],
checksum: csMatch[1],
sizeBytes: 0,
});
}
}
if (!result.backup_id) {
const implId = content.match(/^created:\s*(\S+)\s*$/m);
if (implId) result.backup_id = implId[1];
}
}
// Files the implement step CREATED. A backup cannot hold a file that did not
// exist, so rollback can never restore these — but it must be able to say so.
const lines = content.split('\n');
const createdAt = lines.findIndex(l => /^created:[ \t]*$/.test(l));
if (createdAt !== -1) {
for (const line of lines.slice(createdAt + 1)) {
const item = line.match(/^[ \t]+-[ \t]+(\S+)[ \t]*$/);
if (!item) break;
result.created.push(item[1]);
}
}
return result;
}
@ -161,9 +214,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 +225,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 });
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -96,10 +96,10 @@ export const TRANSLATIONS = {
// ─────────────────────────────────────────────────────────────
SET: {
static: {
'Unknown settings key': {
title: 'A settings key isn\'t recognized',
description: 'A key in your settings file isn\'t one Claude Code understands. It will be ignored.',
recommendation: 'Check the key name for typos, or remove the key if it\'s no longer in use.',
'Possible typo in settings key': {
title: 'A settings key looks like a typo',
description: 'A key in your settings file isn\'t recognized, but it\'s very close to a real one — likely a typo. Claude Code forwards unrecognized keys unchanged rather than rejecting them, so a misspelled key silently has no effect.',
recommendation: 'Check the suggested key name. Fix the spelling, or keep the key if it\'s intentional (e.g. a newer key this audit doesn\'t know yet).',
},
'Deprecated settings key': {
title: 'A settings key is no longer supported',
@ -435,12 +435,12 @@ export const TRANSLATIONS = {
recommendation: 'Consider moving team-wide settings to project scope and keeping personal ones at user or local scope.',
},
'CLAUDE.md not modular': {
title: 'Your instructions file is one big block',
description: 'Splitting long instructions into smaller linked files makes them easier to maintain and easier on the loading time.',
title: 'Your instructions all live in one file',
description: 'Splitting your instructions into smaller linked files with `@import` or `.claude/rules/` keeps each part focused and easier to maintain.',
recommendation: 'Break out long sections into separate files and link them with `@import`.',
},
'No path-scoped rules': {
title: 'Your rules all load on every conversation',
title: 'You haven\'t set up path-scoped rules yet',
description: 'Path-scoped rules only load when you\'re working with files that match — keeps each conversation focused.',
recommendation: 'Add scoping to your rules so they only load for the files they apply to.',
},
@ -490,7 +490,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.',
},
@ -817,6 +817,11 @@ export const TRANSLATIONS = {
description: 'Claude Code keeps every active skill\'s description in one shared listing it reads to choose which skill to use, and that listing has a limited size. Added up, your skills\' descriptions run past that size on a smaller setup, so Claude Code may drop some of them — and stop seeing those skills. This is an estimate; a larger setup has more room.',
recommendation: 'Free up room: turn off bundled skills you do not use, collapse the heaviest ones so only their names show, or shorten the longest descriptions. The details show the measured total and the room available.',
},
'Skill body is large (loads on demand when the skill runs)': {
title: 'A skill\'s body is large (it loads only when that skill runs)',
description: 'This skill\'s instructions run longer than the rough guidance for a skill body. The body is not part of the always-loaded listing Claude reads every turn — it loads only when you invoke the skill, so it costs nothing until then. Once it loads, though, it stays in context for the rest of that session.',
recommendation: 'Move reference material into supporting files the skill opens only when needed, so the body stays lean. For a heavy skill you can also run its body in a separate context with `context: fork` in the skill\'s settings.',
},
},
patterns: [],
_default: {

View file

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

View file

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

View file

@ -48,6 +48,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 +77,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 +107,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 +118,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 +131,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 };
}

View file

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

View file

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

View file

@ -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}

View file

@ -24,15 +24,25 @@
* Exit codes: 0=ok, 3=unrecoverable error. Zero external dependencies.
*/
import { resolve } from 'node:path';
import { resolve, sep } from 'node:path';
import { writeFile, readFile, stat } from 'node:fs/promises';
import { discoverConfigFiles } from './lib/file-discovery.mjs';
import { resetCounter } from './lib/output.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
import { subtractionCandidates, SUBTRACT_DETECTORS } from './lib/subtraction-prefilter.mjs';
import { scan as optScan } from './optimization-lens-scanner.mjs';
// 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);
@ -44,9 +54,11 @@ async function main() {
let targetPath = '.';
let outputFile = null;
let includeGlobal = false;
let subtract = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--global') includeGlobal = true;
else if (args[i] === '--subtract') subtract = true;
else if (args[i] === '--output-file' && args[i + 1]) outputFile = args[++i];
else if (!args[i].startsWith('-')) targetPath = args[i];
}
@ -72,7 +84,13 @@ async function main() {
}
resetCounter();
const discovery = await discoverConfigFiles(absPath, { includeGlobal });
const rawDiscovery = await discoverConfigFiles(absPath, { includeGlobal });
// Scope the lens to the user's authored config: drop plugin-bundled files for
// BOTH halves of the motor (the OPT scanner reads discovery.files directly).
const discovery = {
...rawDiscovery,
files: (rawDiscovery.files || []).filter((f) => !isPluginBundled(f)),
};
// ── Deterministic half: the OPT scanner (CA-OPT-001) ──
const opt = await optScan(absPath, discovery);
@ -80,6 +98,11 @@ async function main() {
// ── Recall half: prose-judgment candidates from the pre-filter ──
const claudeMdFiles = (discovery.files || []).filter((f) => f.type === 'claude-md');
const candidates = [];
// Opt-in only: the subtraction axis asks a different question and must not
// fire on a plain `/config-audit optimize` run (brief §7 q3).
const subtractCands = [];
const subtractEntry = subtract && register ? confirmedEntry(register, 'BP-SUB-001') : null;
for (const file of claudeMdFiles) {
let content;
try {
@ -90,11 +113,37 @@ async function main() {
const parsed = parseFrontmatter(content);
const body = parsed.body || content;
const bodyStartLine = parsed.bodyStartLine || 1;
if (subtractEntry) {
for (const cand of subtractionCandidates(body)) {
subtractCands.push({
file: file.absPath,
line: bodyStartLine - 1 + cand.startLine,
endLine: bodyStartLine - 1 + cand.endLine,
lineCount: cand.lineCount,
lensCheck: cand.lensCheck,
mechanism: cand.mechanism,
signalText: cand.text,
register: {
id: subtractEntry.id,
claim: subtractEntry.claim,
recommendation: subtractEntry.recommendation || null,
severity: subtractEntry.severity || 'low',
source: subtractEntry.source,
},
});
}
}
for (const cand of prefilterClaudeMd(body)) {
const entry = register ? confirmedEntry(register, cand.registerId) : null;
if (!entry) continue; // never surface an unverifiable recommendation
candidates.push({
file: file.relPath || file.absPath,
// Absolute path: unique + readable. relPath collides across scopes
// (a repo-root `CLAUDE.md` and the user-global `~/.claude/CLAUDE.md`
// both relPath to `CLAUDE.md`), which would send the agent's Read() to
// the wrong file. (M-BUG-11)
file: file.absPath,
line: bodyStartLine - 1 + cand.line,
lensCheck: cand.lensCheck,
mechanism: cand.mechanism,
@ -142,6 +191,29 @@ async function main() {
},
};
// Additive ONLY under --subtract: a plain run's payload must stay byte-identical.
if (subtract) {
payload.subtract = {
enabled: true,
candidates: subtractCands,
register: subtractEntry
? [
{
id: subtractEntry.id,
lensCheck: subtractEntry.lensCheck,
claim: subtractEntry.claim,
recommendation: subtractEntry.recommendation || null,
mechanism: subtractEntry.mechanism || null,
severity: subtractEntry.severity || 'low',
source: subtractEntry.source,
},
]
: [],
detectors: SUBTRACT_DETECTORS.map((d) => ({ ...d })),
};
payload.counts.subtractCandidates = subtractCands.length;
}
const json = JSON.stringify(payload, null, 2);
if (outputFile) {
await writeFile(outputFile, json, 'utf-8');

View file

@ -10,6 +10,7 @@
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { runAllScanners } from './scan-orchestrator.mjs';
import { humanizeEnvelope } from './lib/humanizer.mjs';
import {
calculateUtilization,
determineMaturityLevel,
@ -63,10 +64,13 @@ async function main() {
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') {
@ -89,6 +93,7 @@ async function main() {
fullMachine,
filterFixtures,
humanizedProgress,
contextWindow,
});
// stdout JSON path: --json and --raw both write the v5.0.0-shape result
@ -110,7 +115,14 @@ async function main() {
}
if (outputFile) {
const json = JSON.stringify(result, null, 2);
// 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 writeFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}

View file

@ -6,47 +6,72 @@
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getBackupDir, parseManifest, checksum } from './lib/backup.mjs';
import { getBackupDir, getLegacyBackupDir, parseManifest, checksum } from './lib/backup.mjs';
/**
* Resolve a backup id to its directory, canonical root first, then the
* pre-v2.2.0 root. Returns null when the id exists in neither.
* @param {string} backupId
* @returns {Promise<{ path: string, legacy: boolean } | null>}
*/
async function resolveBackupPath(backupId) {
for (const [root, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
const candidate = join(root, backupId);
try {
await stat(join(candidate, 'manifest.yaml'));
return { path: candidate, legacy };
} catch {
// try the next root
}
}
return null;
}
/**
* List all available backups.
* @returns {Promise<{ backups: object[] }>}
*/
export async function listBackups() {
const backupRoot = getBackupDir();
const backups = [];
const seen = new Set();
let entries;
try {
entries = await readdir(backupRoot, { withFileTypes: true });
} catch {
return { backups: [] };
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
// Canonical root first; a legacy backup with the same id must not shadow it.
for (const [backupRoot, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
let entries;
try {
const manifestContent = await readFile(manifestPath, 'utf-8');
const manifest = parseManifest(manifestContent);
backups.push({
id: entry.name,
createdAt: manifest.created_at,
files: manifest.files.map(f => ({
originalPath: f.originalPath,
backupPath: f.backupPath,
checksum: f.checksum,
sizeBytes: f.sizeBytes,
})),
});
entries = await readdir(backupRoot, { withFileTypes: true });
} catch {
// Skip backups without valid manifest
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || seen.has(entry.name)) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
try {
const manifestContent = await readFile(manifestPath, 'utf-8');
const manifest = parseManifest(manifestContent);
seen.add(entry.name);
backups.push({
id: entry.name,
createdAt: manifest.created_at,
legacy,
files: manifest.files.map(f => ({
originalPath: f.originalPath,
backupPath: f.backupPath,
checksum: f.checksum,
sizeBytes: f.sizeBytes,
})),
created: manifest.created,
});
} catch {
// Skip backups without valid manifest
continue;
}
}
}
// Sort newest first
@ -65,22 +90,22 @@ export async function listBackups() {
*/
export async function restoreBackup(backupId, opts = {}) {
const verify = opts.verify !== false;
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const manifestPath = join(backupPath, 'manifest.yaml');
const resolved = await resolveBackupPath(backupId);
if (!resolved) throw new Error(`Backup not found: ${backupId}`);
// Read manifest
let manifestContent;
try {
manifestContent = await readFile(manifestPath, 'utf-8');
} catch {
throw new Error(`Backup not found: ${backupId}`);
}
const backupPath = resolved.path;
const manifestContent = await readFile(join(backupPath, 'manifest.yaml'), 'utf-8');
const manifest = parseManifest(manifestContent);
const restored = [];
const failed = [];
// A manifest with entries that parsed to nothing would restore nothing while
// reporting success. Fail loudly instead.
if (manifest.files.length === 0 && /^\s+-\s/m.test(manifestContent)) {
throw new Error(`Unreadable manifest for backup ${backupId}: entries present but none parsed`);
}
for (const fileEntry of manifest.files) {
const backupFilePath = join(backupPath, fileEntry.backupPath);
@ -139,7 +164,10 @@ export async function restoreBackup(backupId, opts = {}) {
}
}
return { restored, failed };
// Files implement CREATED are absent from the backup by definition, so they
// survive the restore. Report them — a half-restored target is only dangerous
// when it is also silent.
return { restored, failed, createdNotRemoved: manifest.created, legacy: resolved.legacy };
}
/**
@ -148,17 +176,11 @@ export async function restoreBackup(backupId, opts = {}) {
* @returns {Promise<{ deleted: boolean, error?: string }>}
*/
export async function deleteBackup(backupId) {
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const resolved = await resolveBackupPath(backupId);
if (!resolved) return { deleted: false, error: `Backup not found: ${backupId}` };
try {
await stat(backupPath);
} catch {
return { deleted: false, error: `Backup not found: ${backupId}` };
}
try {
await rm(backupPath, { recursive: true, force: true });
await rm(resolved.path, { recursive: true, force: true });
return { deleted: true };
} catch (err) {
return { deleted: false, error: err.message };

View file

@ -10,7 +10,7 @@ import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { lineCount, truncate } from './lib/string-utils.mjs';
import { readdir, stat } from 'node:fs/promises';
import { join, resolve, relative } from 'node:path';
import { join, resolve, relative, sep } from 'node:path';
const SCANNER = 'RUL';
@ -30,8 +30,16 @@ export async function scan(targetPath, discovery) {
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
}
// Collect all real files in the project for glob matching
const projectFiles = await collectProjectFiles(targetPath);
// Rule path patterns scope relative to the rule's OWN project root (the dir
// containing its .claude/), not the outer scan root. Resolve + cache per root.
const home = process.env.HOME || process.env.USERPROFILE || '';
const projectFilesByRoot = new Map();
async function projectFilesFor(root) {
if (!projectFilesByRoot.has(root)) {
projectFilesByRoot.set(root, await collectProjectFiles(root));
}
return projectFilesByRoot.get(root);
}
for (const file of ruleFiles) {
const content = await readTextFile(file.absPath);
@ -74,22 +82,32 @@ export async function scan(targetPath, discovery) {
if (paths) {
const patterns = Array.isArray(paths) ? paths : [paths];
for (const pattern of patterns) {
if (typeof pattern !== 'string') continue;
// A rule scopes relative to its own project root (parent of its .claude/),
// not the scan root. User-global rules (root === HOME) match against the
// active project at runtime, so "matches no files here" is not meaningful.
const projectRoot = deriveProjectRoot(file.absPath) || targetPath;
const isUserGlobal = home && projectRoot === home;
// Check if pattern matches any real files
const matchCount = countGlobMatches(pattern, projectFiles, targetPath);
if (matchCount === 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Rule path pattern matches no files',
description: `${file.relPath}: pattern "${pattern}" matches 0 files. This rule will never activate.`,
file: file.absPath,
evidence: `paths: "${pattern}"`,
recommendation: 'Check the glob pattern. Common issues: wrong directory name, missing **, incorrect extension.',
autoFixable: false,
}));
if (!isUserGlobal) {
const projectFiles = await projectFilesFor(projectRoot);
for (const pattern of patterns) {
if (typeof pattern !== 'string') continue;
// Check if pattern matches any real files (relative to the rule's root)
const matchCount = countGlobMatches(pattern, projectFiles, projectRoot);
if (matchCount === 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Rule path pattern matches no files',
description: `${file.relPath}: pattern "${pattern}" matches 0 files. This rule will never activate.`,
file: file.absPath,
evidence: `paths: "${pattern}"`,
recommendation: 'Check the glob pattern. Common issues: wrong directory name, missing **, incorrect extension.',
autoFixable: false,
}));
}
}
}
}
@ -195,6 +213,18 @@ async function collectProjectFiles(targetPath, depth = 0) {
* @param {string} basePath
* @returns {number}
*/
/**
* Resolve the project root a rule scopes against: the directory containing the
* `.claude/` dir the rule lives under. `/a/b/.claude/rules/x.md` `/a/b`.
* Returns null if the path has no `.claude` segment.
*/
function deriveProjectRoot(ruleAbsPath) {
const parts = ruleAbsPath.split(sep);
const idx = parts.lastIndexOf('.claude');
if (idx <= 0) return null;
return parts.slice(0, idx).join(sep);
}
function countGlobMatches(pattern, files, basePath) {
try {
const regex = globToRegex(pattern);
@ -221,9 +251,9 @@ function globToRegex(pattern) {
.replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}')
.replace(/\*\*/g, '{{GLOBSTAR}}')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]') // must run BEFORE placeholder restore — '(?:' would corrupt
.replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs
.replace(/\{\{GLOBSTAR\}\}/g, '.*')
.replace(/\?/g, '[^/]');
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
// Handle leading patterns
if (!regex.startsWith('.*') && !regex.startsWith('/')) {

View file

@ -14,6 +14,8 @@ import { envelope } from './lib/output.mjs';
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
import { humanizeEnvelope } from './lib/humanizer.mjs';
import { resolveContextWindow } from './lib/context-window.mjs';
import { resolveActiveModel } from './lib/active-model.mjs';
// Scanner registry — import order determines execution order
import { scan as scanClaudeMd } from './claude-md-linter.mjs';
@ -94,6 +96,17 @@ export async function runAllScanners(targetPath, opts = {}) {
// and CNF duplicate-hook findings with config that loads on zero turns. (B3)
const excludeCache = opts.excludeCache !== false;
// B8 — resolve the context window once and thread it to budget-aware scanners
// (SKL, CML). Undefined opts.contextWindow → conservative 200k anchor, which is
// byte-identical to the pre-B8 default; other scanners ignore the third arg.
// B8b — `--context-window auto` probes the configured model (settings cascade /
// ANTHROPIC_MODEL) so a 1M-tier host self-calibrates; unknown/unpinned → advisory.
let probedModel = null;
if (String(opts.contextWindow ?? '').trim().toLowerCase() === 'auto') {
probedModel = await resolveActiveModel(resolvedPath, { env: process.env });
}
const contextWindow = resolveContextWindow(opts.contextWindow, { model: probedModel });
// Shared file discovery — scanners reuse this
let discovery;
if (opts.fullMachine) {
@ -112,7 +125,7 @@ export async function runAllScanners(targetPath, opts = {}) {
resetCounter();
const scanStart = Date.now();
try {
const result = await scanner.fn(resolvedPath, discovery);
const result = await scanner.fn(resolvedPath, discovery, { contextWindow });
results.push(result);
const count = result.findings.length;
const label = opts.humanizedProgress
@ -206,10 +219,13 @@ async function main() {
let outputFile = null;
let saveBaseline = false;
let baselinePath = null;
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] === '--save-baseline') {
saveBaseline = true;
} else if (args[i] === '--baseline' && args[i + 1]) {
@ -254,6 +270,7 @@ async function main() {
filterFixtures,
excludeCache,
humanizedProgress,
contextWindow,
});
// Default mode runs the humanizer; --json and --raw bypass for v5.0.0 byte-equal output.

View file

@ -8,11 +8,11 @@ import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseJson } from './lib/yaml-parser.mjs';
import { extractKeys } from './lib/string-utils.mjs';
import { extractKeys, levenshtein } from './lib/string-utils.mjs';
const SCANNER = 'SET';
/** Known top-level settings.json keys (as of CC 2.1.181 / June 2026) */
/** Known top-level settings.json keys (as of CC 2.1.193 / June 2026) */
const KNOWN_KEYS = new Set([
'additionalDirectories',
'agent', 'allowAllClaudeAiMcps', 'allowedChannelPlugins', 'allowedHttpHookUrls',
@ -37,6 +37,9 @@ const KNOWN_KEYS = new Set([
'spinnerTipsOverride', 'spinnerVerbs', 'statusLine', 'strictKnownMarketplaces',
'useAutoModeDuringPlan', 'voiceEnabled', 'wheelScrollAccelerationEnabled',
'worktree', '$schema',
// CC 2.1.193 binary-verified (M-BUG-10): present as quoted string literals in the binary
'agentPushNotifEnabled', 'remoteControlAtStartup', 'skipAutoPermissionPrompt',
'skipDangerousModePermissionPrompt', 'skipWorkflowUsageWarning', 'tui',
]);
/** Deprecated keys with migration info */
@ -75,6 +78,16 @@ const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
* a project root to walks/discovery, inflating per-turn cost and confusing scope. */
const ADDITIONAL_DIRS_THRESHOLD = 2;
/** M-BUG-10: the CC settings schema is passthrough it forwards unrecognized
* keys unchanged rather than rejecting them, so an arbitrary unknown key is
* valid/forward-compatible, not an error. The only real risk is a TYPO of a
* real key (the intended setting silently does nothing), so an unknown key is
* flagged ONLY when it closely matches a known key: edit distance within
* TYPO_MAX_DISTANCE and both keys at least TYPO_MIN_LEN chars (short keys are
* too noisy for reliable edit-distance matching). */
const TYPO_MAX_DISTANCE = 2;
const TYPO_MIN_LEN = 4;
/** The only valid sub-keys of `autoMode`, each a prose-rule string array
* (the literal "$defaults" is a valid entry). Verified against
* code.claude.com/docs/en/auto-mode-config. */
@ -115,17 +128,32 @@ export async function scan(targetPath, discovery) {
continue;
}
// Check for unknown keys
// Check for unknown keys — typo gate (M-BUG-10). The CC settings schema is
// passthrough, so an unrecognized key is NOT an error; only a typo of a real
// key is (the intended setting silently does nothing). Flag a key only when
// it closely matches a known key; an unknown key far from every known key is
// treated as valid/forward-compatible and emitted nothing.
for (const key of Object.keys(parsed)) {
if (!KNOWN_KEYS.has(key)) {
if (KNOWN_KEYS.has(key)) continue;
let nearest = null;
let best = Infinity;
for (const known of KNOWN_KEYS) {
if (Math.min(key.length, known.length) < TYPO_MIN_LEN) continue;
const d = levenshtein(key, known);
if (d <= TYPO_MAX_DISTANCE && d < best) {
best = d;
nearest = known;
}
}
if (nearest) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Unknown settings key',
description: `${file.relPath}: "${key}" is not a recognized settings.json key. It will be silently ignored.`,
severity: SEVERITY.low,
title: 'Possible typo in settings key',
description: `${file.relPath}: "${key}" is not a recognized settings.json key, but it closely matches "${nearest}". Claude Code forwards unrecognized keys unchanged (it does not reject them), so if "${key}" is a typo of "${nearest}" the intended setting silently has no effect.`,
file: file.absPath,
evidence: key,
recommendation: 'Check spelling. See https://json.schemastore.org/claude-code-settings.json for valid keys.',
recommendation: `Did you mean "${nearest}"? Fix the spelling, or keep "${key}" if it is intentional (e.g. a newer settings key this audit does not recognize yet).`,
autoFixable: false,
}));
}

View file

@ -40,8 +40,19 @@ import {
DESCRIPTION_CAP,
AGGREGATE_BUDGET_TOKENS,
BUDGET_CALIBRATION_NOTE,
BODY_TOKEN_THRESHOLD,
BODY_CALIBRATION_NOTE,
measureActiveSkillListing,
} from './lib/skill-listing-budget.mjs';
import { CONTEXT_WINDOW_ANCHOR, scaleForWindow, withCommas } from './lib/context-window.mjs';
// Shared remediation for the aggregate-budget finding (byte-identical across the
// default and the B8 window-calibrated branches).
const AGGREGATE_RECOMMENDATION =
'Reclaim skill-listing budget: set `disableBundledSkills: true` to drop bundled skills you ' +
'do not use from the listing, use `skillOverrides` (`name-only` collapses a description, ' +
'`off` removes a skill) on the heaviest entries, and trim long descriptions toward their ' +
'trigger phrases.';
const SCANNER = 'SKL';
@ -51,11 +62,21 @@ const SCANNER = 'SKL';
* @param {string} _targetPath unused (skill listing is HOME-scoped)
* @param {object} _discovery unused (ignores project discovery)
*/
export async function scan(_targetPath, _discovery) {
export async function scan(_targetPath, _discovery, opts = {}) {
const start = Date.now();
const findings = [];
const { skills, aggregate } = await measureActiveSkillListing();
// B8 — calibrate the aggregate budget to the resolved context window. The
// default (no opts) is the conservative 200k anchor at full severity, which is
// byte-identical to the pre-B8 behavior. An unknown (advisory) window keeps the
// anchor but downgrades the finding to info instead of firing it as a breach.
const cw = opts.contextWindow;
const window = (cw && typeof cw.window === 'number') ? cw.window : CONTEXT_WINDOW_ANCHOR;
const advisory = !!(cw && cw.advisory);
const isDefault = window === CONTEXT_WINDOW_ANCHOR && !advisory;
const budgetTokens = scaleForWindow(AGGREGATE_BUDGET_TOKENS, window);
const { skills, aggregate } = await measureActiveSkillListing(budgetTokens);
for (const skill of skills) {
if (skill.descLength <= DESCRIPTION_CAP) continue;
@ -91,27 +112,83 @@ export async function scan(_targetPath, _discovery) {
// CA-SKL-002 (aggregate). Emitted after the per-skill findings so the common
// "one oversized skill + aggregate" case reads 001=cap, 002=aggregate.
if (aggregate.overBudget) {
if (isDefault) {
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Aggregate skill descriptions may exceed the listing budget',
description:
`The ${aggregate.scanned} active skills carry about ${aggregate.aggregateTokens} tokens of description text ` +
`(each description counted up to the ${DESCRIPTION_CAP}-char listing cap), above the ` +
`${AGGREGATE_BUDGET_TOKENS}-token budget Claude Code allots the skill listing on a 200k ` +
'context window (about 2% of context, CC 2.1.32). When the listing overflows that budget ' +
'Claude Code drops descriptions, so the model may stop seeing some skills entirely. This ' +
'is an estimate — the budget scales with your actual context window (see evidence).',
evidence:
`active_skills_scanned=${aggregate.scanned}; description_chars=${aggregate.aggregateChars} (each capped at ` +
`${DESCRIPTION_CAP}); description_tokens~${aggregate.aggregateTokens}; budget@200k=` +
`${AGGREGATE_BUDGET_TOKENS} tok (skill listing ~2% of context, CC 2.1.32); over_by~` +
`${aggregate.overBy} tok - ${BUDGET_CALIBRATION_NOTE}`,
recommendation: AGGREGATE_RECOMMENDATION,
category: 'token-efficiency',
}));
} else {
// B8 — window-calibrated. Advisory (unknown window) downgrades to info.
const winLabel = withCommas(window);
findings.push(finding({
scanner: SCANNER,
severity: advisory ? SEVERITY.info : SEVERITY.low,
title: 'Aggregate skill descriptions may exceed the listing budget',
description:
`The ${aggregate.scanned} active skills carry about ${aggregate.aggregateTokens} tokens of description text ` +
`(each description counted up to the ${DESCRIPTION_CAP}-char listing cap), above the ` +
`${budgetTokens}-token budget Claude Code allots the skill listing at a ${winLabel}-token ` +
'context window (about 2% of context, CC 2.1.32). When the listing overflows that budget ' +
'Claude Code drops descriptions, so the model may stop seeing some skills entirely.' +
(advisory
? ' Your context window is unknown, so this is advisory: it anchors on the conservative 200k window.'
: ''),
evidence:
`active_skills_scanned=${aggregate.scanned}; description_chars=${aggregate.aggregateChars} (each capped at ` +
`${DESCRIPTION_CAP}); description_tokens~${aggregate.aggregateTokens}; budget@${winLabel}=` +
`${budgetTokens} tok (skill listing ~2% of context, CC 2.1.32); over_by~${aggregate.overBy} tok` +
(advisory ? ` - ${BUDGET_CALIBRATION_NOTE}` : ' - this is an estimate, not measured telemetry'),
recommendation: AGGREGATE_RECOMMENDATION,
category: 'token-efficiency',
}));
}
}
// CA-SKL-003 (oversized body). Emitted last so the common single-issue cases
// read cleanly. Unlike the listing budget, this is an ON-DEMAND cost — the body
// loads only when the skill is invoked, not every turn — hence low severity and
// an explicit on-demand calibration note.
for (const skill of skills) {
if (skill.bodyTokens <= BODY_TOKEN_THRESHOLD) continue;
const sourceLabel = skill.source === 'plugin'
? `plugin:${skill.pluginName}`
: 'user';
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Aggregate skill descriptions may exceed the listing budget',
title: 'Skill body is large (loads on demand when the skill runs)',
description:
`The ${aggregate.scanned} active skills carry about ${aggregate.aggregateTokens} tokens of description text ` +
`(each description counted up to the ${DESCRIPTION_CAP}-char listing cap), above the ` +
`${AGGREGATE_BUDGET_TOKENS}-token budget Claude Code allots the skill listing on a 200k ` +
'context window (about 2% of context, CC 2.1.32). When the listing overflows that budget ' +
'Claude Code drops descriptions, so the model may stop seeing some skills entirely. This ' +
'is an estimate — the budget scales with your actual context window (see evidence).',
`Skill "${skill.name}" (${sourceLabel}) has a body of about ${skill.bodyTokens} tokens ` +
`(${skill.bodyLines} lines), over the ~${BODY_TOKEN_THRESHOLD}-token guidance for a skill body. ` +
'The body is not in the always-loaded listing — it loads only when the skill is invoked — but ' +
'once loaded a large body consumes context for the rest of that session. Claude Code skill ' +
'guidance is to keep the body lean and move heavy reference material into supporting files.',
file: skill.path,
evidence:
`active_skills_scanned=${aggregate.scanned}; description_chars=${aggregate.aggregateChars} (each capped at ` +
`${DESCRIPTION_CAP}); description_tokens~${aggregate.aggregateTokens}; budget@200k=` +
`${AGGREGATE_BUDGET_TOKENS} tok (skill listing ~2% of context, CC 2.1.32); over_by~` +
`${aggregate.overBy} tok - ${BUDGET_CALIBRATION_NOTE}`,
`body_tokens~${skill.bodyTokens}; body_lines=${skill.bodyLines}; body_chars=${skill.bodyChars}; ` +
`threshold=${BODY_TOKEN_THRESHOLD} tok; skill="${skill.name}"; source=${sourceLabel} - ${BODY_CALIBRATION_NOTE}`,
recommendation:
'Reclaim skill-listing budget: set `disableBundledSkills: true` to drop bundled skills you ' +
'do not use from the listing, use `skillOverrides` (`name-only` collapses a description, ' +
'`off` removes a skill) on the heaviest entries, and trim long descriptions toward their ' +
'trigger phrases.',
'Move reference content into supporting files the skill loads only when needed, and consider ' +
'`context: fork` in the skill frontmatter for heavy skills so the body runs in a forked context ' +
'instead of consuming the main thread.',
category: 'token-efficiency',
}));
}

View file

@ -22,12 +22,17 @@
*/
import { resolve, dirname, isAbsolute } from 'node:path';
import { stat } from 'node:fs/promises';
import { stat, readFile } from 'node:fs/promises';
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { estimateTokens, readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
import { estimateTokens, effectiveMemoryBytes, readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
import {
assessMcpDeferralForRepo,
severityForForcedSchemas,
DEFERRAL_DISCLOSURE,
} from './lib/mcp-deferral.mjs';
const SCANNER = 'TOK';
@ -256,6 +261,26 @@ function detectRedundantPermissions(settings) {
return issues;
}
/**
* Byte count to feed the token estimator for a discovered file. CLAUDE.md /
* memory files are sized from their *effective* (injected) content CC strips
* block-level HTML comments before injection so a raw byte read over-counts
* them. Every other source uses the raw on-disk size. (M-BUG-6)
*
* @param {{type:string, absPath?:string, size:number}} f
* @returns {Promise<number>}
*/
async function tokenBytesFor(f) {
if (f.type === 'claude-md' && f.absPath) {
try {
return effectiveMemoryBytes(await readFile(f.absPath, 'utf-8'));
} catch {
return f.size;
}
}
return f.size;
}
/**
* Build the ranked hotspots array.
*
@ -267,7 +292,7 @@ async function buildHotspots(discovery, targetPath, activeConfig) {
const ranked = [];
for (const f of discovery.files) {
const kind = tokenKind(f.type);
const tokens = estimateTokens(f.size, kind);
const tokens = estimateTokens(await tokenBytesFor(f), kind);
if (tokens <= 0) continue;
ranked.push({
absPath: f.absPath,
@ -574,19 +599,79 @@ export async function scan(targetPath, discovery) {
'(installed_plugins.json points at newer versions)',
recommendation:
'Delete the listed stale version directories under ~/.claude/plugins/cache to reclaim ' +
'disk (reinstall/prune via the plugin manager, or remove the dirs directly). Re-run ' +
'with --no-exclude-cache to include cached versions in the token/conflict scan.',
'disk (reinstall/prune via the plugin manager, or remove the dirs directly). ' +
'Caution: do NOT delete a version a running session is still using — "stale" is judged ' +
'against installed_plugins.json (what NEW sessions load), but an already-running session ' +
'can hold an older version for its whole lifetime. Removing it mid-session pulls the files ' +
'out from under that session, which then breaks and must /exit + restart to pick up the ' +
'active version. Run with --no-exclude-cache to include cached versions in the ' +
'token/conflict scan.',
category: 'plugin-cache-hygiene',
}));
}
// ── Pattern I: MCP tool-schema deferral (v5.10 B4, CA-TOK-006) ──
// By default MCP tool schemas are DEFERRED (names-only, ~120 tok); certain
// config signals force the FULL schemas into the always-loaded prefix every
// turn. Scope: project-local .mcp.json servers (mirrors Pattern G — plugin /
// global servers are the manifest's concern). Static config-file check only;
// runtime conditions (Vertex / ANTHROPIC_BASE_URL / runtime /model switch) are
// DISCLOSED (DEFERRAL_DISCLOSURE), not triggered, so the finding is deterministic.
const mcpForDeferral = (activeConfig && Array.isArray(activeConfig.mcpServers))
? activeConfig.mcpServers.filter(m => m && m.enabled && m.source === '.mcp.json')
: [];
if (mcpForDeferral.length > 0) {
const a = await assessMcpDeferralForRepo(targetPath, { mcpServers: activeConfig.mcpServers });
if (a.forcedUpfront) {
const conf = a.confidence || 'high';
const severity = severityForForcedSchemas(a.aggregateTokens, conf);
const names = a.affectedServers.map(m => m.name).join(', ');
const reasonText = {
'enable-tool-search-false': 'ENABLE_TOOL_SEARCH is set to "false" in settings',
'deny-tool-search': '"ToolSearch" is listed in permissions.deny',
'haiku-model': 'the configured model is a Haiku model (Haiku has no tool-search support)',
}[a.reason];
const description = a.toolSearchDisabled
? `Tool search is disabled (${reasonText}), so the full tool schemas of ` +
`${a.affectedServers.length} active project MCP server${a.affectedServers.length === 1 ? '' : 's'} ` +
`(~${a.aggregateTokens} tokens) load into the always-loaded prefix on every turn instead of ` +
'being deferred (tool names only, ~120 tokens total). Every schema token is re-sent each turn ' +
'whether or not a tool is used.'
: `${a.alwaysLoadServers.length} project MCP server${a.alwaysLoadServers.length === 1 ? '' : 's'} ` +
`marked alwaysLoad (${names}) load their full tool schemas (~${a.aggregateTokens} tokens) into ` +
'the always-loaded prefix on every turn regardless of tool search, instead of deferring them ' +
'(names only, ~120 tokens).';
const evidence =
`reason=${a.reason || 'alwaysLoad'}; confidence=${conf}; servers=${names}; ` +
`forced_schema_tokens~${a.aggregateTokens}${CALIBRATION_NOTE}. ${DEFERRAL_DISCLOSURE}`;
const recommendation = a.toolSearchDisabled
? 'Re-enable tool search so MCP schemas defer (names-only) by default: remove ' +
'ENABLE_TOOL_SEARCH="false" / the "ToolSearch" deny, or stop defaulting to a Haiku model. ' +
'Also disable unused servers via /mcp, and prefer CLI tools (gh / aws / gcloud) over MCP for ' +
'common operations — CLI adds zero context tokens until used.'
: 'Drop alwaysLoad on large-schema servers so they defer (names-only) until a tool is needed; ' +
'keep alwaysLoad only for small servers you call on most turns. Prefer CLI tools ' +
'(gh / aws / gcloud) over MCP for common operations.';
findings.push(finding({
scanner: SCANNER,
severity,
title: 'MCP tool schemas forced into the always-loaded prefix',
file: null,
evidence,
description,
recommendation,
category: 'token-efficiency',
}));
}
}
// ── Hotspots ranking ──
const hotspots = await buildHotspots(discovery, targetPath, activeConfig);
// ── Total estimated tokens (sum of every discovered source + activeConfig MCP) ──
let totalTokens = 0;
for (const f of discovery.files) {
totalTokens += estimateTokens(f.size, tokenKind(f.type));
totalTokens += estimateTokens(await tokenBytesFor(f), tokenKind(f.type));
}
if (activeConfig && Array.isArray(activeConfig.mcpServers)) {
for (const m of activeConfig.mcpServers) {

View file

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

View file

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

View file

@ -0,0 +1,46 @@
# Fenced Documentation Project
Stable preamble. All volatility below is illustrative, inside a code fence.
Body line 4.
Body line 5.
Body line 6.
Body line 7.
Body line 8.
Body line 9.
Body line 10.
Body line 11.
Body line 12.
Body line 13.
Body line 14.
Body line 15.
Body line 16.
Body line 17.
Body line 18.
Body line 19.
Body line 20.
Body line 21.
Body line 22.
Body line 23.
Body line 24.
Body line 25.
Body line 26.
Body line 27.
Body line 28.
Body line 29.
Body line 30.
Body line 31.
Body line 32.
Body line 33.
Body line 34.
```bash
export STAMP=${TIMESTAMP}
!deploy.sh --at 2026-06-26T10:00:00
echo {date} > /tmp/log
[2026-06-26 12:00] starting
```
Body line 41.
Body line 42.
Body line 43.
Body line 44.
Body line 45.
Body line 46.

View file

@ -0,0 +1,55 @@
# Fenced Plus Prose Project
Stable preamble. A fence below, plus one genuine volatile prose line.
Body line 4.
Body line 5.
Body line 6.
Body line 7.
Body line 8.
Body line 9.
Body line 10.
Body line 11.
Body line 12.
Body line 13.
Body line 14.
Body line 15.
Body line 16.
Body line 17.
Body line 18.
Body line 19.
Body line 20.
Body line 21.
Body line 22.
Body line 23.
Body line 24.
Body line 25.
Body line 26.
Body line 27.
Body line 28.
Body line 29.
Body line 30.
Body line 31.
Body line 32.
Body line 33.
Body line 34.
```bash
export STAMP=${TIMESTAMP} # documented, not live
```
Body line 38.
Body line 39.
Body line 40.
Body line 41.
Body line 42.
Body line 43.
Body line 44.
Body line 45.
Body line 46.
Body line 47.
Body line 48.
Body line 49.
!git log -1 # genuine shell-exec in live prose at line 50
Body line 51.
Body line 52.
Body line 53.
Body line 54.
Body line 55.

View file

@ -0,0 +1,55 @@
# Inline Code Mixed Project
Stable preamble. Backticked template plus a live var in prose.
Body line 4.
Body line 5.
Body line 6.
Body line 7.
Body line 8.
Body line 9.
Body line 10.
Body line 11.
Body line 12.
Body line 13.
Body line 14.
Body line 15.
Body line 16.
Body line 17.
Body line 18.
Body line 19.
Body line 20.
Body line 21.
Body line 22.
Body line 23.
Body line 24.
Body line 25.
Body line 26.
Body line 27.
Body line 28.
Body line 29.
Body line 30.
Body line 31.
Body line 32.
Body line 33.
Body line 34.
Body line 35.
Body line 36.
Body line 37.
Body line 38.
Body line 39.
Output template is `run-{date}.md` (documented).
Body line 41.
Body line 42.
Body line 43.
Body line 44.
Body line 45.
Body line 46.
Body line 47.
Body line 48.
Body line 49.
Deployed at ${RELEASE_STAMP} on every push.
Body line 51.
Body line 52.
Body line 53.
Body line 54.
Body line 55.

View file

@ -0,0 +1,46 @@
# Inline Code Doc Project
Stable preamble. A filename template is documented in backticks below.
Body line 4.
Body line 5.
Body line 6.
Body line 7.
Body line 8.
Body line 9.
Body line 10.
Body line 11.
Body line 12.
Body line 13.
Body line 14.
Body line 15.
Body line 16.
Body line 17.
Body line 18.
Body line 19.
Body line 20.
Body line 21.
Body line 22.
Body line 23.
Body line 24.
Body line 25.
Body line 26.
Body line 27.
Body line 28.
Body line 29.
Body line 30.
Body line 31.
Body line 32.
Body line 33.
Body line 34.
Body line 35.
Body line 36.
Body line 37.
Body line 38.
Body line 39.
| `--brief <path>` | writes to `.claude/plans/run-{date}-{slug}.md` |
Body line 41.
Body line 42.
Body line 43.
Body line 44.
Body line 45.
Body line 46.

View file

@ -0,0 +1,46 @@
# Non-Whitelisted Var Project
Stable preamble with a genuine runtime substitution below.
Body line 4.
Body line 5.
Body line 6.
Body line 7.
Body line 8.
Body line 9.
Body line 10.
Body line 11.
Body line 12.
Body line 13.
Body line 14.
Body line 15.
Body line 16.
Body line 17.
Body line 18.
Body line 19.
Body line 20.
Body line 21.
Body line 22.
Body line 23.
Body line 24.
Body line 25.
Body line 26.
Body line 27.
Body line 28.
Body line 29.
Body line 30.
Body line 31.
Body line 32.
Body line 33.
Body line 34.
Body line 35.
Body line 36.
Body line 37.
Body line 38.
Body line 39.
Deployed build tag: ${DEPLOY_TIMESTAMP} (changes every release).
Body line 41.
Body line 42.
Body line 43.
Body line 44.
Body line 45.
Body line 46.

View file

@ -0,0 +1,46 @@
# Stable CC Vars Project
Stable preamble — only CC-provided path vars appear below.
Body line 4.
Body line 5.
Body line 6.
Body line 7.
Body line 8.
Body line 9.
Body line 10.
Body line 11.
Body line 12.
Body line 13.
Body line 14.
Body line 15.
Body line 16.
Body line 17.
Body line 18.
Body line 19.
Body line 20.
Body line 21.
Body line 22.
Body line 23.
Body line 24.
Body line 25.
Body line 26.
Body line 27.
Body line 28.
Body line 29.
Body line 30.
Body line 31.
Body line 32.
Body line 33.
Body line 34.
Body line 35.
Body line 36.
Body line 37.
Body line 38.
Body line 39.
Hooks resolve under ${CLAUDE_PLUGIN_ROOT}/hooks and project root is ${CLAUDE_PROJECT_DIR}.
Body line 41.
Body line 42.
Body line 43.
Body line 44.
Body line 45.
Body line 46.

View file

@ -0,0 +1,10 @@
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "bash ./scripts/chatty.sh", "timeout": 5000 }] }
],
"PostToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "bash ./scripts/filtered.sh", "timeout": 5000 }] }
]
}
}

View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Chatty hook: dumps unfiltered command output straight into additionalContext.
# Every SessionStart this whole payload enters Claude's context (not just stdout).
NOTES="$(cat ./STATE.md)"
LOG="$(git log --oneline)"
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s\n%s"}}' "$NOTES" "$LOG"

View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Bounded hook: greps the log down to only failures before injecting context.
# This is the filter-before-Claude-reads pattern — should NOT be flagged.
ERRORS="$(cat ./test.log | grep -E 'FAIL|ERROR' | head -20)"
printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}' "$ERRORS"

View file

@ -0,0 +1,7 @@
# Clean Import Project
Stable preamble.
@shared/stable.md
All content is stable.

View file

@ -0,0 +1,5 @@
# Stable Conventions
Fully stable content.
No timestamps, no shell-exec, no variable substitutions.
Just plain prose that is identical on every turn.

View file

@ -0,0 +1,8 @@
# Imported Volatile Project
Stable preamble. No volatile content lives in this file.
@shared/conventions.md
This importing file is byte-stable on its own.
Nothing below changes between turns.

View file

@ -0,0 +1,8 @@
# Conventions
These conventions are mostly stable prose.
Last build: ${BUILD_TIMESTAMP}
!git log -1 --format=%cd
More conventions text that does not change.

View file

@ -0,0 +1,6 @@
{
"mcpServers": {
"always-srv": { "command": "npx", "args": ["fake-pkg"], "alwaysLoad": true, "tools": [{"name": "t_0", "description": "tool 0"},{"name": "t_1", "description": "tool 1"},{"name": "t_2", "description": "tool 2"},{"name": "t_3", "description": "tool 3"},{"name": "t_4", "description": "tool 4"},{"name": "t_5", "description": "tool 5"},{"name": "t_6", "description": "tool 6"},{"name": "t_7", "description": "tool 7"},{"name": "t_8", "description": "tool 8"},{"name": "t_9", "description": "tool 9"}] },
"deferred-srv": { "command": "npx", "args": ["other-pkg"], "tools": [{"name": "t_0", "description": "tool 0"},{"name": "t_1", "description": "tool 1"},{"name": "t_2", "description": "tool 2"},{"name": "t_3", "description": "tool 3"},{"name": "t_4", "description": "tool 4"},{"name": "t_5", "description": "tool 5"},{"name": "t_6", "description": "tool 6"},{"name": "t_7", "description": "tool 7"},{"name": "t_8", "description": "tool 8"},{"name": "t_9", "description": "tool 9"}] }
}
}

View file

@ -0,0 +1,5 @@
{
"mcpServers": {
"plain-srv": { "command": "npx", "args": ["fake-pkg"], "tools": [{"name": "t_0", "description": "tool 0"},{"name": "t_1", "description": "tool 1"},{"name": "t_2", "description": "tool 2"},{"name": "t_3", "description": "tool 3"},{"name": "t_4", "description": "tool 4"},{"name": "t_5", "description": "tool 5"},{"name": "t_6", "description": "tool 6"},{"name": "t_7", "description": "tool 7"},{"name": "t_8", "description": "tool 8"},{"name": "t_9", "description": "tool 9"}] }
}
}

View file

@ -0,0 +1,5 @@
{
"mcpServers": {
"srv-a": { "command": "npx", "args": ["fake-pkg"], "tools": [{"name": "t_0", "description": "tool 0"},{"name": "t_1", "description": "tool 1"},{"name": "t_2", "description": "tool 2"},{"name": "t_3", "description": "tool 3"},{"name": "t_4", "description": "tool 4"},{"name": "t_5", "description": "tool 5"},{"name": "t_6", "description": "tool 6"},{"name": "t_7", "description": "tool 7"},{"name": "t_8", "description": "tool 8"},{"name": "t_9", "description": "tool 9"}] }
}
}

View file

@ -0,0 +1,98 @@
# Subtraction shapes — synthesized fixture
This fixture reproduces the *discriminating shapes* from the hand-built subtraction fasit
(økt #40) without reproducing any of the operator's private config text. Every block below
exists to make one classification call hard in the same way a real block was hard.
Both languages appear on purpose: the config this lens was designed against is Norwegian
prose with English identifiers, and a detector that only speaks English would score well
here and fail in the field.
## Preferences
- Language: Norwegian for dialogue, English for code and documentation.
- Tone: direct and technical, minimal ceremony.
- Format: be concrete and action-oriented, avoid unnecessary text.
- Never say "as an AI I cannot"; skip apologies and over-explanation.
## Kodepreferanser
- TypeScript > JavaScript
- Readability > cleverness
- Conventional Commits: `type(scope): description`
## Oppstart
1. Run `pwd` to see where you are.
2. Read the nearest STATE.md and confirm the active task.
3. Sjekk `git status` mot det STATE.md hevder.
4. Summarize before you do anything.
## Arbeidsregler
- Commit often with descriptive messages.
- Test incrementally, and ask when blocked.
- Tenk før du koder — uttal antakelser eksplisitt og spør ved tvetydighet.
- Aldri gjett på rotårsak; undersøk først.
## Defensive shell-scripting
- Alltid siter variabel-ekspansjoner (`"$var"`) — en usitert ekspansjon brøt en zsh-loop.
- Hold testkode ASCII-ren; et multibyte-tegn krasjet bash 3.2 under `set -u`.
## Anti-patterns
- Do not one-shot large features.
- Never declare "done" without running the tests.
- Aldri jobb i et annet repo enn det økten står i. Hvert repo eier sin egen
versjonsplan og sin egen tilstand. Skriver du der, tar du beslutninger på
deres vegne uten deres kontekst — `coord-send` er mekanismen, send arbeidet
dit i stedet.
- Do not refactor as part of a bugfix without approval.
## Git
- Aldri GitHub. Kun Forgejo (`git.example-forge.test`). Aldri `gh` CLI.
- Etter `git commit`: push til Forgejo umiddelbart.
## Miljø
- System bash er **3.2**: ingen `declare -A`, ingen `readarray`.
- Alle hook-scripts må være 3.2-kompatible.
## Modellvalg
Default for alt arbeid er Opus 4.8 med xhigh effort. Ingen annen modell skal brukes.
Sett `model: "opus"` på hver spawn. Haiku: aldri.
## Secrets
- ALDRI commit secrets. Bruk `.env`/`.template`-mønsteret.
### The Iron Law: NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
## Arbeidsflyt
Default for alle oppgaver: forstå problemet, vurder alternativer, presenter dem,
og vent på bekreftelse før du implementerer. Ved usikkerhet om en oppgave faller
i unntakene: den gjør det ikke.
**Viktig:** `/harness continue` og andre skills styrer sin egen workflow.
## Kodeblokk
```bash
# Never run this in production, and always check every time first.
echo "avoid unnecessary output"
```
## Kontinuitet
Konteksten mellom sesjoner hviler på tre lag med hver sin ene jobb:
- **`STATE.md`** — current state-of-play der du jobber. Overskrives alltid ved sesjonsslutt.
- **`MEMORY.md`** — varige fakta om bruker. Hold den under 200 linjer.
## Verktøy
- Deleger arbeid til subagenter, og bruk Explore for søk.
- Skriv alltid kortest mulig kode.

View file

@ -5,6 +5,8 @@ import { mkdir, writeFile, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import {
estimateTokens,
stripInjectedHtmlComments,
effectiveMemoryBytes,
detectGitRoot,
walkClaudeMdCascade,
readClaudeJsonProjectSlice,
@ -196,6 +198,85 @@ describe('estimateTokens', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────
// stripInjectedHtmlComments / effectiveMemoryBytes (M-BUG-6)
// Claude Code strips block-level HTML comments from a CLAUDE.md/memory file
// before injecting it into context (code.claude.com/docs/en/memory), preserving
// them only inside fenced code blocks. A byte-accurate token estimate must
// discount them. Inline comments (text on the same line) are conservatively
// retained — only block-level stripping is verified behavior.
// ─────────────────────────────────────────────────────────────────────────
describe('stripInjectedHtmlComments (M-BUG-6)', () => {
it('strips a single-line block-level comment outside code fences', () => {
const src = '# Title\n\n<!-- maintainer note: regenerate weekly -->\n\nBody text.\n';
const out = stripInjectedHtmlComments(src);
assert.ok(!out.includes('maintainer note'), 'comment text should be removed');
assert.ok(out.includes('# Title') && out.includes('Body text.'), 'surrounding content preserved');
});
it('strips a multi-line block comment outside fences', () => {
const src = 'A\n<!--\nline one\nline two\n-->\nB\n';
const out = stripInjectedHtmlComments(src);
assert.ok(!out.includes('line one') && !out.includes('line two'), 'all comment lines removed');
assert.ok(out.includes('A') && out.includes('B'), 'surrounding content preserved');
});
it('preserves an HTML comment inside a ``` fenced code block', () => {
const src = '# Title\n\n```html\n<!-- kept: this is example code -->\n```\n';
const out = stripInjectedHtmlComments(src);
assert.ok(out.includes('kept: this is example code'), 'fenced comment must be preserved (CC keeps it)');
});
it('preserves an HTML comment inside a ~~~ fenced code block', () => {
const src = '~~~\n<!-- kept tilde -->\n~~~\n';
const out = stripInjectedHtmlComments(src);
assert.ok(out.includes('kept tilde'), 'tilde-fenced comment must be preserved');
});
it('keeps inline comments (only block-level stripping is verified)', () => {
// Text on the same line as the comment → conservatively retained; the
// verified CC behavior covers block-level comments only (Verifiseringsplikt).
const src = 'Visible <!-- hidden --> tail\n';
assert.equal(stripInjectedHtmlComments(src), src);
});
it('returns content unchanged when there are no comments', () => {
const src = '# Plain\n\nNo comments here.\n';
assert.equal(stripInjectedHtmlComments(src), src);
});
it('handles empty and non-string input', () => {
assert.equal(stripInjectedHtmlComments(''), '');
assert.equal(stripInjectedHtmlComments(undefined), '');
assert.equal(stripInjectedHtmlComments(null), '');
});
});
describe('effectiveMemoryBytes (M-BUG-6)', () => {
it('discounts out-of-fence block comments from the byte count', () => {
const src = '# Title\n\n<!-- a fairly long maintainer note that costs real bytes -->\n\nBody.\n';
const raw = Buffer.byteLength(src, 'utf8');
const eff = effectiveMemoryBytes(src);
assert.ok(eff < raw, `effective (${eff}) should be below raw (${raw})`);
});
it('counts comments inside fences (CC keeps them)', () => {
const src = '```\n<!-- kept -->\n```\n';
assert.equal(effectiveMemoryBytes(src), Buffer.byteLength(src, 'utf8'));
});
it('equals raw bytes when no comments are present', () => {
const src = '# Plain markdown\n\nbody\n';
assert.equal(effectiveMemoryBytes(src), Buffer.byteLength(src, 'utf8'));
});
it('returns 0 for non-string input', () => {
assert.equal(effectiveMemoryBytes(undefined), 0);
assert.equal(effectiveMemoryBytes(null), 0);
});
});
// ─────────────────────────────────────────────────────────────────────────
// detectGitRoot
// ─────────────────────────────────────────────────────────────────────────
@ -291,6 +372,23 @@ describe('walkClaudeMdCascade', () => {
assert.equal(result.estimatedTokens, Math.ceil(result.totalBytes / 4));
});
it('discounts block-level HTML comments from estimatedTokens (M-BUG-6)', async () => {
// CC strips block-level HTML comments before injection, so a CLAUDE.md
// padded with a maintainer-note comment must estimate FEWER tokens than its
// raw byte size would imply — totalBytes stays the honest on-disk figure.
const comment = `<!-- ${'maintainer note '.repeat(40)} -->`;
await writeFile(
join(fixture.root, 'CLAUDE.md'),
`# Project Instructions\n\n${comment}\n\nBuild with care.\n`,
);
const result = await walkClaudeMdCascade(fixture.root);
assert.ok(
result.estimatedTokens < Math.ceil(result.totalBytes / 4),
`expected discounted tokens (${result.estimatedTokens}) below raw heuristic ` +
`(${Math.ceil(result.totalBytes / 4)})`,
);
});
it('handles missing user CLAUDE.md gracefully', async () => {
// Remove user CLAUDE.md
await rm(join(fixture.fakeHome, '.claude', 'CLAUDE.md'));
@ -415,6 +513,125 @@ describe('enumeratePlugins', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────
// enumeratePlugins — enabledPlugins + installed_plugins.json (M-BUG-1)
// ─────────────────────────────────────────────────────────────────────────
/**
* Build a fake HOME that mirrors a real machine: an installed_plugins.json
* manifest (polyrepo plugins live under plugins/cache, monorepo ones under
* plugins/marketplaces) + an enabledPlugins toggle map in settings.json.
*
* The enumerator must inject ONLY enabled plugins, resolving each to its ACTIVE
* installPath including cache/ paths the marketplaces walk never sees and
* must NOT count disabled plugins, plugins absent from enabledPlugins, or
* marketplaces plugins absent from the manifest entirely (the real M-BUG-1).
*/
async function buildManifestHome(home) {
const cacheRoot = join(home, '.claude', 'plugins', 'cache', 'mp');
const mpRoot = join(home, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins');
async function makePlugin(root, name, version) {
await mkdir(join(root, '.claude-plugin'), { recursive: true });
await writeFile(
join(root, '.claude-plugin', 'plugin.json'),
JSON.stringify({ name, description: name, version }, null, 2),
);
await mkdir(join(root, 'agents'), { recursive: true });
await writeFile(
join(root, 'agents', `${name}-agent.md`),
`---\nname: ${name}-agent\ndescription: ${name} agent\n---\nbody\n`,
);
}
const enabledCachePath = join(cacheRoot, 'enabled-cache', '1.0.0');
const disabledCachePath = join(cacheRoot, 'disabled-cache', '1.0.0');
const enabledMpPath = join(mpRoot, 'enabled-mp');
const notToggledMpPath = join(mpRoot, 'not-toggled-mp');
const ghostMpPath = join(mpRoot, 'ghost-mp'); // on disk, absent from manifest
await makePlugin(enabledCachePath, 'enabled-cache', '1.0.0');
await makePlugin(disabledCachePath, 'disabled-cache', '1.0.0');
await makePlugin(enabledMpPath, 'enabled-mp', '0.1.0');
await makePlugin(notToggledMpPath, 'not-toggled-mp', '0.1.0');
await makePlugin(ghostMpPath, 'ghost-mp', '0.1.0');
await mkdir(join(home, '.claude', 'plugins'), { recursive: true });
await writeFile(
join(home, '.claude', 'plugins', 'installed_plugins.json'),
JSON.stringify({
version: 2,
plugins: {
'enabled-cache@mp': [{ scope: 'user', installPath: enabledCachePath, version: '1.0.0' }],
'disabled-cache@mp': [{ scope: 'user', installPath: disabledCachePath, version: '1.0.0' }],
'enabled-mp@mp': [{ scope: 'user', installPath: enabledMpPath, version: '0.1.0' }],
'not-toggled-mp@mp': [{ scope: 'user', installPath: notToggledMpPath, version: '0.1.0' }],
// ghost-mp deliberately absent from the manifest
},
}, null, 2),
);
await writeFile(
join(home, '.claude', 'settings.json'),
JSON.stringify({
enabledPlugins: {
'enabled-cache@mp': true,
'disabled-cache@mp': false,
'enabled-mp@mp': true,
// not-toggled-mp@mp deliberately absent (treated as not enabled)
},
}, null, 2),
);
}
describe('enumeratePlugins — enabledPlugins + installed_plugins.json (M-BUG-1)', () => {
let home, originalHome;
beforeEach(async () => {
home = uniqueDir('manifest-home');
await mkdir(home, { recursive: true });
originalHome = process.env.HOME;
process.env.HOME = home;
});
afterEach(async () => {
process.env.HOME = originalHome;
await rm(home, { recursive: true, force: true });
});
it('injects only ENABLED plugins (honors enabledPlugins toggle)', async () => {
await buildManifestHome(home);
const names = (await enumeratePlugins()).map(p => p.name).sort();
assert.deepEqual(names, ['enabled-cache', 'enabled-mp']);
});
it('excludes disabled, not-toggled, and manifest-absent (ghost) plugins', async () => {
await buildManifestHome(home);
const names = (await enumeratePlugins()).map(p => p.name);
assert.ok(!names.includes('disabled-cache'), 'disabled plugin must not be injected');
assert.ok(!names.includes('not-toggled-mp'), 'plugin absent from enabledPlugins must not be injected');
assert.ok(!names.includes('ghost-mp'), 'marketplaces plugin absent from manifest must not be injected');
});
it('resolves an enabled plugin from plugins/cache (polyrepo installPath)', async () => {
await buildManifestHome(home);
const cached = (await enumeratePlugins()).find(p => p.name === 'enabled-cache');
assert.ok(cached, 'cache-installed enabled plugin must be discovered');
assert.ok(cached.path.includes(join('plugins', 'cache')), `expected a cache/ path, got ${cached.path}`);
assert.equal(cached.agents, 1);
});
it('falls back to the marketplaces walk when installed_plugins.json is absent', async () => {
// No manifest → cannot tell enabled from installed → discover all on disk.
const legacyRoot = join(home, '.claude', 'plugins', 'marketplaces', 'mp', 'plugins', 'legacy');
await mkdir(join(legacyRoot, '.claude-plugin'), { recursive: true });
await writeFile(
join(legacyRoot, '.claude-plugin', 'plugin.json'),
JSON.stringify({ name: 'legacy', version: '9.9.9' }, null, 2),
);
const plugins = await enumeratePlugins();
assert.ok(plugins.find(p => p.name === 'legacy'), 'legacy marketplaces plugin discovered via fallback');
});
});
// ─────────────────────────────────────────────────────────────────────────
// enumerateSkills
// ─────────────────────────────────────────────────────────────────────────
@ -833,6 +1050,70 @@ describe('enumerateAgents (v5.6)', () => {
await mkdir(root, { recursive: true });
assert.deepEqual(await enumerateAgents(root, []), []);
});
// 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 — they must NOT be counted as agents.
it('M-BUG-5: skips files with no frontmatter at all', async () => {
const dir = join(root, '.claude', 'agents');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'reviewer.md'), '---\nname: reviewer\ndescription: reviews code\n---\nbody\n');
await writeFile(join(dir, 'not-an-agent.md'), '# Not An Agent\n\nJust a markdown doc, no frontmatter.\n');
const agents = await enumerateAgents(root, []);
assert.deepEqual(agents.map(a => a.name).sort(), ['reviewer']);
});
it('M-BUG-5: skips files missing name or description', async () => {
const dir = join(root, '.claude', 'agents');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'reviewer.md'), '---\nname: reviewer\ndescription: reviews code\n---\nbody\n');
await writeFile(join(dir, 'name-only.md'), '---\nname: name-only\n---\nbody\n');
await writeFile(join(dir, 'desc-only.md'), '---\ndescription: has no name\n---\nbody\n');
await writeFile(join(dir, 'empty-name.md'), '---\nname:\ndescription: blank name\n---\nbody\n');
const agents = await enumerateAgents(root, []);
assert.deepEqual(agents.map(a => a.name).sort(), ['reviewer']);
});
// M-BUG-3: CC scans agents dirs recursively, so a valid agent in a subfolder
// (e.g. agents/review/security.md) is registered and must be counted.
it('M-BUG-3: recurses into agent subdirectories', async () => {
const sub = join(root, '.claude', 'agents', 'review');
await mkdir(sub, { recursive: true });
await writeFile(join(sub, 'security.md'), '---\nname: security\ndescription: security review\n---\nbody\n');
const agents = await enumerateAgents(root, []);
const a = agents.find(x => x.name === 'security');
assert.ok(a, 'agent in subdir should be counted');
assert.equal(a.source, 'project');
});
});
// M-BUG-4: when repoPath === $HOME (the `manifest --global` self-scan), the
// project agents dir resolves to the SAME path as the user agents dir. The
// shared configDirs helper must count it once (as user scope), not twice.
describe('enumerateAgents — HOME self-scan dedup (M-BUG-4)', () => {
let home, originalHome;
beforeEach(async () => {
home = uniqueDir('agents-home-selfscan');
await mkdir(join(home, '.claude', 'agents'), { recursive: true });
originalHome = process.env.HOME;
process.env.HOME = home;
});
afterEach(async () => {
process.env.HOME = originalHome;
await rm(home, { recursive: true, force: true });
});
it('does not double-count user agents when repoPath === HOME', async () => {
await writeFile(
join(home, '.claude', 'agents', 'solo.md'),
'---\nname: solo\ndescription: only one of me\n---\nbody\n',
);
const agents = await enumerateAgents(home, []);
const solos = agents.filter(a => a.name === 'solo');
assert.equal(solos.length, 1, 'agent at HOME must be counted once, not twice');
assert.equal(solos[0].source, 'user', 'HOME self-scan agents are user scope');
});
});
describe('enumerateOutputStyles (v5.6)', () => {

View file

@ -0,0 +1,96 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveActiveModel } from '../../scanners/lib/active-model.mjs';
// B8b — resolveActiveModel reads the configured model so `--context-window auto`
// can probe it. Source precedence mirrors Claude Code's own resolution: the shell
// ANTHROPIC_MODEL override wins, otherwise the settings cascade (local > project >
// user). Fully file/env-injectable so it is hermetic under the test HOME.
async function withTempHome(fn) {
const home = await mkdtemp(join(tmpdir(), 'config-audit-model-home-'));
try {
return await fn(home);
} finally {
await rm(home, { recursive: true, force: true });
}
}
async function withTempProject(fn) {
const project = await mkdtemp(join(tmpdir(), 'config-audit-model-proj-'));
try {
return await fn(project);
} finally {
await rm(project, { recursive: true, force: true });
}
}
async function writeSettings(dir, file, obj) {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, file), JSON.stringify(obj));
}
describe('resolveActiveModel — env override (ANTHROPIC_MODEL)', () => {
it('returns the shell ANTHROPIC_MODEL when set, ignoring settings', async () => {
await withTempHome(async (home) => {
await writeSettings(join(home, '.claude'), 'settings.json', { model: 'claude-sonnet-4-6' });
const env = { HOME: home, ANTHROPIC_MODEL: 'claude-opus-4-8[1m]' };
assert.equal(await resolveActiveModel(null, { env }), 'claude-opus-4-8[1m]');
});
});
it('an empty ANTHROPIC_MODEL is treated as unset (falls through to the cascade)', async () => {
await withTempHome(async (home) => {
await writeSettings(join(home, '.claude'), 'settings.json', { model: 'claude-sonnet-4-6' });
const env = { HOME: home, ANTHROPIC_MODEL: ' ' };
assert.equal(await resolveActiveModel(null, { env }), 'claude-sonnet-4-6');
});
});
});
describe('resolveActiveModel — settings cascade (local > project > user)', () => {
it('reads the model from user ~/.claude/settings.json', async () => {
await withTempHome(async (home) => {
await writeSettings(join(home, '.claude'), 'settings.json', { model: 'claude-opus-4-8' });
assert.equal(await resolveActiveModel(null, { env: { HOME: home } }), 'claude-opus-4-8');
});
});
it('project settings.json overrides user settings', async () => {
await withTempHome(async (home) => {
await writeSettings(join(home, '.claude'), 'settings.json', { model: 'claude-opus-4-8' });
await withTempProject(async (project) => {
await writeSettings(join(project, '.claude'), 'settings.json', { model: 'claude-sonnet-4-6' });
assert.equal(await resolveActiveModel(project, { env: { HOME: home } }), 'claude-sonnet-4-6');
});
});
});
it('settings.local.json overrides project settings (local wins)', async () => {
await withTempHome(async (home) => {
await withTempProject(async (project) => {
await writeSettings(join(project, '.claude'), 'settings.json', { model: 'claude-sonnet-4-6' });
await writeSettings(join(project, '.claude'), 'settings.local.json', { model: 'claude-opus-4-8[1m]' });
assert.equal(await resolveActiveModel(project, { env: { HOME: home } }), 'claude-opus-4-8[1m]');
});
});
});
});
describe('resolveActiveModel — nothing configured', () => {
it('returns null on a clean HOME with no model anywhere (honest advisory fallback)', async () => {
await withTempHome(async (home) => {
assert.equal(await resolveActiveModel(null, { env: { HOME: home } }), null);
});
});
it('ignores a non-string model field', async () => {
await withTempHome(async (home) => {
await writeSettings(join(home, '.claude'), 'settings.json', { model: 123 });
assert.equal(await resolveActiveModel(null, { env: { HOME: home } }), null);
});
});
});

View file

@ -0,0 +1,167 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
CONTEXT_WINDOW_ANCHOR,
LARGE_CONTEXT_WINDOW,
modelToContextWindow,
resolveContextWindow,
scaleForWindow,
} from '../../scanners/lib/context-window.mjs';
// B8 — context-window calibration. resolveContextWindow turns the raw
// --context-window CLI value into { window, advisory } that SKL/CML calibrate
// their budgets with. The DEFAULT (no flag) must be byte-identical to the
// pre-B8 behavior: the conservative 200k anchor at full severity (advisory=false).
describe('resolveContextWindow — default (no flag) is the conservative anchor', () => {
it('undefined resolves to the 200k anchor, not advisory (byte-stable default)', () => {
const r = resolveContextWindow(undefined);
assert.equal(r.window, CONTEXT_WINDOW_ANCHOR);
assert.equal(r.advisory, false);
assert.equal(r.source, 'default');
});
it('null resolves to the conservative default', () => {
const r = resolveContextWindow(null);
assert.equal(r.window, CONTEXT_WINDOW_ANCHOR);
assert.equal(r.advisory, false);
});
});
describe('resolveContextWindow — explicit window', () => {
it('a numeric string calibrates to that window, not advisory', () => {
const r = resolveContextWindow('1000000');
assert.equal(r.window, 1_000_000);
assert.equal(r.advisory, false);
assert.equal(r.source, 'explicit');
});
it('a plain number is accepted', () => {
const r = resolveContextWindow(1_000_000);
assert.equal(r.window, 1_000_000);
assert.equal(r.advisory, false);
});
});
describe('resolveContextWindow — auto / unknown downgrades to advisory', () => {
it('"auto" with no model keeps the conservative anchor but flags advisory', () => {
const r = resolveContextWindow('auto');
assert.equal(r.window, CONTEXT_WINDOW_ANCHOR, 'window stays the conservative anchor');
assert.equal(r.advisory, true, 'unknown window -> advisory, not a budget breach');
assert.equal(r.source, 'auto-unresolved');
});
it('"auto" with an unrecognized model also stays advisory', () => {
const r = resolveContextWindow('auto', { model: 'totally-made-up-model' });
assert.equal(r.window, CONTEXT_WINDOW_ANCHOR);
assert.equal(r.advisory, true);
assert.equal(r.source, 'auto-unresolved');
});
it('"AUTO" is case-insensitive', () => {
assert.equal(resolveContextWindow('AUTO').advisory, true);
});
it('an invalid value falls back to the conservative default (not advisory)', () => {
for (const bad of ['banana', '0', '-5', '']) {
const r = resolveContextWindow(bad);
assert.equal(r.window, CONTEXT_WINDOW_ANCHOR, `"${bad}" -> anchor`);
assert.equal(r.advisory, false, `"${bad}" -> not advisory`);
}
});
});
describe('scaleForWindow — linear scaling off the 200k anchor', () => {
it('is the identity at the 200k anchor (byte-stable default)', () => {
assert.equal(scaleForWindow(4000, CONTEXT_WINDOW_ANCHOR), 4000);
assert.equal(scaleForWindow(40_000, CONTEXT_WINDOW_ANCHOR), 40_000);
});
it('scales 5x at the 1M window', () => {
assert.equal(scaleForWindow(4000, LARGE_CONTEXT_WINDOW), 20_000);
assert.equal(scaleForWindow(40_000, LARGE_CONTEXT_WINDOW), 200_000);
});
});
// B8b — model -> context-window mapping. `--context-window auto` probes the
// configured model and maps known 1M-tier model IDs to the large window so SKL/CML
// self-calibrate instead of falling back to the conservative advisory anchor.
// Verified June 2026 (platform.claude.com models overview): Fable 5, Opus
// 4.8/4.7/4.6 and Sonnet 4.6 run a 1M context window.
describe('modelToContextWindow — known 1M-tier models map to the large window', () => {
it('the explicit [1m] tier tag wins (the running session model surfaces this way)', () => {
assert.equal(modelToContextWindow('claude-opus-4-8[1m]'), LARGE_CONTEXT_WINDOW);
// Suffix wins even for a base ID we do not otherwise enumerate.
assert.equal(modelToContextWindow('claude-future-9[1m]'), LARGE_CONTEXT_WINDOW);
});
it('known 1M base IDs map to 1M', () => {
for (const id of [
'claude-fable-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
]) {
assert.equal(modelToContextWindow(id), LARGE_CONTEXT_WINDOW, id);
}
});
it('tolerates dated suffixes and provider prefixes (substring match)', () => {
assert.equal(modelToContextWindow('claude-opus-4-8-20260528'), LARGE_CONTEXT_WINDOW);
assert.equal(modelToContextWindow('us.anthropic.claude-sonnet-4-6'), LARGE_CONTEXT_WINDOW);
assert.equal(modelToContextWindow('CLAUDE-OPUS-4-8'), LARGE_CONTEXT_WINDOW); // case-insensitive
});
it('short aliases that resolve to a 1M-tier model map to 1M', () => {
for (const alias of ['opus', 'sonnet', 'fable', 'opusplan']) {
assert.equal(modelToContextWindow(alias), LARGE_CONTEXT_WINDOW, alias);
}
});
});
describe('modelToContextWindow — unknown / unconfirmed models return null (conservative)', () => {
it('returns null for models whose window we do not confirm', () => {
// Haiku is intentionally NOT enumerated: keep the conservative anchor rather
// than guess. null -> caller keeps the advisory 200k anchor.
assert.equal(modelToContextWindow('claude-haiku-4-5-20251001'), null);
assert.equal(modelToContextWindow('haiku'), null);
assert.equal(modelToContextWindow('some-unknown-model'), null);
assert.equal(modelToContextWindow('default'), null);
});
it('returns null for non-string / empty input', () => {
assert.equal(modelToContextWindow(undefined), null);
assert.equal(modelToContextWindow(null), null);
assert.equal(modelToContextWindow(''), null);
assert.equal(modelToContextWindow(' '), null);
assert.equal(modelToContextWindow(42), null);
});
});
describe('resolveContextWindow — auto probes the model (B8b)', () => {
it('"auto" + a recognized 1M model calibrates to 1M, not advisory', () => {
const r = resolveContextWindow('auto', { model: 'claude-opus-4-8[1m]' });
assert.equal(r.window, LARGE_CONTEXT_WINDOW);
assert.equal(r.advisory, false, 'a confirmed window is not advisory');
assert.equal(r.source, 'auto-probed');
});
it('"auto" + a recognized base ID (no tag) also calibrates', () => {
const r = resolveContextWindow('auto', { model: 'claude-sonnet-4-6' });
assert.equal(r.window, LARGE_CONTEXT_WINDOW);
assert.equal(r.source, 'auto-probed');
});
it('the model is ignored on the explicit and default paths (byte-stable)', () => {
const explicit = resolveContextWindow('1000000', { model: 'claude-opus-4-8' });
assert.equal(explicit.source, 'explicit');
assert.equal(explicit.window, 1_000_000);
const def = resolveContextWindow(undefined, { model: 'claude-opus-4-8[1m]' });
assert.equal(def.source, 'default');
assert.equal(def.window, CONTEXT_WINDOW_ANCHOR);
assert.equal(def.advisory, false);
});
});

View file

@ -1,6 +1,6 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { join, sep } from 'node:path';
import { mkdir, writeFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import {
@ -509,3 +509,49 @@ describe('discoverConfigFiles — cache-aware filtering', () => {
assert.ok(!keys.has(STALE1) && !keys.has(STALE2), 'stale dropped via Multi');
});
});
// ───────────────────────────────────────────────────────────────
// Group 8: backups/ skip (M-BUG-8) — a directory named `backups`
// holds backup COPIES, not live config. config-audit's own session
// backups (~/.claude/config-audit/backups/<ts>/files/plugins/*/CLAUDE.md)
// were walked as if live, producing stale findings on a ~/.claude audit.
// ───────────────────────────────────────────────────────────────
describe('discoverConfigFiles — skips backups/ (M-BUG-8)', () => {
let dir;
before(async () => {
dir = tempDir('backups');
// A live CLAUDE.md at the root (must still be discovered).
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'CLAUDE.md'), '# Live config');
// config-audit's own backup tree — frozen copies, NOT live config.
const backupClaude = join(dir, 'config-audit', 'backups', '20260518_103233', 'files', 'plugins', 'voyage');
await mkdir(backupClaude, { recursive: true });
await writeFile(join(backupClaude, 'CLAUDE.md'), '# Frozen backup copy');
const backupSettings = join(dir, 'config-audit', 'backups', '20260518_103233', 'files', '.claude');
await mkdir(backupSettings, { recursive: true });
await writeFile(join(backupSettings, 'settings.json'), '{}');
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('discovers the live CLAUDE.md', async () => {
const { files } = await discoverConfigFiles(dir);
const live = files.find(f => f.relPath === 'CLAUDE.md');
assert.ok(live, 'live CLAUDE.md at root should be discovered');
});
it('does NOT discover config files under a backups/ directory', async () => {
const { files } = await discoverConfigFiles(dir);
const inBackups = files.filter(f => f.absPath.includes(`backups${sep}`));
assert.equal(inBackups.length, 0, 'no files under backups/ should be discovered');
});
it('counts backups/ as a skipped directory', async () => {
const { skipped } = await discoverConfigFiles(dir);
assert.ok(skipped >= 1, 'backups/ should be counted as skipped');
});
});

View file

@ -162,6 +162,63 @@ test('CML, SET, HKV, RUL, MCP, IMP, GAP, TOK, PLH have non-empty static maps', (
}
});
test('GAP "CLAUDE.md not modular" copy is size-neutral (M-BUG-14)', () => {
// The t2_2 check is a pure presence check (no length gate), so a small
// non-modular CLAUDE.md must not be told it is "big"/"long" or that it
// costs "loading time". Title + description must stay size/cost-neutral
// and keep only the honest structural "split into linked files" framing.
const t = TRANSLATIONS.GAP.static['CLAUDE.md not modular'];
assert.ok(t, 'GAP static map missing "CLAUDE.md not modular"');
const title = t.title.toLowerCase();
const desc = t.description.toLowerCase();
assert.ok(!/\bbig\b/.test(title), `title overclaims size: "${t.title}"`);
assert.ok(!/\blong\b/.test(title), `title overclaims size: "${t.title}"`);
assert.ok(!/\bbig\b/.test(desc), `description overclaims size: "${t.description}"`);
assert.ok(!/\blong\b/.test(desc), `description overclaims size: "${t.description}"`);
assert.ok(!/loading time/.test(desc), `description overclaims load cost: "${t.description}"`);
assert.ok(/split/.test(desc), `description should keep structural split framing: "${t.description}"`);
});
test('GAP enhancement-gap titles do not presuppose the feature exists (M-BUG-15)', () => {
// t2_3 ("No path-scoped rules") and t3_6 ("No subagent isolation") iterate a
// collection and return false for an EMPTY one — so they fire even when the
// user has zero rules / zero subagents. The humanized title must therefore
// not assert the feature exists, or it contradicts the sibling presence gap
// ("No custom subagents" → "You haven't set up any specialized helper agents
// yet"). Mirror the house "You haven't set up X yet" framing — honest for
// both the zero-state and the has-but-unconfigured state.
const rules = TRANSLATIONS.GAP.static['No path-scoped rules'];
const iso = TRANSLATIONS.GAP.static['No subagent isolation'];
assert.ok(rules, 'GAP static map missing "No path-scoped rules"');
assert.ok(iso, 'GAP static map missing "No subagent isolation"');
// Title must not presuppose existing rules / subagents.
assert.ok(!/your rules all load/i.test(rules.title),
`t2_3 title presupposes existing rules: "${rules.title}"`);
assert.ok(!/your subagents/i.test(iso.title),
`t3_6 title presupposes existing subagents: "${iso.title}"`);
// Title must still name the feature it points at.
assert.ok(/path-scoped rules/i.test(rules.title),
`t2_3 title should still name path-scoped rules: "${rules.title}"`);
assert.ok(/isolation/i.test(iso.title),
`t3_6 title should still name subagent isolation: "${iso.title}"`);
});
test('SKL oversized-body copy is on-demand, not listing-budget (M-BUG-16)', () => {
// The skill-listing check emits a third finding for an oversized skill BODY,
// which loads ON DEMAND only when the skill runs — not the always-loaded
// listing. Before the fix this title had no static entry and fell through to
// the SKL _default ("using more of the listing budget"), contradicting the
// finding's own evidence ("NOT every turn like the always-loaded listing").
const t = TRANSLATIONS.SKL.static['Skill body is large (loads on demand when the skill runs)'];
assert.ok(t, 'SKL static map missing "Skill body is large (loads on demand when the skill runs)"');
assert.ok(!/listing budget/i.test(t.title), `body title must not claim listing budget: "${t.title}"`);
assert.ok(!/listing budget/i.test(t.description), `body description must not claim listing budget: "${t.description}"`);
// Must convey the on-demand body cost.
assert.ok(/body/i.test(t.title), `body title should name the body: "${t.title}"`);
assert.ok(/loads only|on demand|when (it|that|the) skill runs|when you invoke/i.test(t.title + ' ' + t.description),
`copy should convey the on-demand load: "${t.title}" / "${t.description}"`);
});
test('CNF, COL, PLH have at least one pattern entry (template-literal titles)', () => {
// These scanners use template-literal titles for some findings.
for (const prefix of ['CNF', 'COL', 'PLH']) {

View file

@ -116,6 +116,27 @@ test('humanizeFinding falls back to _default when title unknown', () => {
assert.ok(/instructions file/i.test(out.title), `expected CML _default title, got: ${out.title}`);
});
test('humanizeFinding maps SKL oversized-body finding to an on-demand title, not the listing-budget _default', () => {
// RAW title emitted by skill-listing-scanner for body > threshold (v5.11 B7).
// It is an ON-DEMAND body cost, NOT the always-loaded listing budget — the
// scanner is careful to distinguish them, so the humanized title must not
// regress into "listing budget" language via the SKL _default (M-BUG-16).
const input = makeFinding({
scanner: 'SKL',
severity: 'low',
title: 'Skill body is large (loads on demand when the skill runs)',
description: 'Skill "repo-init" (user) has a body of about 6223 tokens (712 lines), over the ~5000-token guidance for a skill body.',
evidence: 'body_tokens~6223; threshold=5000 tok; skill="repo-init"; source=user - this is the skill BODY which loads ON DEMAND only when the skill is invoked - NOT every turn like the always-loaded listing.',
});
const out = humanizeFinding(input);
assert.doesNotMatch(out.title, /listing budget/i,
`body finding must not be framed as a listing-budget cost, got: ${out.title}`);
assert.notEqual(out.title, 'A skill is using more of the listing budget than it should',
'body finding must have its own mapping, not the SKL _default');
assert.match(out.title, /body|on demand|when (it|the skill) runs/i,
`humanized title must convey the on-demand body cost, got: ${out.title}`);
});
test('humanizeFinding passes through original strings when scanner prefix unknown', () => {
const input = makeFinding({ scanner: 'XXX', title: 'whatever' });
const out = humanizeFinding(input);
@ -184,10 +205,13 @@ test('humanizeFinding sets category Conflict for CNF/COL', () => {
}
});
test('humanizeFinding sets category Wasted tokens for TOK/CPS/SKL', () => {
for (const s of ['TOK', 'CPS', 'SKL']) {
test('humanizeFinding sets category Wasted tokens for TOK/CPS/SKL/AGT', () => {
// AGT (agent-listing budget) is an always-loaded per-turn token cost — the
// agent name+description is re-sent every turn in the listing — so it belongs
// in "Wasted tokens" alongside TOK/CPS/SKL, not the 'Other' fallback (M-BUG-17).
for (const s of ['TOK', 'CPS', 'SKL', 'AGT']) {
const out = humanizeFinding(makeFinding({ scanner: s }));
assert.equal(out.userImpactCategory, 'Wasted tokens');
assert.equal(out.userImpactCategory, 'Wasted tokens', `${s} should map to Wasted tokens`);
}
});

View file

@ -102,6 +102,20 @@ describe('assessSkillListingBudget — aggregate math', () => {
assert.equal(r.aggregateChars, 100);
assert.equal(r.scanned, 3);
});
it('accepts a calibrated budget (B8): 17×1000 chars is within a 20,000-tok 1M budget', () => {
const r = assessSkillListingBudget(Array(17).fill(1000), 20_000);
assert.equal(r.aggregateTokens, 4250);
assert.equal(r.budgetTokens, 20_000, 'reports the calibrated budget, not the 200k default');
assert.equal(r.overBudget, false, 'within the relaxed 1M budget');
assert.equal(r.overBy, 0);
});
it('the budget argument defaults to the 200k anchor (byte-stable for existing callers)', () => {
const r = assessSkillListingBudget(Array(17).fill(1000));
assert.equal(r.budgetTokens, AGGREGATE_BUDGET_TOKENS);
assert.equal(r.overBudget, true);
});
});
describe('envFlag', () => {

View file

@ -1,6 +1,6 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { lineCount, truncate, isSimilar, extractKeys, normalizePath } from '../../scanners/lib/string-utils.mjs';
import { lineCount, truncate, isSimilar, extractKeys, normalizePath, levenshtein } from '../../scanners/lib/string-utils.mjs';
describe('lineCount', () => {
it('counts lines correctly', () => {
@ -90,6 +90,42 @@ describe('extractKeys', () => {
});
});
describe('levenshtein', () => {
it('returns 0 for identical strings', () => {
assert.strictEqual(levenshtein('permissions', 'permissions'), 0);
});
it('counts a single substitution as 1', () => {
assert.strictEqual(levenshtein('model', 'modet'), 1);
});
it('counts a single deletion as 1 (permisions → permissions)', () => {
assert.strictEqual(levenshtein('permisions', 'permissions'), 1);
});
it('counts a single insertion as 1', () => {
assert.strictEqual(levenshtein('hooks', 'hooks2'), 1);
});
it('counts a transposition as 2 (standard Levenshtein)', () => {
assert.strictEqual(levenshtein('import', 'improt'), 2);
});
it('returns the other length when one string is empty', () => {
assert.strictEqual(levenshtein('', 'abc'), 3);
assert.strictEqual(levenshtein('abc', ''), 3);
assert.strictEqual(levenshtein('', ''), 0);
});
it('is symmetric', () => {
assert.strictEqual(levenshtein('effortLevel', 'efortLevel'), levenshtein('efortLevel', 'effortLevel'));
});
it('gives a large distance for unrelated keys', () => {
assert.ok(levenshtein('unknownKey123', 'permissions') > 2);
});
});
describe('normalizePath', () => {
it('expands ~ to HOME', () => {
const home = process.env.HOME;

View file

@ -0,0 +1,228 @@
/**
* subtraction-prefilter tests the deterministic half of the v5.13 subtraction
* lens (`/config-audit optimize --subtract`, CA-OPT / BP-SUB-001).
*
* The subtraction axis asks the inverse of every other command: *what is no
* longer earning its always-loaded rent?* Precision here is ASYMMETRIC a
* missed dead line costs a few tokens per turn, a deleted load-bearing line
* costs a wrong remote or a broken script (brief §6.0). So unlike
* `lens-prefilter` (recall-first, agent as the gate), this module is
* precision-first and carries a BLOCKING deterministic guarantee:
*
* **a load-bearing block never reaches the judge at all.**
*
* That guarantee is delivered by two independent mechanisms, and the contract is
* therefore asserted on the CANDIDATE LIST rather than on either mechanism:
* 1. the compensatory detector does not fire on declarative local facts
* ("Language: Norwegian for dialogue") they are never generated; and
* 2. floor-exclusion vetoes any leaf block carrying an underivable local
* literal (code span, path, domain, version pin) or a policy invariant.
* A test written against `isLoadBearing()` alone would miss group 1 entirely.
*
* GRANULARITY (the one design choice the fasit left open, §5): leaf-block one
* markdown list item including its wrapped continuation lines, or one paragraph.
* It must SPLIT a mixed numbered list and three consecutive bullets, and must
* KEEP WHOLE a bullet whose load-bearing literal sits on a continuation line.
*
* FIXTURE, not the real subject file. The fasit was built against the operator's
* private `~/.claude/CLAUDE.md`; that file is machine-dependent, edited between
* sessions, and this repo's only remote is a PUBLIC mirror. So the committed
* suite runs against a synthesized fixture that reproduces the discriminating
* *shapes*, and the run against the real file is the manual dogfood gate (brief
* §8), recorded in the worklist not a suite test.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
splitLeafBlocks,
subtractionCandidates,
floorExcluded,
SUBTRACT_DETECTORS,
} from '../../scanners/lib/subtraction-prefilter.mjs';
import { isLoadBearing } from '../../scanners/lib/floor-exclusion.mjs';
const FIXTURE = readFileSync(
fileURLToPath(new URL('../fixtures/subtraction/shapes-claude-md.md', import.meta.url)),
'utf8',
);
const candidateLines = (text) => subtractionCandidates(text).map((c) => c.line);
const covers = (cands, line) => cands.some((c) => line >= c.startLine && line <= c.endLine);
/**
* Every line of the fixture that the fasit labels FLOOR or POLICY-FLOOR. If any
* of these is covered by a candidate block, the gate fails outright whatever
* the recall. (brief §8, "zero load-bearing blocks proposed for deletion")
*/
const FLOOR_LINES = [
13, 14, // declarative preference facts (B-04 / B-05 shape) — must never fire
22, // format contract in a code span (B-30c) — adjacent to two other calls
26, 27, 28, 29, // ordered list = contract (B-16 / B-17): siblings 27-28 floor the whole run
91, 92, 93, // list stem + its floor items (B-09) — the stem carries no literal of its own
97, // "bruk Explore for søk" — an entity the mechanism cannot resolve, so it keeps it
40, 41, // generic-sounding advice wrapped around a local incident (B-27 — THE trap)
47, 48, 49, 50, // floor bullet inside a compensatory list (B-32b), literal on a continuation line
55, 56, // §8 named anchor: the remote (B-25) + the push workflow (B-23)
60, 61, // §8 named anchor: system bash version (B-26)
65, 66, // stale-in-content but load-bearing (B-29) — staleness is NOT a deletion signal
70, // policy invariant, floor by decision not classification (B-34)
72, // absolute policy prohibition as a heading (B-37)
80, // local fact about how skills behave (B-38b)
85, 86, // inside a fenced code block — never a candidate
];
/** Lines the fasit labels DELETABLE that the mechanism must actually surface. */
const MUST_SURFACE = [
15, // "avoid unnecessary text" — the B-06 half of the B-05/B-06 adjacent pair
16, // "Never say 'as an AI I cannot'" — textbook tier-3 compensatory
33, // "Commit often with descriptive messages" (B-19b) — the named pure-behaviour case
36, // "Aldri gjett på rotårsak" — Norwegian imperative
46, // "Never declare done without running the tests"
76, // the workflow paragraph (B-38a) — split from its floor line at 80
98, // "Skriv alltid kortest mulig kode" — survives next to an entity-vetoed sibling
];
describe('subtraction-prefilter — detector table', () => {
it('registers the subtraction class against BP-SUB-001, separately from LENS_DETECTORS', async () => {
const map = new Map(SUBTRACT_DETECTORS.map((d) => [d.lensCheck, d]));
assert.ok(map.has('compensatory-instruction'), 'subtraction lensCheck must exist');
assert.equal(map.get('compensatory-instruction').registerId, 'BP-SUB-001');
// Byte-stability: the three mechanism-fit detectors drive the plain
// `optimize` payload's `register` block. Adding the subtraction class there
// would change plain-optimize output, which the brief forbids.
const { LENS_DETECTORS } = await import('../../scanners/lib/lens-prefilter.mjs');
assert.equal(LENS_DETECTORS.length, 3, 'plain optimize must still expose exactly 3 detectors');
assert.ok(
!LENS_DETECTORS.some((d) => d.lensCheck === 'compensatory-instruction'),
'the subtraction class must not leak into the plain-optimize detector table',
);
});
});
describe('subtraction-prefilter — leaf-block granularity (the declared contract)', () => {
it('keeps a wrapped bullet whole, so a continuation-line literal still protects it', () => {
const blocks = splitLeafBlocks(FIXTURE);
const b32b = blocks.find((b) => b.startLine === 47);
assert.ok(b32b, 'the wrapped bullet must start its own block at line 47');
assert.equal(b32b.endLine, 50, 'its continuation lines belong to the same block');
});
it('splits three consecutive bullets into three blocks (three different calls)', () => {
const blocks = splitLeafBlocks(FIXTURE);
for (const line of [20, 21, 22]) {
assert.ok(
blocks.some((b) => b.startLine === line && b.endLine === line),
`line ${line} must be its own leaf block`,
);
}
});
it('treats an ordered list as a contract: a floor sibling floors the whole run', () => {
// Steps 2-3 name the file to read and the check to run; steps 1 and 4 are
// filler. Splitting them would propose deleting step 4 of a five-step
// protocol — so the run inherits floor. (Structural exception 2.)
const cands = subtractionCandidates(FIXTURE);
for (const line of [26, 27, 28, 29]) {
assert.ok(!covers(cands, line), `ordered step at line ${line} must inherit floor`);
}
const reasons = floorExcluded(FIXTURE);
assert.ok(
reasons.some((r) => r.startLine === 29 && r.marker === 'ordered-contract'),
'step 4 must be recorded as floored by contract inheritance, not by its own literal',
);
});
it('merges a list stem with the items it introduces (structural exception 1)', () => {
const blocks = splitLeafBlocks(FIXTURE);
const stem = blocks.find((b) => b.startLine === 91);
assert.ok(stem, 'the stem paragraph must open a block at line 91');
assert.equal(stem.endLine, 93, 'the stem absorbs the list it introduces');
// The stem itself carries no literal — it is protected only by the merge.
assert.equal(isLoadBearing('Konteksten mellom sesjoner hviler på tre lag med hver sin ene jobb:'), false);
});
it('never emits a heading as a candidate block', () => {
const cands = subtractionCandidates(FIXTURE);
assert.ok(!covers(cands, 72), 'the Iron Law heading must not be a deletion candidate');
});
});
describe('subtraction-prefilter — THE GATE (brief §8, blocking)', () => {
it('proposes ZERO load-bearing lines for deletion', () => {
const cands = subtractionCandidates(FIXTURE);
const violations = FLOOR_LINES.filter((line) => covers(cands, line));
assert.deepEqual(
violations,
[],
`load-bearing lines proposed for deletion: ${violations.join(', ')} — gate fails outright`,
);
});
it('still surfaces genuinely compensatory blocks (recall is secondary, not zero)', () => {
const cands = subtractionCandidates(FIXTURE);
const missed = MUST_SURFACE.filter((line) => !covers(cands, line));
assert.deepEqual(missed, [], `expected these to be candidates: ${missed.join(', ')}`);
});
it('separates the adjacent near-identical preference bullets (B-05 vs B-06)', () => {
const lines = candidateLines(FIXTURE);
assert.ok(!lines.includes(14), 'the tone preference is a fact about the human — keep');
assert.ok(lines.includes(15), 'the "avoid unnecessary text" nag is compensatory — surface');
});
it('does not fire inside fenced code blocks', () => {
const cands = subtractionCandidates(FIXTURE);
assert.ok(!covers(cands, 85) && !covers(cands, 86), 'fenced code is not config prose');
});
});
describe('floor-exclusion — the deterministic veto', () => {
it('vetoes underivable local literals', () => {
assert.ok(isLoadBearing('Aldri GitHub. Kun Forgejo (`git.example.test`).'), 'code span + domain');
assert.ok(isLoadBearing('System bash er **3.2**: ingen declare -A.'), 'version pin');
assert.ok(isLoadBearing('Read the nearest STATE.md before starting.'), 'concrete filename');
assert.ok(isLoadBearing('Tokens live in ~/.zshenv on this machine.'), 'home path');
});
it('vetoes policy invariants regardless of phrasing (§6.0 carve-out)', () => {
assert.ok(isLoadBearing('ALDRI commit secrets.'), 'secrets are floor by decision');
});
it('does not veto generic behaviour correction carrying no local fact', () => {
assert.equal(isLoadBearing('Commit often with descriptive messages.'), false);
assert.equal(isLoadBearing('Think before you code and avoid unnecessary text.'), false);
});
it('vetoes an unresolvable mid-sentence entity, but not a bold label or a quoted opener', () => {
// The conservative default: a product/tool name is local vocabulary the
// mechanism cannot resolve without a dictionary, so it keeps the block.
assert.ok(isLoadBearing('push til deres egne Forgejo-remotes også'), 'mid-sentence entity');
// …but these are sentence openings dressed in punctuation. Reading them as
// entities cost 4 of 11 deletable groups in the dogfood run.
assert.equal(isLoadBearing('- **Format:** Konkret og handlingsorientert'), false, 'bold label');
assert.equal(isLoadBearing('- **Aldri:** "Som AI kan jeg ikke..."'), false, 'quoted opener + acronym');
});
it('does not treat a bare word/word as a path (it is not one)', () => {
// "pros/cons" vetoed the largest deletable block until PATH_RE was tightened.
assert.equal(isLoadBearing('Presenter alternativene med pros/cons og foreslå ett valg.'), false);
assert.ok(isLoadBearing('Store baselines under ~/.config-audit/baselines/.'), 'a real path still vetoes');
});
});
describe('subtraction-prefilter — Unicode word boundaries', () => {
// JS `\b` is ASCII-only, so /\bunngå\b/ never matches: the trailing "å" is
// not a word character. Every Norwegian keyword ending in æ/ø/å was silently
// dead until the dogfood run surfaced it.
it('matches Norwegian keywords ending in æ/ø/å', () => {
const c = subtractionCandidates('- Vær konkret og handlingsorientert, unngå overflødig tekst her.');
assert.equal(c.length, 1, '"unngå" must be detected despite the trailing å');
});
it('still requires a whole-word match', () => {
assert.deepEqual(subtractionCandidates('- Systemet er uunngåelig komplekst og stort.'), []);
});
});

View file

@ -61,6 +61,91 @@ describe('CPS scanner — does not duplicate TOK Pattern A territory', () => {
});
});
describe('CPS scanner — volatile content in @imported files (v5.10 B6)', () => {
it('flags volatile content inside an @imported file even when the root file is stable', async () => {
const result = await runScanner('import-volatile/positive');
const f = result.findings.find(x => /volatile content in @imported file/i.test(x.title || ''));
assert.ok(f, `expected @import-volatile finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, 'medium', `expected medium, got ${f.severity}`);
assert.match(String(f.evidence || ''), /imported by/i);
assert.match(String(f.evidence || ''), /BUILD_TIMESTAMP|git log|VAR substitution|shell-exec/i);
assert.equal(f.category, 'token-efficiency');
});
it('does NOT emit the in-file CPS finding when the importing file itself is stable', async () => {
// Root CLAUDE.md carries no volatile lines; only the @imported file does.
const result = await runScanner('import-volatile/positive');
const inFile = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || ''));
assert.equal(inFile, undefined, 'a byte-stable importing file must not trip the in-file finding');
});
it('does NOT flag a clean @imported file', async () => {
const result = await runScanner('import-volatile/clean');
const f = result.findings.find(x => /volatile content in @imported file/i.test(x.title || ''));
assert.equal(f, undefined, `expected no finding for a clean import; got: ${f?.title}`);
});
});
describe('CPS scanner — CC-stable vars are not cache-busters (M-BUG-7)', () => {
it('does NOT flag ${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_PROJECT_DIR} in prose', async () => {
// These are CC-provided path substitutions that resolve to a stable value
// every turn — referencing them in CLAUDE.md prose is not a cache-buster.
const result = await runScanner('cps-stable-vars/whitelisted');
const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || ''));
assert.equal(f, undefined,
`CC-stable path vars must not trip CPS; got: ${f?.evidence}`);
});
it('STILL flags a genuine non-CC ${VAR} substitution (whitelist is selective)', async () => {
const result = await runScanner('cps-stable-vars/non-whitelisted');
const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || ''));
assert.ok(f, `expected a finding for a genuine runtime var; got none`);
assert.match(String(f.evidence || ''), /line 40/);
assert.match(String(f.evidence || ''), /\$\{VAR\} substitution/i);
});
});
describe('CPS scanner — volatility inside fenced code blocks is documentation (M-BUG-7)', () => {
it('does NOT flag volatile-looking lines inside a ```fence```', async () => {
// Content inside a fenced code block is illustrative, byte-stable literal
// text — not runtime volatility — so it must not break the cached prefix.
const result = await runScanner('cps-fenced/inside-fence');
const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || ''));
assert.equal(f, undefined,
`fenced code content must not trip CPS; got: ${f?.evidence}`);
});
it('STILL flags genuine volatility in live prose outside the fence', async () => {
const result = await runScanner('cps-fenced/mixed');
const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || ''));
assert.ok(f, `expected a finding for the out-of-fence volatile line; got none`);
assert.match(String(f.evidence || ''), /line 50/);
assert.match(String(f.evidence || ''), /shell-exec/i);
assert.doesNotMatch(String(f.evidence || ''), /line 3[567]/,
'fenced lines 3537 must not appear in the evidence');
});
});
describe('CPS scanner — volatility inside inline code is documentation (M-BUG-7)', () => {
it('does NOT flag a {date} placeholder shown inside `inline code`', async () => {
// A filename template like `.claude/plans/run-{date}.md` in backticks is
// literal documentation text — byte-stable, not a runtime substitution.
const result = await runScanner('cps-inline-code/pure');
const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || ''));
assert.equal(f, undefined,
`inline-code documentation must not trip CPS; got: ${f?.evidence}`);
});
it('STILL flags a live ${VAR} that sits outside backticks', async () => {
const result = await runScanner('cps-inline-code/mixed');
const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || ''));
assert.ok(f, `expected a finding for the out-of-backtick volatile line; got none`);
assert.match(String(f.evidence || ''), /line 50/);
assert.doesNotMatch(String(f.evidence || ''), /line 40/,
'the backticked {date} on line 40 must not appear in the evidence');
});
});
describe('CPS scanner — orchestrator wiring', () => {
it('CPS appears in scan-orchestrator scanner list', async () => {
const orch = await import('../../scanners/scan-orchestrator.mjs');

View file

@ -172,6 +172,38 @@ describe('CML scanner — char budget mirrors CC startup warning (CC 2.1.169)',
});
});
describe('CML scanner — context-window calibration (B8)', () => {
const FIXTURE = resolve(FIXTURES, 'large-claude-chars'); // 48,531 chars
const charFinding = (r) =>
r.findings.find((f) => /performance-warning threshold/i.test(f.title || ''));
async function scanWithCtx(contextWindow) {
resetCounter();
const discovery = await discoverConfigFiles(FIXTURE);
return scan(FIXTURE, discovery, { contextWindow });
}
it('--context-window 1000000 relaxes the 40k char threshold so a 48k-char file does NOT fire', async () => {
const at1m = await scanWithCtx({ window: 1_000_000, advisory: false });
assert.equal(charFinding(at1m), undefined,
'48,531 chars is under the ~200,000-char threshold at a 1M window');
});
it('an unknown (advisory) window keeps the 40k anchor but downgrades to info', async () => {
const advisory = await scanWithCtx({ window: 200_000, advisory: true });
const f = charFinding(advisory);
assert.ok(f, 'still surfaces the measurement at the conservative anchor');
assert.equal(f.severity, 'info', 'advisory downgrades it from medium to info');
});
it('no opts (default) is unchanged: fires medium at the 40k anchor', async () => {
resetCounter();
const discovery = await discoverConfigFiles(FIXTURE);
const result = await scan(FIXTURE, discovery);
assert.equal(charFinding(result)?.severity, 'medium', 'default must stay byte-stable: medium');
});
});
describe('CML scanner — large-by-lines but under the char budget (no false char finding)', () => {
// large-cascade/CLAUDE.md is 1024 lines but only 37,393 chars (short lines):
// under CC's 40.0k char threshold, so the char-budget finding must NOT fire —

View file

@ -1,6 +1,6 @@
import { describe, it, beforeEach, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { resolve, join, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -208,3 +208,98 @@ describe('CNF scanner — cache exclusion drops duplicate-hook count (B3)', () =
`cache exclusion must drop CNF count: include=${includeCount} exclude=${excludeCount}`);
});
});
// M-BUG-2: CNF must segregate plugin-bundled configs from the user's authored
// cascade. Installed plugins ship their OWN settings.json / hooks.json — plus
// bundled test fixtures and examples — under ~/.claude/plugins/. None of these
// are user-authored config the user can edit, and a "conflict" between two
// plugins' bundled files is not something the user can resolve. Yet CNF compared
// every settings-json/hooks-json pairwise regardless of origin, inflating the
// Conflicts grade to F with hundreds of bogus findings (339 on this machine,
// ~315 of them high-severity permission "conflicts" between plugin test
// fixtures). CNF must exclude any file whose path is under `.claude/plugins/`.
// The exclusion belongs in CNF, NOT discovery: an active plugin's contributed
// hooks.json/.mcp.json legitimately lives in plugins/cache and other scanners
// need it — only conflict analysis must ignore it.
describe('CNF scanner — excludes plugin-bundled configs (M-BUG-2)', () => {
let dir, result, discovery;
before(async () => {
dir = join(tmpdir(), `config-audit-cnf-mbug2-${Date.now()}`);
// Live, user-authored cascade (project-scope settings).
const liveClaude = join(dir, '.claude');
await mkdir(liveClaude, { recursive: true });
await writeFile(join(liveClaude, 'settings.json'), JSON.stringify({
model: 'opus',
permissions: { deny: ['Bash(curl:*)'] },
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
}));
// An installed plugin ships its OWN settings.json + hooks.json (active version).
const p1 = join(liveClaude, 'plugins', 'cache', 'mkt', 'p1', '1.0.0');
await mkdir(join(p1, '.claude'), { recursive: true });
await writeFile(join(p1, '.claude', 'settings.json'), JSON.stringify({
model: 'sonnet',
permissions: { allow: ['Bash(curl:*)'] },
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
}));
await mkdir(join(p1, 'hooks'), { recursive: true });
await writeFile(join(p1, 'hooks', 'hooks.json'), JSON.stringify({
hooks: { PreToolUse: [{ matcher: 'Edit' }] },
}));
// Another plugin ships a TEST FIXTURE settings.json — pure noise, never live.
const p2fix = join(liveClaude, 'plugins', 'cache', 'mkt', 'p2', '2.0.0',
'tests', 'fixtures', 'proj', '.claude');
await mkdir(p2fix, { recursive: true });
await writeFile(join(p2fix, 'settings.json'), JSON.stringify({
model: 'haiku',
permissions: { allow: ['Bash(rm:*)'] },
}));
resetCounter();
discovery = await discoverConfigFiles(dir);
result = await scan(dir, discovery);
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('discovery surfaces the plugin-bundled settings (exclusion must be CNF-side, not discovery-side)', () => {
const pluginSettings = discovery.files.filter(
f => f.type === 'settings-json' && f.absPath.includes(`.claude${sep}plugins${sep}`));
assert.ok(pluginSettings.length >= 2,
`expected ≥2 plugin-bundled settings discovered; got ${pluginSettings.length}`);
});
it('does not flag any conflict sourced from plugin-bundled configs', () => {
assert.equal(result.findings.length, 0,
`expected 0 CNF findings (only noise is plugin-bundled); got ${result.findings.length}: ${result.findings.map(f => f.title).join(', ')}`);
});
});
// Guard against over-exclusion: the fix must NOT silence genuine conflicts
// between the user's own authored files (user/project/local), which are not
// under `.claude/plugins/`.
describe('CNF scanner — genuine live conflict still flagged (M-BUG-2 guard)', () => {
let dir, result;
before(async () => {
dir = join(tmpdir(), `config-audit-cnf-mbug2-guard-${Date.now()}`);
const claude = join(dir, '.claude');
await mkdir(claude, { recursive: true });
await writeFile(join(claude, 'settings.json'), JSON.stringify({ model: 'opus' }));
await writeFile(join(claude, 'settings.local.json'), JSON.stringify({ model: 'haiku' }));
resetCounter();
const discovery = await discoverConfigFiles(dir);
result = await scan(dir, discovery);
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('still flags the genuine cross-scope key conflict between user-authored files', () => {
assert.ok(result.findings.some(f => f.title.includes('model')),
`expected a genuine "model" conflict; got: ${result.findings.map(f => f.title).join(', ') || '(none)'}`);
});
});

View file

@ -2,9 +2,11 @@ import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { deleteBaseline } from '../../scanners/lib/baseline.mjs';
import { hermeticEnv, withHermeticHome } from '../helpers/hermetic-home.mjs';
import { hermeticEnv, withHermeticHome, HERMETIC_HOME } from '../helpers/hermetic-home.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
@ -81,3 +83,171 @@ describe('drift-cli compare', () => {
});
});
});
// ---------------------------------------------------------------------------
// M-BUG-21 — argument validation.
//
// The arg loop ended in `else if (!args[i].startsWith('-')) targetPath = args[i]`,
// so an UNKNOWN flag was dropped silently and its VALUE became the scan target.
// `drift-cli.mjs . --output-file /tmp/x.json` scanned /tmp/x.json — a path that
// does not exist — producing a near-empty scan and therefore phantom drift
// against the baseline, permanently and without a single warning.
// ---------------------------------------------------------------------------
/** Run the CLI capturing both streams regardless of exit code. */
function spawnCapture(args) {
const res = spawnSync('node', [DRIFT_CLI, ...args], RUN);
return { status: res.status, stdout: String(res.stdout || ''), stderr: String(res.stderr || '') };
}
/** Run the CLI expecting a non-zero exit; returns { status, stderr }. */
function runExpectingFailure(args) {
try {
execFileSync('node', [DRIFT_CLI, ...args], RUN);
return { status: 0, stderr: '' };
} catch (err) {
return { status: err.status, stderr: String(err.stderr || '') };
}
}
describe('drift-cli argument validation (M-BUG-21)', () => {
it('rejects an unknown flag instead of swallowing its value as the scan target', () => {
const { status, stderr } = runExpectingFailure([HEALTHY, '--bogus', 'some-value', '--json']);
assert.equal(status, 3, 'unknown flag must fail loudly, not scan "some-value"');
assert.match(stderr, /unknown option/i);
assert.match(stderr, /--bogus/);
});
it('rejects --name without a value instead of silently writing the default baseline', () => {
// The destructive case: `--save --name` (value missing) fell through to the
// default baseline name and OVERWROTE an existing baseline without warning.
const { status, stderr } = runExpectingFailure([HEALTHY, '--save', '--name']);
assert.equal(status, 3);
assert.match(stderr, /--name/);
assert.match(stderr, /requires a value/i);
});
it('rejects --baseline without a value', () => {
const { status, stderr } = runExpectingFailure([HEALTHY, '--baseline']);
assert.equal(status, 3);
assert.match(stderr, /--baseline/);
assert.match(stderr, /requires a value/i);
});
it('still accepts a bare path as the scan target', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE, '--json'], RUN);
const out = JSON.parse(
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], RUN)
);
assert.equal(out.summary.trend, 'stable');
});
});
// ---------------------------------------------------------------------------
// ux-rules rule 2 — every scanner Bash call uses `--output-file <path>`.
// drift-cli had no such flag, and its default-mode report goes to STDERR, so
// commands/drift.md ("... 2>/dev/null" + "Read stdout") captured NOTHING.
// ---------------------------------------------------------------------------
describe('drift-cli --output-file (ux-rules rule 2)', () => {
const outDir = mkdtempSync(join(tmpdir(), 'drift-outfile-'));
it('writes the diff to the given path in --json mode', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
const target = join(outDir, 'diff-json.json');
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json', '--output-file', target], RUN);
assert.ok(existsSync(target), '--output-file must create the file');
const written = JSON.parse(readFileSync(target, 'utf-8'));
assert.ok('summary' in written);
assert.ok('newFindings' in written);
});
it('writes the diff to the given path in default mode', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
const target = join(outDir, 'diff-default.json');
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--output-file', target], RUN);
assert.ok(existsSync(target), 'default mode must honour --output-file too');
const written = JSON.parse(readFileSync(target, 'utf-8'));
assert.ok('summary' in written);
});
it('does not treat the --output-file value as the scan target', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
const target = join(outDir, 'not-a-scan-target.json');
const stdout = execFileSync(
'node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json', '--output-file', target], RUN
);
// Scanning HEALTHY against a baseline of HEALTHY is stable. If the output
// path had been swallowed as the target, this would be phantom drift.
assert.equal(JSON.parse(stdout).summary.trend, 'stable');
});
});
// ---------------------------------------------------------------------------
// M-BUG-27 — baseline anchor guard.
//
// diff-engine never compared the baseline's target_path against the current
// scan target. Comparing a repo against a baseline saved from an unrelated
// directory yielded 100% phantom drift reported as trend "improving" — a
// reassuring, entirely false signal, and the DEFAULT behaviour (--baseline
// default). The warning goes to stderr in every mode; --raw stdout stays
// byte-identical to the frozen v5.0.0 snapshot.
// ---------------------------------------------------------------------------
describe('drift-cli baseline anchor guard (M-BUG-27)', () => {
const OTHER = resolve(FIXTURES, 'conflict-project');
it('warns when the baseline was anchored to a different target path', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
const res = spawnCapture([OTHER, '--baseline', TEST_BASELINE, '--json']);
assert.match(res.stderr, /different (target|path)/i, 'must warn about the anchor mismatch');
assert.match(res.stderr, /healthy-project/, 'warning should name the baseline target');
});
it('does not warn when the target paths match', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
const res = spawnCapture([HEALTHY, '--baseline', TEST_BASELINE, '--json']);
assert.doesNotMatch(res.stderr, /different (target|path)/i);
});
it('records the anchor mismatch in the --output-file payload (default mode)', () => {
// The stderr warning alone is not enough: commands/drift.md runs the CLI
// under `2>/dev/null`, so a stderr-only warning is invisible to the very
// caller that needs it. The machine-readable flag must ride in the file.
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
const target = join(mkdtempSync(join(tmpdir(), 'drift-anchor-')), 'diff.json');
spawnCapture([OTHER, '--baseline', TEST_BASELINE, '--output-file', target]);
const written = JSON.parse(readFileSync(target, 'utf-8'));
assert.ok(written._baselineAnchor, '_baselineAnchor must be present');
assert.equal(written._baselineAnchor.matches, false);
assert.match(written._baselineAnchor.baselineTarget, /healthy-project/);
assert.match(written._baselineAnchor.currentTarget, /conflict-project/);
});
it('marks the anchor as matching when targets agree', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
const target = join(mkdtempSync(join(tmpdir(), 'drift-anchor-ok-')), 'diff.json');
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--output-file', target], RUN);
const written = JSON.parse(readFileSync(target, 'utf-8'));
assert.equal(written._baselineAnchor.matches, true);
});
it('keeps --raw stdout free of the warning (frozen v5.0.0 contract)', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
const res = spawnCapture([OTHER, '--baseline', TEST_BASELINE, '--raw']);
const parsed = JSON.parse(res.stdout);
assert.ok(!('baselineAnchor' in parsed), '--raw stdout must stay v5.0.0-shaped');
assert.ok(!('targetMismatch' in parsed), '--raw stdout must stay v5.0.0-shaped');
});
});

View file

@ -1,11 +1,11 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan, opportunitySummary, bundledSkillsLeverFinding } from '../../scanners/feature-gap-scanner.mjs';
import { scan, opportunitySummary, bundledSkillsLeverFinding, cliOverMcpLeverFinding, filterHookLeverFinding } from '../../scanners/feature-gap-scanner.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { withHermeticHome } from '../helpers/hermetic-home.mjs';
@ -340,3 +340,169 @@ describe('GAP scanner — disableBundledSkills lever wiring (HOME-scoped)', () =
}
});
});
describe('cliOverMcpLeverFinding (CLI-over-MCP lever, v5.10 B4)', () => {
it('returns null when nothing is forced upfront', () => {
assert.equal(cliOverMcpLeverFinding({ assessment: { forcedUpfront: false } }), null);
assert.equal(cliOverMcpLeverFinding({ assessment: null }), null);
assert.equal(cliOverMcpLeverFinding({}), null);
});
it('fires a low-severity opportunity when MCP schemas are forced upfront', () => {
resetCounter();
const f = cliOverMcpLeverFinding({
assessment: {
forcedUpfront: true,
aggregateTokens: 2500,
affectedServers: [{ name: 'srv-a' }],
reason: 'enable-tool-search-false',
},
});
assert.ok(f, 'expected a lever finding');
assert.equal(f.severity, 'low');
assert.equal(f.category, 'token-efficiency');
assert.match(f.recommendation || '', /\bgh\b|\baws\b|\bgcloud\b/);
});
});
describe('filterHookLeverFinding (filter-before-Claude-reads lever, v5.10 B5)', () => {
it('returns null when no chatty hook was detected', () => {
assert.equal(filterHookLeverFinding({ flaggedHooks: [] }), null);
assert.equal(filterHookLeverFinding({}), null);
assert.equal(filterHookLeverFinding(), null);
});
it('fires an info opportunity when ≥1 chatty hook is detected', () => {
resetCounter();
const f = filterHookLeverFinding({
flaggedHooks: [{ event: 'SessionStart', scriptPath: '/x/hooks/scripts/chatty.sh' }],
});
assert.ok(f, 'expected a lever finding');
assert.equal(f.severity, 'info');
assert.equal(f.category, 'token-efficiency');
assert.match(f.description || '', /filter-test-output\.sh|filter/i);
assert.match(f.evidence || '', /chatty_hooks=1/);
});
});
describe('GAP scanner — filter-before lever wiring (chatty hook fixture)', () => {
it('emits the filter-before lever when scanning a repo with a chatty hook', async () => {
resetCounter();
const discovery = await fixtureDiscovery('hooks-additional-context');
const result = await withHermeticHome(() =>
scan(resolve(FIXTURES, 'hooks-additional-context'), discovery),
);
const lever = result.findings.find(
f => f.scanner === 'GAP' && /chatty_hooks=/.test(f.evidence || ''),
);
assert.ok(lever, `expected filter-before lever; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(lever.severity, 'info');
});
it('does NOT emit the lever for a repo with only quiet hooks', async () => {
resetCounter();
const discovery = await fixtureDiscovery('hooks-quiet');
const result = await withHermeticHome(() =>
scan(resolve(FIXTURES, 'hooks-quiet'), discovery),
);
const lever = result.findings.find(
f => f.scanner === 'GAP' && /chatty_hooks=/.test(f.evidence || ''),
);
assert.equal(lever, undefined, `expected no filter-before lever; got id=${lever?.id}`);
});
});
describe('GAP scanner — test/demo data must not mask real gaps (M-BUG-13)', () => {
// Build a throwaway project (+ optional hermetic HOME settings) and run GAP
// exactly as posture --global does: includeGlobal discovery + scan().
async function runGap({ projectFiles = {}, homeSettings = null } = {}) {
const project = await mkdtemp(join(tmpdir(), 'config-audit-gap-mask-proj-'));
const home = await mkdtemp(join(tmpdir(), 'config-audit-gap-mask-home-'));
for (const [rel, content] of Object.entries(projectFiles)) {
const abs = join(project, rel);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, content);
}
if (homeSettings) {
await mkdir(join(home, '.claude'), { recursive: true });
await writeFile(join(home, '.claude', 'settings.json'), JSON.stringify(homeSettings));
}
const original = process.env.HOME;
process.env.HOME = home;
try {
resetCounter();
const discovery = await discoverConfigFiles(project, { includeGlobal: true });
const result = await scan(project, discovery);
return result;
} finally {
process.env.HOME = original;
await rm(project, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
}
}
const hasGap = (result, titleRe) =>
result.findings.some(f => f.scanner === 'GAP' && titleRe.test(f.title || ''));
it('a nested examples/ settings.json does NOT satisfy the outputStyle check (settings-key)', async () => {
const result = await runGap({
projectFiles: {
'.claude/CLAUDE.md': '# proj',
'examples/optimal-setup/.claude/settings.json': JSON.stringify({ outputStyle: 'Explanatory' }),
},
});
assert.ok(
hasGap(result, /output style/i),
`example settings.json must not mask the outputStyle gap; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
it('a nested examples/ keybindings.json does NOT satisfy the keybindings check (file-type)', async () => {
const result = await runGap({
projectFiles: {
'.claude/CLAUDE.md': '# proj',
'examples/optimal-setup/.claude/keybindings.json': JSON.stringify({}),
},
});
assert.ok(
hasGap(result, /keybinding/i),
`example keybindings.json must not mask the keybindings gap; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
it('a nested tests/fixtures/ settings.json does NOT satisfy the model check', async () => {
const result = await runGap({
projectFiles: {
'.claude/CLAUDE.md': '# proj',
'tests/fixtures/demo/.claude/settings.json': JSON.stringify({ model: 'opus' }),
},
});
assert.ok(
hasGap(result, /model config/i),
`fixture settings.json must not mask the model gap; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
it("the project's OWN real .claude/settings.json IS counted (not over-excluded)", async () => {
const result = await runGap({
projectFiles: {
'.claude/CLAUDE.md': '# proj',
'.claude/settings.json': JSON.stringify({ outputStyle: 'Explanatory' }),
},
});
assert.ok(
!hasGap(result, /output style/i),
`real project settings.json must satisfy outputStyle; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
it('the real ~/.claude/settings.json IS seen for settings-key checks (gotcha fix end-to-end)', async () => {
const result = await runGap({
homeSettings: { statusLine: { type: 'command', command: 'x' } },
});
assert.ok(
!hasGap(result, /status line/i),
`~/.claude/settings.json statusLine must be discovered; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
});

View file

@ -79,8 +79,12 @@ describe('fix-cli --apply', () => {
// Verify backup exists. The CLI runs with a hermetic HOME (see execFileSync env
// below), so its backups land under HERMETIC_HOME, not the developer's real home.
const backupDir = join(HERMETIC_HOME, '.config-audit', 'backups', output.backupId);
const backupDir = join(HERMETIC_HOME, '.claude', 'config-audit', 'backups', output.backupId);
assert.ok(existsSync(backupDir), 'Backup directory should exist');
// …and nothing may land in the pre-v2.2.0 root, which is read-only now.
const legacyDir = join(HERMETIC_HOME, '.config-audit', 'backups', output.backupId);
assert.ok(!existsSync(legacyDir), 'Nothing may be written to the legacy backup root');
});
it('actually modifies files after --apply', async () => {

View file

@ -0,0 +1,90 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { assessHookAdditionalContext } from '../../scanners/lib/hook-additional-context.mjs';
// B5 (v5.10) — pure heuristic: does a hook script build
// hookSpecificOutput.additionalContext from un-grepped / verbose command output?
// Low precision by design (ships as an info advisory, never severity-bearing).
describe('assessHookAdditionalContext — not applicable', () => {
it('empty / missing input → not flagged', () => {
assert.equal(assessHookAdditionalContext({}).flagged, false);
assert.equal(assessHookAdditionalContext({ scriptContent: '' }).flagged, false);
assert.equal(assessHookAdditionalContext().flagged, false);
});
it('script with no additionalContext reference → not flagged even if it cats a file', () => {
const a = assessHookAdditionalContext({
scriptContent: 'CONTENT="$(cat /var/log/big.log)"\necho "$CONTENT"\n',
});
assert.equal(a.buildsAdditionalContext, false);
assert.equal(a.flagged, false);
});
});
describe('assessHookAdditionalContext — shell hooks', () => {
it('additionalContext built from an unfiltered cat → flagged', () => {
const a = assessHookAdditionalContext({
scriptContent:
'BODY="$(cat /tmp/notes.md)"\n' +
'echo "{\\"hookSpecificOutput\\":{\\"additionalContext\\":\\"$BODY\\"}}"\n',
});
assert.equal(a.buildsAdditionalContext, true);
assert.equal(a.hasVerboseCapture, true);
assert.equal(a.hasFilter, false);
assert.equal(a.capturesUnfiltered, true);
assert.equal(a.flagged, true);
});
it('additionalContext from a git log → flagged', () => {
const a = assessHookAdditionalContext({
scriptContent:
'LOG="$(git log --oneline)"\n' +
'printf \'{"hookSpecificOutput":{"additionalContext":"%s"}}\' "$LOG"\n',
});
assert.equal(a.flagged, true);
});
it('additionalContext from cat piped through grep → NOT flagged (filtered)', () => {
const a = assessHookAdditionalContext({
scriptContent:
'BODY="$(cat /tmp/test.log | grep ERROR)"\n' +
'echo "{\\"hookSpecificOutput\\":{\\"additionalContext\\":\\"$BODY\\"}}"\n',
});
assert.equal(a.hasFilter, true);
assert.equal(a.capturesUnfiltered, false);
assert.equal(a.flagged, false);
});
it('additionalContext from a cheap command only ($(date)) → NOT flagged', () => {
const a = assessHookAdditionalContext({
scriptContent:
'NOW="$(date)"\n' +
'echo "{\\"hookSpecificOutput\\":{\\"additionalContext\\":\\"$NOW\\"}}"\n',
});
assert.equal(a.hasVerboseCapture, false);
assert.equal(a.flagged, false);
});
});
describe('assessHookAdditionalContext — node hooks', () => {
it('execSync(git log) into additionalContext with no slice → flagged', () => {
const a = assessHookAdditionalContext({
scriptContent:
"const out = execSync('git log --oneline').toString();\n" +
"process.stdout.write(JSON.stringify({ hookSpecificOutput: { additionalContext: out } }));\n",
});
assert.equal(a.flagged, true);
});
it('readFileSync sliced before additionalContext → NOT flagged (bounded)', () => {
const a = assessHookAdditionalContext({
scriptContent:
"const raw = readFileSync('big.log', 'utf8');\n" +
"const out = raw.slice(0, 400);\n" +
"process.stdout.write(JSON.stringify({ hookSpecificOutput: { additionalContext: out } }));\n",
});
assert.equal(a.hasFilter, true);
assert.equal(a.flagged, false);
});
});

View file

@ -101,6 +101,46 @@ describe('HKV scanner — verbose hook output (v5 M5)', () => {
});
});
describe('HKV scanner — additionalContext injection advisory (v5.10 B5)', () => {
it('flags a hook that injects unfiltered command output into additionalContext (info advisory)', async () => {
resetCounter();
const path = resolve(FIXTURES, 'hooks-additional-context');
const discovery = await discoverConfigFiles(path);
const result = await scan(path, discovery);
const f = result.findings.find(
x => x.scanner === 'HKV' && /additional_context_unfiltered=true/.test(x.evidence || ''),
);
assert.ok(f, `expected additionalContext advisory; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, 'info', `expected info (advisory), got ${f.severity}`);
// The chatty.sh script (SessionStart) is the one flagged, not filtered.sh.
assert.match(f.file || '', /chatty\.sh$/);
// Precision caveat must be disclosed in the description (low-precision advisory).
assert.match(f.description || '', /every time|enters Claude's context|advisory/i);
});
it('does NOT flag the filtered (grep|head) sibling hook', async () => {
resetCounter();
const path = resolve(FIXTURES, 'hooks-additional-context');
const discovery = await discoverConfigFiles(path);
const result = await scan(path, discovery);
const filtered = result.findings.find(
x => x.scanner === 'HKV' && /additional_context_unfiltered=true/.test(x.evidence || '') && /filtered\.sh$/.test(x.file || ''),
);
assert.equal(filtered, undefined, `filtered.sh should not be flagged; got id=${filtered?.id}`);
});
it('does NOT flag a quiet hook with no additionalContext', async () => {
resetCounter();
const path = resolve(FIXTURES, 'hooks-quiet');
const discovery = await discoverConfigFiles(path);
const result = await scan(path, discovery);
const f = result.findings.find(
x => x.scanner === 'HKV' && /additional_context_unfiltered=true/.test(x.evidence || ''),
);
assert.equal(f, undefined, `expected no additionalContext advisory; got id=${f?.id}`);
});
});
describe('HKV scanner — CC 2.1.152 MessageDisplay event (Batch 1 false-positive fix)', () => {
// The pre-write path-guard blocks committing settings.json/hooks.json, so
// this suite materializes a hermetic temp fixture at runtime.

View file

@ -9,10 +9,18 @@ import { tmpdir } from 'node:os';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const CLI = resolve(__dirname, '../../scanners/knowledge-refresh-cli.mjs');
// The bundled register's seed entries are all verified 2026-06-20, so a reference
// date of 2026-06-21 makes every entry exactly 1 day old — deterministic regardless
// of the real clock. We exploit that to exercise both the fresh and stale branches.
const REF = '2026-06-21';
// The reference date must sit AFTER the newest entry's `verified` date, so every
// entry is at least one day old and both the fresh and stale branches are
// reachable deterministically, regardless of the real clock.
//
// It used to be 2026-06-21, on the premise that every seed entry was verified
// 2026-06-20. That premise expired when BP-SUB-001 was added with a genuine
// 2026-07-31 verification date — an entry verified *after* the reference date has
// a negative age and counts as fresh, so `--stale-after 0` no longer emptied the
// fresh bucket. Backdating the entry to fit the test would have been a lie about
// when its source was checked; moving the reference date is the honest fix.
// Bump this again when a newer entry lands.
const REF = '2026-08-01';
function runCli(extraArgs) {
try {
@ -56,7 +64,7 @@ describe('knowledge-refresh-cli — payload shape', () => {
const out = JSON.parse(stdout);
assert.equal(out.status, 'ok');
assert.match(out.registerPath, /knowledge\/best-practices\.json$/);
assert.equal(out.referenceDate, '2026-06-21');
assert.equal(out.referenceDate, REF);
assert.equal(out.staleAfterDays, 90);
assert.ok(Array.isArray(out.stale));
assert.ok(Array.isArray(out.fresh));
@ -78,7 +86,12 @@ describe('knowledge-refresh-cli — payload shape', () => {
const s = out.stale[0];
assert.match(s.id, /^BP-[A-Z]+-\d{3}$/);
assert.match(s.verified, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(s.ageDays, 1);
// Was `=== 1`, which only held while every entry shared one verified date.
// Assert the invariant that actually matters: ageDays is a positive integer
// and agrees with this entry's own `verified` date against the reference.
const expectedAge = Math.round((Date.parse(REF) - Date.parse(s.verified)) / 86400000);
assert.equal(s.ageDays, expectedAge);
assert.ok(s.ageDays >= 1, 'a stale entry is at least a day old');
assert.ok(s.url && s.claim);
});
});

View file

@ -136,6 +136,36 @@ describe('MCP scanner — stray `trust` field is an unknown field (verify-first,
});
});
describe('MCP scanner — `alwaysLoad` is a valid field (v5.10 B4, verify-first 2026-06-23)', () => {
let result;
let tmpRoot;
beforeEach(async () => {
resetCounter();
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-mcp-alwaysload-'));
// alwaysLoad exempts 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.
const mcp = {
mcpServers: {
core: { type: 'http', url: 'https://mcp.example.com/mcp', alwaysLoad: true },
},
};
await writeFile(join(tmpRoot, '.mcp.json'), JSON.stringify(mcp, null, 2) + '\n', 'utf8');
const discovery = await discoverConfigFiles(tmpRoot);
result = await scan(tmpRoot, discovery);
});
afterEach(async () => {
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
});
it('does NOT flag `alwaysLoad` as an unknown MCP server field', () => {
const f = result.findings.find(x => x.title.includes('Unknown MCP server field')
&& /alwaysLoad/.test(x.description || ''));
assert.equal(f, undefined, 'alwaysLoad is a valid field and must not be flagged');
});
});
describe('MCP scanner — env-var false positives (CC 2.1.139/2.1.142, Batch 1)', () => {
let tmpRoot;
let envFindings;

View file

@ -0,0 +1,192 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
assessMcpDeferral,
severityForForcedSchemas,
FORCED_SCHEMA_TOKENS_HIGH,
FORCED_SCHEMA_TOKENS_MEDIUM,
} from '../../scanners/lib/mcp-deferral.mjs';
// A small helper to build server shapes the way active-config-reader exposes them.
const srv = (over = {}) => ({
name: 'srv',
source: '.mcp.json',
enabled: true,
toolCount: 10,
estimatedTokens: 2500,
alwaysLoad: false,
...over,
});
describe('assessMcpDeferral — default (deferred) case', () => {
it('no settings + a plain server → tool search NOT disabled, nothing forced upfront', () => {
const a = assessMcpDeferral({ settings: {}, mcpServers: [srv()] });
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.reason, null);
assert.equal(a.confidence, null);
assert.equal(a.forcedUpfront, false);
assert.deepEqual(a.affectedServers, []);
assert.equal(a.aggregateTokens, 0);
});
it('empty inputs are tolerated', () => {
const a = assessMcpDeferral({});
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.forcedUpfront, false);
});
});
describe('assessMcpDeferral — config-file disabling signals (HIGH confidence)', () => {
it('settings.env.ENABLE_TOOL_SEARCH="false" → disabled, high, all servers affected', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' } },
mcpServers: [srv({ name: 'a', estimatedTokens: 1000 }), srv({ name: 'b', estimatedTokens: 2000 })],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'enable-tool-search-false');
assert.equal(a.confidence, 'high');
assert.equal(a.forcedUpfront, true);
assert.equal(a.affectedServers.length, 2);
assert.equal(a.aggregateTokens, 3000);
});
it('permissions.deny including bare "ToolSearch" → disabled, high', () => {
const a = assessMcpDeferral({
settings: { permissions: { deny: ['Read(./.env)', 'ToolSearch'] } },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'deny-tool-search');
assert.equal(a.confidence, 'high');
});
it('explicit ENABLE_TOOL_SEARCH="false" wins over a haiku model for the reason label', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' }, model: 'claude-haiku-4-5' },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'enable-tool-search-false');
assert.equal(a.confidence, 'high');
});
});
describe('assessMcpDeferral — configured Haiku model (MEDIUM confidence)', () => {
it('settings.model matching /haiku/ → disabled, medium, haiku-model', () => {
const a = assessMcpDeferral({
settings: { model: 'claude-haiku-4-5-20251001' },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'haiku-model');
assert.equal(a.confidence, 'medium');
assert.equal(a.forcedUpfront, true);
});
it('haiku disables even when ENABLE_TOOL_SEARCH="true" (Haiku lacks tool_reference support)', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'true' }, model: 'haiku' },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'haiku-model');
});
});
describe('assessMcpDeferral — non-disabling values', () => {
it('ENABLE_TOOL_SEARCH="true" (non-haiku) → NOT disabled', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'true' }, model: 'claude-sonnet-4-6' },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.forcedUpfront, false);
});
it('ENABLE_TOOL_SEARCH="auto" → threshold mode, NOT disabled', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'auto' } },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.thresholdMode, true);
assert.equal(a.forcedUpfront, false);
});
it('ENABLE_TOOL_SEARCH="auto:5" → threshold mode', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'auto:5' } },
mcpServers: [srv()],
});
assert.equal(a.thresholdMode, true);
assert.equal(a.toolSearchDisabled, false);
});
});
describe('assessMcpDeferral — per-server alwaysLoad', () => {
it('alwaysLoad:true server with tool search enabled → that server forced upfront only', () => {
const a = assessMcpDeferral({
settings: {},
mcpServers: [
srv({ name: 'always', alwaysLoad: true, estimatedTokens: 1500 }),
srv({ name: 'deferred', alwaysLoad: false, estimatedTokens: 9000 }),
],
});
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.forcedUpfront, true);
assert.equal(a.alwaysLoadServers.length, 1);
assert.equal(a.affectedServers.length, 1);
assert.equal(a.affectedServers[0].name, 'always');
assert.equal(a.aggregateTokens, 1500);
});
it('when tool search is disabled, ALL active servers are affected (not just alwaysLoad)', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' } },
mcpServers: [
srv({ name: 'always', alwaysLoad: true, estimatedTokens: 1500 }),
srv({ name: 'deferred', alwaysLoad: false, estimatedTokens: 2500 }),
],
});
assert.equal(a.affectedServers.length, 2);
assert.equal(a.aggregateTokens, 4000);
});
});
describe('assessMcpDeferral — server filtering', () => {
it('disabled servers (enabled:false) are excluded from affected + aggregate', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' } },
mcpServers: [
srv({ name: 'on', enabled: true, estimatedTokens: 1000 }),
srv({ name: 'off', enabled: false, estimatedTokens: 5000 }),
],
});
assert.equal(a.affectedServers.length, 1);
assert.equal(a.affectedServers[0].name, 'on');
assert.equal(a.aggregateTokens, 1000);
});
it('no active servers → not forced upfront even when tool search disabled', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' } },
mcpServers: [],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.forcedUpfront, false);
assert.equal(a.aggregateTokens, 0);
});
});
describe('severityForForcedSchemas', () => {
it('high confidence scales with aggregate tokens', () => {
assert.equal(severityForForcedSchemas(FORCED_SCHEMA_TOKENS_HIGH, 'high'), 'high');
assert.equal(severityForForcedSchemas(FORCED_SCHEMA_TOKENS_MEDIUM, 'high'), 'medium');
assert.equal(severityForForcedSchemas(100, 'high'), 'low');
});
it('medium confidence is capped at medium', () => {
assert.equal(severityForForcedSchemas(FORCED_SCHEMA_TOKENS_HIGH * 10, 'medium'), 'medium');
assert.equal(severityForForcedSchemas(100, 'medium'), 'low');
});
});

View file

@ -0,0 +1,102 @@
/**
* optimize-lens-cli tests payload scoping + candidate identity (M-BUG-11).
*
* The lens CLI feeds the precision-gate agent. Two correctness invariants it must
* hold, both established elsewhere in the codebase but originally missing here:
*
* 1. SCOPING plugin-bundled CLAUDE.md (anything under `.claude/plugins/`:
* vendored plugin config + its bundled tests/fixtures + examples, active or
* stale) is NOT the user's authored config. The user can't act on a
* mechanism-fit suggestion against a file a plugin ships. So the lens must
* drop them the M-BUG-2 `isPluginBundled` rule, applied to the lens.
*
* 2. IDENTITY a candidate's `file` must uniquely name a readable file. The
* user-global `~/.claude/CLAUDE.md` and a repo-root `CLAUDE.md` both have
* relPath `CLAUDE.md`; labelling candidates by relPath collides them, and
* the agent's `Read(file)` then resolves the wrong one. Candidates carry an
* absolute path.
*
* Hermetic: a temp target with a real CLAUDE.md and a nested plugin-bundled one.
* `.claude/plugins/` is not in SKIP_DIRS, so the normal walk discovers it
* no ~/.claude / --global needed.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { join, isAbsolute } from 'node:path';
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
const execFileP = promisify(execFile);
const CLI = fileURLToPath(new URL('../../scanners/optimize-lens-cli.mjs', import.meta.url));
/** Build a temp target, run the lens CLI on it, return the parsed payload. */
async function runLens(files) {
const root = await mkdtemp(join(tmpdir(), 'ca-lens-cli-'));
try {
for (const [rel, content] of Object.entries(files)) {
const abs = join(root, rel);
await mkdir(join(abs, '..'), { recursive: true });
await writeFile(abs, content, 'utf-8');
}
const out = join(root, 'payload.json');
await execFileP('node', [CLI, root, '--output-file', out]);
return JSON.parse(await readFile(out, 'utf-8'));
} finally {
await rm(root, { recursive: true, force: true });
}
}
const REAL = '# Project\n\nNever commit secrets to the repo.\n';
const PLUGIN = '# Vendored plugin\n\nNever delete the plugin cache directory.\n';
describe('optimize-lens-cli — scoping (M-BUG-11)', () => {
it('excludes plugin-bundled CLAUDE.md from candidates', async () => {
const payload = await runLens({
'CLAUDE.md': REAL,
'.claude/plugins/cache/mkt/plug/1.0.0/CLAUDE.md': PLUGIN,
});
const bundled = payload.candidates.filter(
(c) => c.file.includes(`.claude/plugins/`) || c.file.includes(`/plugins/`),
);
assert.deepEqual(
bundled.map((c) => c.file),
[],
'no candidate should come from a file under .claude/plugins/',
);
});
it('still surfaces the users real CLAUDE.md', async () => {
const payload = await runLens({
'CLAUDE.md': REAL,
'.claude/plugins/cache/mkt/plug/1.0.0/CLAUDE.md': PLUGIN,
});
const real = payload.candidates.filter((c) => c.file.endsWith(`${join('', 'CLAUDE.md')}`));
assert.ok(real.length >= 1, 'the real CLAUDE.md never-instruction should survive scoping');
});
it('excludes plugin-bundled CLAUDE.md from deterministic findings too', async () => {
const procedure =
'# Plugin release\n\n' +
Array.from({ length: 7 }, (_, i) => `${i + 1}. Do release step ${i + 1} and verify.`).join('\n') +
'\n';
const payload = await runLens({
'CLAUDE.md': REAL,
'.claude/plugins/cache/mkt/plug/1.0.0/CLAUDE.md': procedure,
});
const bundled = (payload.deterministic || []).filter((f) => String(f.file).includes('plugins/'));
assert.deepEqual(bundled, [], 'deterministic CA-OPT-001 must not fire on vendored plugin CLAUDE.md');
});
});
describe('optimize-lens-cli — candidate identity (M-BUG-11)', () => {
it('labels every candidate with an absolute, unique path', async () => {
const payload = await runLens({ 'CLAUDE.md': REAL });
assert.ok(payload.candidates.length >= 1, 'expected at least one candidate');
for (const c of payload.candidates) {
assert.ok(isAbsolute(c.file), `candidate file must be absolute, got: ${c.file}`);
}
});
});

View file

@ -1,6 +1,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, dirname } from 'node:path';
import { resolve, dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
@ -141,4 +142,44 @@ describe('posture humanizer wiring (Step 6)', () => {
'humanized stderr must differ from v5.0.0 verbatim stderr');
});
});
// M-BUG-12: feature-gap.md/posture.md read findings from posture.mjs --output-file
// and group on humanizer fields (userActionLanguage etc.). Default-mode output-file
// must therefore humanize findings inside the nested scannerEnvelope; --raw stays raw.
describe('default mode --output-file (M-BUG-12: humanized findings)', () => {
it('writes humanized GAP findings (userActionLanguage defined) to the output file', async () => {
const tmp = join(tmpdir(), `ca-posture-outfile-${process.pid}.json`);
try {
await runPosture(['--output-file', tmp]);
const env = JSON.parse(await readFile(tmp, 'utf-8'));
const gap = env.scannerEnvelope.scanners.find(s => s.scanner === 'GAP');
assert.ok(gap, 'GAP scanner must be present in the output file');
assert.ok(gap.findings.length > 0, 'fixture must yield at least one GAP finding');
for (const f of gap.findings) {
assert.notEqual(f.userActionLanguage, undefined,
`${f.id}: default-mode output-file findings must carry userActionLanguage`);
assert.notEqual(f.userImpactCategory, undefined,
`${f.id}: default-mode output-file findings must carry userImpactCategory`);
}
} finally {
await unlink(tmp).catch(() => {});
}
});
it('--raw --output-file keeps v5.0.0 raw finding shape (no humanizer fields)', async () => {
const tmp = join(tmpdir(), `ca-posture-outfile-raw-${process.pid}.json`);
try {
await runPosture(['--raw', '--output-file', tmp]);
const env = JSON.parse(await readFile(tmp, 'utf-8'));
for (const s of env.scannerEnvelope.scanners) {
for (const f of s.findings) {
assert.equal(f.userActionLanguage, undefined,
`${f.id}: --raw output-file must not carry userActionLanguage`);
}
}
} finally {
await unlink(tmp).catch(() => {});
}
});
});
});

View file

@ -7,6 +7,13 @@ import { tmpdir, homedir } from 'node:os';
import { createBackup, getBackupDir, checksum } from '../../scanners/lib/backup.mjs';
import { listBackups, restoreBackup, deleteBackup } from '../../scanners/rollback-engine.mjs';
// Keep every backup this file creates inside a temp root. Without this the
// suite writes into the operator's real ~/.claude/config-audit/backups, where
// cleanupOldBackups() would start deleting genuine backups past MAX_BACKUPS.
const TEST_BACKUP_ROOT = join(tmpdir(), `config-audit-rb-root-${process.pid}`);
process.env.CONFIG_AUDIT_BACKUP_ROOT = TEST_BACKUP_ROOT;
process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = join(TEST_BACKUP_ROOT, 'legacy');
/** Create a temp file and back it up, returning paths and content. */
async function setupTestBackup() {
const tmpDir = join(tmpdir(), `config-audit-rb-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);

View file

@ -0,0 +1,267 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { tmpdir, homedir } from 'node:os';
import { resolve } from 'node:path';
import {
createBackup,
getBackupDir,
getLegacyBackupDir,
parseManifest,
} from '../../scanners/lib/backup.mjs';
import { listBackups, restoreBackup } from '../../scanners/rollback-engine.mjs';
// ========================================
// Backup root — canonical vs legacy (M-BUG-22)
//
// Dogfood step 4 found the rollback engine reading ~/.config-audit/backups
// while every command, agent and doc writes to ~/.claude/config-audit/backups.
// listBackups() saw 0 of the 4 real backups on the operator's machine and
// restoreBackup() threw "Backup not found" for a backup that was right there.
// ========================================
const CANONICAL = join(homedir(), '.claude', 'config-audit', 'backups');
const LEGACY = join(homedir(), '.config-audit', 'backups');
/** Unique temp dir per test, cleaned up by the caller. */
function tempRoot(tag) {
const d = join(tmpdir(), `ca-rbpaths-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(d, { recursive: true });
return d;
}
describe('backup root resolution', () => {
const saved = {};
beforeEach(() => {
saved.root = process.env.CONFIG_AUDIT_BACKUP_ROOT;
saved.legacy = process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
});
afterEach(() => {
if (saved.root === undefined) delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_BACKUP_ROOT = saved.root;
if (saved.legacy === undefined) delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = saved.legacy;
});
it('getBackupDir() defaults to the canonical ~/.claude/config-audit/backups', () => {
assert.strictEqual(getBackupDir(), CANONICAL);
});
it('getLegacyBackupDir() exposes the pre-v2.2.0 path', () => {
assert.strictEqual(getLegacyBackupDir(), LEGACY);
});
it('the two roots are distinct (guards against a copy-paste fix)', () => {
assert.notStrictEqual(getBackupDir(), getLegacyBackupDir());
});
it('getBackupDir() honours CONFIG_AUDIT_BACKUP_ROOT so tests stay hermetic', () => {
const root = tempRoot('env');
try {
process.env.CONFIG_AUDIT_BACKUP_ROOT = root;
assert.strictEqual(getBackupDir(), root);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('createBackup() writes under the overridden root, not the real home (M-BUG-24)', () => {
const root = tempRoot('write');
const work = tempRoot('work');
try {
process.env.CONFIG_AUDIT_BACKUP_ROOT = root;
const target = join(work, 'settings.json');
writeFileSync(target, '{"original": true}');
const { backupPath } = createBackup([target]);
assert.ok(backupPath.startsWith(root), `backup landed outside the override: ${backupPath}`);
assert.ok(!backupPath.startsWith(homedir() + '/.config-audit'), 'must not write to the real home');
} finally {
rmSync(root, { recursive: true, force: true });
rmSync(work, { recursive: true, force: true });
}
});
});
describe('listBackups / restoreBackup across both roots', () => {
const saved = {};
let canonical, legacy, work;
beforeEach(() => {
saved.root = process.env.CONFIG_AUDIT_BACKUP_ROOT;
saved.legacy = process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
canonical = tempRoot('canon');
legacy = tempRoot('leg');
work = tempRoot('work');
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = legacy;
});
afterEach(() => {
if (saved.root === undefined) delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_BACKUP_ROOT = saved.root;
if (saved.legacy === undefined) delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = saved.legacy;
for (const d of [canonical, legacy, work]) rmSync(d, { recursive: true, force: true });
});
it('lists a backup that lives in the canonical root', async () => {
const target = join(work, 'settings.json');
writeFileSync(target, '{"original": true}');
const { backupId } = createBackup([target]);
const { backups } = await listBackups();
assert.ok(backups.some(b => b.id === backupId), 'canonical backup must be listed');
});
it('still lists backups left behind in the legacy root', async () => {
// Seed a legacy-root backup by pointing the writer at it temporarily.
const target = join(work, 'legacy.json');
writeFileSync(target, '{"legacy": true}');
process.env.CONFIG_AUDIT_BACKUP_ROOT = legacy;
const { backupId } = createBackup([target]);
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
const { backups } = await listBackups();
const found = backups.find(b => b.id === backupId);
assert.ok(found, 'a pre-v2.2.0 backup must not become invisible after the path fix');
assert.strictEqual(found.legacy, true, 'legacy backups should be flagged as such');
});
it('restores a backup that only exists in the legacy root', async () => {
const target = join(work, 'legacy-restore.json');
writeFileSync(target, '{"original": true}');
process.env.CONFIG_AUDIT_BACKUP_ROOT = legacy;
const { backupId } = createBackup([target]);
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
writeFileSync(target, '{"modified": true}');
const result = await restoreBackup(backupId);
assert.strictEqual(result.failed.length, 0, 'no failures expected');
assert.strictEqual(result.restored.length, 1, 'the legacy backup should resolve');
assert.strictEqual(await readFile(target, 'utf-8'), '{"original": true}');
});
});
// ========================================
// Manifest compatibility (M-BUG-25)
//
// The implement flow hand-builds its manifest (commands/implement.md tells the
// agent to mkdir + cp), so real backups on disk use `- backup:` / `original:` /
// `sha256:` while parseManifest only understood the engine's quoted
// `original_path:` / `backup_path:` / `checksum:`. Result: parseManifest
// returned files: [] and restoreBackup reported success having restored
// nothing — a success-shaped no-op, the worst failure mode in the file.
// ========================================
const ENGINE_MANIFEST = `created_at: "2026-07-17T03:26:36.000Z"
backup_id: "20260717_032636"
files:
- original_path: "/tmp/x/CLAUDE.md"
backup_path: "./files/_tmp_x_CLAUDE.md"
checksum: "6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12"
size_bytes: 42
`;
const IMPLEMENT_MANIFEST = `session: 20260717_step3impl
created: 20260717_032636
target_root: /tmp/x
files:
- backup: files/project-claude/settings.local.json
original: /tmp/x/.claude/settings.local.json
sha256: f03f45699568218df0dce447d82954eeece34264f47783a1a2ff6b10cdc43ef9
- backup: files/nested-claude/settings.local.json
original: /tmp/x/posts/2026-01-23-ralph-wiggum/.claude/settings.local.json
sha256: 39a3723441892ce1cf987508fb448b6a656b65c8d194d1640c1915cd9098d3c6
- backup: files/root/CLAUDE.md
original: /tmp/x/CLAUDE.md
sha256: 6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12
`;
describe('parseManifest format compatibility', () => {
it('still parses the engine format unchanged', () => {
const m = parseManifest(ENGINE_MANIFEST);
assert.strictEqual(m.backup_id, '20260717_032636');
assert.strictEqual(m.files.length, 1);
assert.strictEqual(m.files[0].originalPath, '/tmp/x/CLAUDE.md');
assert.strictEqual(m.files[0].backupPath, './files/_tmp_x_CLAUDE.md');
assert.strictEqual(m.files[0].sizeBytes, 42);
});
it('parses the implement-flow format written by the agent', () => {
const m = parseManifest(IMPLEMENT_MANIFEST);
assert.strictEqual(m.files.length, 3, 'all three entries must be recognised');
assert.strictEqual(m.files[0].originalPath, '/tmp/x/.claude/settings.local.json');
assert.strictEqual(m.files[0].backupPath, 'files/project-claude/settings.local.json');
assert.strictEqual(
m.files[2].checksum,
'6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12',
);
});
it('picks up the implement-flow backup id from `created:`', () => {
const m = parseManifest(IMPLEMENT_MANIFEST);
assert.strictEqual(m.backup_id, '20260717_032636');
});
it('never returns a silent empty file list for a manifest that has entries', () => {
for (const [label, content] of [['engine', ENGINE_MANIFEST], ['implement', IMPLEMENT_MANIFEST]]) {
assert.ok(parseManifest(content).files.length > 0, `${label} manifest parsed to zero files`);
}
});
});
// ========================================
// Files created by implement are not restorable (B1 / M-BUG-26)
//
// A backup records only files that already existed. Rollback therefore cannot
// remove what implement CREATED. That is acceptable; doing it silently is not.
// ========================================
describe('created-file reporting', () => {
it('surfaces manifest `created:` entries so rollback can tell the user what it cannot undo', () => {
const manifest = IMPLEMENT_MANIFEST + `created:
- /tmp/x/.claude/rules/post-quality.md
- /tmp/x/guidelines/posting-rhythm.md
`;
const m = parseManifest(manifest);
assert.deepStrictEqual(m.created, [
'/tmp/x/.claude/rules/post-quality.md',
'/tmp/x/guidelines/posting-rhythm.md',
]);
});
it('defaults `created` to an empty array when the manifest has no such section', () => {
assert.deepStrictEqual(parseManifest(ENGINE_MANIFEST).created, []);
});
});
// ========================================
// Session-dir hooks (M-BUG-23)
//
// Both hooks watched ~/.config-audit/sessions, which does not exist on the
// operator's machine — sessions live in ~/.claude/config-audit/sessions. The
// hooks exited 0 every time, so "check for active sessions" never fired.
// Shape test: same layer as tests/commands/implement-log-append.test.mjs.
// ========================================
describe('session hooks point at the canonical sessions dir', () => {
for (const hook of ['session-start.mjs', 'stop-session-reminder.mjs']) {
it(`${hook} resolves ~/.claude/config-audit/sessions`, () => {
const src = readFileSync(resolve(import.meta.dirname, '../../hooks/scripts', hook), 'utf-8');
assert.match(
src,
/join\(\s*homedir\(\)\s*,\s*'\.claude'\s*,\s*'config-audit'\s*,\s*'sessions'\s*\)/,
`${hook} must watch the canonical sessions dir`,
);
});
}
});

View file

@ -200,3 +200,132 @@ describe('RUL — block-sequence-scoped rule is correctly scoped (parser regress
assert.equal(f.severity, 'low');
});
});
describe('RUL — nested-repo rule glob resolves to its own project root (M-BUG-9)', () => {
// A rule living in a NESTED repo's .claude/rules/ scopes its paths: pattern
// relative to that nested repo's root — NOT relative to the outer scan root.
// Before the fix, countGlobMatches globbed against the scan root and
// collectProjectFiles' depth>4 cutoff never reached the deep matching files,
// so a live rule was wrongly flagged "matches no files / never activates".
// (Real-machine surface: ~/.claude/plugins/marketplaces/ktg-privat/.claude/rules/.)
let tmpRoot;
let result;
async function writeNestedRepo(scanRoot) {
// Nested repo sits 3 dirs below the scan root → its matching files land
// past the old depth>4 cutoff when walked from the scan root.
const nested = join(scanRoot, 'level1', 'level2', 'nested-repo');
await mkdir(join(nested, '.claude', 'rules'), { recursive: true });
await mkdir(join(nested, 'plugins', 'app'), { recursive: true });
await mkdir(join(nested, 'plugins', 'app', 'hooks'), { recursive: true });
// Files that the rule patterns match — relative to the NESTED repo root.
await writeFile(join(nested, 'plugins', 'app', 'CLAUDE.md'), '# App\n', 'utf8');
await writeFile(join(nested, 'plugins', 'app', 'hooks', 'hooks.json'), '{}\n', 'utf8');
// Two rules mirroring the real ktg-privat ones.
await writeFile(
join(nested, '.claude', 'rules', 'plugin-convention.md'),
'---\npaths: "plugins/*/CLAUDE.md"\n---\n\n# Convention\nbody\n',
'utf8',
);
await writeFile(
join(nested, '.claude', 'rules', 'hook-format.md'),
'---\npaths: "**/hooks/hooks.json"\n---\n\n# Hook format\nbody\n',
'utf8',
);
}
beforeEach(async () => {
resetCounter();
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-nested-'));
await writeNestedRepo(tmpRoot);
const discovery = await discoverConfigFiles(tmpRoot);
result = await scan(tmpRoot, discovery);
});
afterEach(async () => {
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
});
it('does NOT flag the nested rules as "matches no files"', () => {
const dead = result.findings.filter(f => f.title.includes('matches no files'));
assert.equal(
dead.length,
0,
`nested-repo rules matched real files but were flagged dead: ${dead.map(f => f.evidence).join(' | ')}`,
);
});
});
describe('RUL — user-global rule is not flagged "matches no files" (M-BUG-9 guard)', () => {
// A user-global ~/.claude/rules/ rule scopes against whatever project is
// active at runtime, not a fixed tree — so "matches 0 files here" is not a
// meaningful dead-rule signal and must not be flagged (and must not trigger a
// HOME-wide file walk).
let tmpHome;
let savedHome;
let result;
beforeEach(async () => {
resetCounter();
tmpHome = await mkdtemp(join(tmpdir(), 'ca-rul-home-'));
savedHome = process.env.HOME;
process.env.HOME = tmpHome;
await mkdir(join(tmpHome, '.claude', 'rules'), { recursive: true });
await writeFile(
join(tmpHome, '.claude', 'rules', 'global.md'),
'---\npaths: "src/**/*.rs"\n---\n\n# Global rust rule\nbody\n',
'utf8',
);
// Scan from HOME so the rule is discovered AND its derived project root === HOME.
const discovery = await discoverConfigFiles(tmpHome);
result = await scan(tmpHome, discovery);
});
afterEach(async () => {
process.env.HOME = savedHome;
if (tmpHome) await rm(tmpHome, { recursive: true, force: true });
});
it('does NOT flag a user-global rule as "matches no files"', () => {
const dead = result.findings.filter(f => f.title.includes('matches no files'));
assert.equal(dead.length, 0, `user-global rule wrongly flagged dead: ${dead.map(f => f.evidence).join(' | ')}`);
});
});
describe('RUL — mid-pattern /**/ glob matches intermediate dirs (M-BUG-19)', () => {
// globToRegex restored the {{GLOBSTAR_SLASH}} placeholder to "(?:/.+/|/)"
// BEFORE the ? → [^/] replacement ran, corrupting the group opener "(?:"
// into "([^/]:" — so every pattern containing a mid-pattern "/**/" silently
// matched nothing but the zero-dir "|/" branch, and live rules like
// "posts/**/post.md" were flagged "matches no files".
let tmpRoot;
let result;
beforeEach(async () => {
resetCounter();
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-globstar-'));
await mkdir(join(tmpRoot, '.claude', 'rules'), { recursive: true });
await mkdir(join(tmpRoot, 'posts', '2026-01-23-slug'), { recursive: true });
await writeFile(join(tmpRoot, 'posts', '2026-01-23-slug', 'post.md'), '# Post\n', 'utf8');
await writeFile(
join(tmpRoot, '.claude', 'rules', 'post-scope.md'),
'---\npaths: "posts/**/post.md"\n---\n\n# Post rule\nbody\n',
'utf8',
);
const discovery = await discoverConfigFiles(tmpRoot);
result = await scan(tmpRoot, discovery);
});
afterEach(async () => {
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
});
it('does NOT flag a live posts/**/post.md rule as "matches no files"', () => {
const dead = result.findings.filter(f => f.title.includes('matches no files'));
assert.equal(
dead.length,
0,
`/**/ rule matched a real file but was flagged dead: ${dead.map(f => f.evidence).join(' | ')}`,
);
});
});

View file

@ -47,10 +47,12 @@ describe('SET scanner — broken project', () => {
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects unknown settings key', () => {
// CA-SET-001 in broken-project, evidence='unknownKey123'.
it('does NOT flag an arbitrary unknown key (CC schema is passthrough)', () => {
// M-BUG-10: "unknownKey123" is not close to any known key, so it is a
// valid forward-compatible / passthrough key — NOT a typo. Claude Code
// forwards unrecognized keys unchanged, so flagging it is a false positive.
const found = result.findings.some(f => f.scanner === 'SET' && /unknownKey123/.test(f.evidence || ''));
assert.ok(found, 'Should detect unknownKey123');
assert.ok(!found, 'unknownKey123 is far from every known key → must not be flagged');
});
it('detects deprecated key (includeCoAuthoredBy)', () => {
@ -192,6 +194,63 @@ describe('SET scanner — CC 2.1.114→181 valid keys (Batch 1 false-positive fi
});
});
describe('SET scanner — unknown-key typo gate (M-BUG-10)', () => {
// The CC settings schema is passthrough: it forwards unrecognized keys
// unchanged rather than rejecting them, so an arbitrary unknown key is
// valid/forward-compatible and must NOT be flagged. The only real risk is a
// TYPO of a real key (the intended setting silently has no effect), so the
// scanner flags ONLY unknown keys that closely match a known key.
// Path-guard blocks committing settings.json fixtures → materialize a temp one.
let tmpRoot;
let result;
const TYPO_TITLE = 'Possible typo in settings key';
beforeEach(async () => {
resetCounter();
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-set-typo-'));
await mkdir(join(tmpRoot, '.claude'), { recursive: true });
const settings = {
$schema: 'https://json.schemastore.org/claude-code-settings.json',
permisions: { deny: ['Read(./.env)'] }, // TYPO of "permissions" (edit distance 1)
someNewKey2027: true, // forward-compat / passthrough — far from every known key
permissions: { deny: ['Read(./.env)'], allow: ['Bash(npm run *)'] },
};
await writeFile(
join(tmpRoot, '.claude', 'settings.json'),
JSON.stringify(settings, null, 2) + '\n',
'utf8',
);
const discovery = await discoverConfigFiles(tmpRoot);
result = await scan(tmpRoot, discovery);
});
afterEach(async () => {
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
});
it('flags a likely typo ("permisions") and suggests the nearest known key', () => {
const typo = result.findings.find(f => f.scanner === 'SET' && f.evidence === 'permisions');
assert.ok(typo, `expected a typo finding for "permisions"; got: ${result.findings.map(f => `${f.title}:${f.evidence || ''}`).join(' | ')}`);
assert.equal(typo.title, TYPO_TITLE);
assert.match(typo.recommendation, /permissions/, 'recommendation suggests the nearest known key');
});
it('marks the typo finding as low severity (passthrough → not a hard error)', () => {
const typo = result.findings.find(f => f.scanner === 'SET' && f.evidence === 'permisions');
assert.equal(typo.severity, 'low');
});
it('does NOT flag a forward-compatible key far from any known key', () => {
const fwd = result.findings.find(f => f.scanner === 'SET' && f.evidence === 'someNewKey2027');
assert.equal(fwd, undefined, 'someNewKey2027 is far from every known key → passthrough, not flagged');
});
it('emits exactly one typo finding for this file', () => {
const typos = result.findings.filter(f => f.scanner === 'SET' && f.title === TYPO_TITLE);
assert.equal(typos.length, 1, `expected exactly one typo finding; got: ${typos.map(f => f.evidence).join(', ')}`);
});
});
describe('SET scanner — empty project', () => {
let result;
beforeEach(async () => {

View file

@ -28,6 +28,18 @@ async function runScannerWithHome(home) {
}
}
/** Like runScannerWithHome but threads a resolved { window, advisory } (B8 calibration). */
async function runScannerWithCtx(home, contextWindow) {
resetCounter();
const original = process.env.HOME;
process.env.HOME = home;
try {
return await scan('/unused', { files: [] }, { contextWindow });
} finally {
process.env.HOME = original;
}
}
/** Build a fake HOME with one user skill whose description has `len` chars. */
async function homeWithUserSkill(name, descLen) {
const home = uniqueDir(name);
@ -61,6 +73,22 @@ async function homeWithNUserSkills(count, descLen, prefix = 'agg') {
return home;
}
/**
* Write one user skill whose SKILL.md *body* (below the frontmatter) is about
* `bodyChars` chars, spread across multiple lines (81 chars incl. newline).
* The description stays tiny so body-size tests stay isolated from the cap/aggregate checks.
*/
async function addUserSkillWithBody(home, name, bodyChars) {
const dir = join(home, '.claude', 'skills', name);
await mkdir(dir, { recursive: true });
const line = `${'x'.repeat(80)}\n`;
const body = line.repeat(Math.ceil(bodyChars / line.length));
await writeFile(
join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: short.\n---\n${body}`,
);
}
// Aggregate listing budget: 2% (CC 2.1.32) of the conservative 200k anchor.
const AGGREGATE_BUDGET_TOKENS = 4000; // 0.02 * 200_000
// Token heuristic mirrors estimateTokens('markdown'): ceil(chars / 4).
@ -303,6 +331,123 @@ describe('SKL scanner — aggregate listing budget (CA-SKL-002)', () => {
});
});
describe('SKL scanner — context-window calibration (B8)', () => {
it('--context-window 1000000 relaxes the aggregate budget so an over-200k listing does NOT fire', async () => {
// 50 skills * 400 chars = 20,000 chars -> 5,000 tok. Over the 4,000-tok 200k
// budget, under the 20,000-tok 1M budget — the acceptance case.
const home = await homeWithNUserSkills(50, 400, 'cw');
try {
const at200k = await runScannerWithCtx(home, { window: 200_000, advisory: false });
assert.ok(findAggregate(at200k.findings), 'control: fires at the 200k anchor');
const at1m = await runScannerWithCtx(home, { window: 1_000_000, advisory: false });
assert.equal(findAggregate(at1m.findings), undefined,
'at a 1M context window the listing is within budget and must not fire');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('an unknown (advisory) window keeps the anchor but downgrades the finding to info', async () => {
const home = await homeWithNUserSkills(17, 1000, 'cwadv');
try {
const advisory = await runScannerWithCtx(home, { window: 200_000, advisory: true });
const agg = findAggregate(advisory.findings);
assert.ok(agg, 'still surfaces the measurement (conservative anchor)');
assert.equal(agg.severity, 'info', 'advisory downgrades it from a budget breach (low) to info');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('no opts (default) is unchanged: fires low at the 200k anchor', async () => {
const home = await homeWithNUserSkills(17, 1000, 'cwdef');
try {
const result = await runScannerWithHome(home);
const agg = findAggregate(result.findings);
assert.ok(agg);
assert.equal(agg.severity, 'low', 'default behavior must be byte-stable: low severity');
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
describe('SKL scanner — oversized skill body (B7, on-demand cost)', () => {
const findBody = (findings) => findings.find((f) => /body is large/i.test(f.title || ''));
it('flags a skill whose body exceeds ~5,000 tokens', async () => {
const home = uniqueDir('bigbody');
await addUserSkillWithBody(home, 'heavy', 20_400); // ~5,100 tok body
try {
const result = await runScannerWithHome(home);
const f = findBody(result.findings);
assert.ok(f, 'expected an oversized-body finding for a >5k-token skill body');
assert.equal(f.severity, 'low', 'body size is on-demand cost -> low severity');
assert.match(String(f.file), /heavy[\\/]SKILL\.md$/);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('does NOT flag a skill whose body is under the threshold', async () => {
const home = uniqueDir('smallbody');
await addUserSkillWithBody(home, 'light', 8_000); // ~2,000 tok body
try {
const result = await runScannerWithHome(home);
assert.equal(findBody(result.findings), undefined, 'a small body must not fire');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('evidence reports body tokens + lines and an honest on-demand calibration note', async () => {
const home = uniqueDir('bodyev');
await addUserSkillWithBody(home, 'heavy', 20_400);
try {
const result = await runScannerWithHome(home);
const f = findBody(result.findings);
assert.ok(f);
const ev = String(f.evidence);
assert.match(ev, /body_tokens~\d+/, 'evidence states estimated body tokens');
assert.match(ev, /body_lines=\d+/, 'evidence states the body line count');
assert.match(ev, /on demand/i, 'evidence discloses on-demand (not always-loaded) cost');
assert.match(ev, /not every turn/i, 'evidence contrasts with the always-loaded listing');
assert.match(ev, /estimate/i, 'evidence flags the figure as an estimate, not telemetry');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('recommends context:fork and moving reference content into supporting files', async () => {
const home = uniqueDir('bodyrec');
await addUserSkillWithBody(home, 'heavy', 20_400);
try {
const result = await runScannerWithHome(home);
const f = findBody(result.findings);
assert.ok(f);
const rec = String(f.recommendation);
assert.match(rec, /context.?\s*fork/i, 'recommendation should mention context: fork');
assert.match(rec, /supporting file/i, 'recommendation should mention supporting files');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('a large body alone does NOT trip the per-description cap or aggregate findings', async () => {
const home = uniqueDir('bodyonly');
await addUserSkillWithBody(home, 'heavy', 20_400); // big body, tiny description
try {
const result = await runScannerWithHome(home);
assert.equal(findCaps(result.findings).length, 0, 'body size is independent of the 1,536-char description cap');
assert.equal(findAggregate(result.findings), undefined, 'one tiny description stays under the aggregate budget');
assert.ok(findBody(result.findings), 'only the body finding should fire');
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
describe('SKL scanner — suppression compatibility', () => {
it('CA-SKL-001 is NOT matched by a CA-TOK-* glob suppression', async () => {
const { applySuppressions } = await import('../../scanners/lib/suppression.mjs');

View file

@ -0,0 +1,70 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan } from '../../scanners/token-hotspots.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { withHermeticHome } from '../helpers/hermetic-home.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
// Hermetic HOME so readActiveConfig does not leak the developer's real
// ~/.claude.json plugin MCP servers into the fixture result (mirrors the other
// TOK tests). The deferral check reads project+local settings + project .mcp.json.
async function runScanner(fixtureName) {
resetCounter();
const path = resolve(FIXTURES, fixtureName);
const discovery = await discoverConfigFiles(path);
return withHermeticHome(() => scan(path, discovery));
}
const TITLE = 'MCP tool schemas forced into the always-loaded prefix';
const findDeferral = (result) => result.findings.find(f => f.title === TITLE);
describe('TOK scanner — MCP deferral (v5.10 B4, CA-TOK-006)', () => {
it('deferred (default) project → NO deferral finding', async () => {
const result = await runScanner('mcp-deferral/deferred');
assert.equal(findDeferral(result), undefined,
'a plain server with tool search on must not be flagged as forced-upfront');
});
it('alwaysLoad:true server → deferral finding fires (medium severity)', async () => {
const result = await runScanner('mcp-deferral/alwaysload');
const f = findDeferral(result);
assert.ok(f, `expected a deferral finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, 'medium', `expected medium for ~2500 forced tokens, got ${f.severity}`);
assert.equal(f.category, 'token-efficiency');
// Only the alwaysLoad server is named, not the deferred sibling.
assert.match(String(f.evidence || ''), /always-srv/);
assert.doesNotMatch(String(f.description || ''), /deferred-srv/);
assert.match(String(f.evidence || ''), /alwaysLoad/);
});
it('ENABLE_TOOL_SEARCH="false" in settings → deferral finding (all servers affected)', async () => {
const result = await runScanner('mcp-deferral/disabled-settings');
const f = findDeferral(result);
assert.ok(f, `expected a deferral finding for tool-search-disabled fixture`);
assert.match(String(f.evidence || ''), /reason=enable-tool-search-false/);
assert.match(String(f.description || ''), /Tool search is disabled/);
});
it('every deferral finding discloses runtime conditions + calibration', async () => {
const result = await runScanner('mcp-deferral/alwaysload');
const f = findDeferral(result);
assert.ok(f);
// DEFERRAL_DISCLOSURE names the launch/runtime conditions a static scan can't see.
assert.match(String(f.evidence || ''), /Vertex AI/);
assert.match(String(f.evidence || ''), /ANTHROPIC_BASE_URL/);
// CALIBRATION_NOTE shared by all TOK pattern findings.
assert.match(String(f.evidence || ''), /severity reflects estimated tokens\/turn/i);
});
it('finding ID matches CA-TOK-NNN format', async () => {
const result = await runScanner('mcp-deferral/alwaysload');
const f = findDeferral(result);
assert.ok(f);
assert.match(f.id, /^CA-TOK-\d{3}$/);
});
});

View file

@ -1,7 +1,9 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan } from '../../scanners/token-hotspots.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
@ -383,6 +385,16 @@ describe('TOK scanner — H stale plugin-cache versions (v5.9 B3)', () => {
assert.match(f.evidence, /mkt\/config-audit\/5\.6\.0/);
});
it('warns against deleting a version a running session still uses (/exit caveat)', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', staleDiscovery));
const f = result.findings.find(x => /stale plugin-cache/i.test(x.title || ''));
assert.match(f.recommendation, /running session|active session/i,
'recommendation must caution about sessions still using a version');
assert.match(f.recommendation, /\/exit/,
'recommendation must tell affected sessions to /exit + restart');
});
it('does NOT fire when there are no stale versions', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', { files: [], staleCacheVersions: [] }));
@ -395,3 +407,36 @@ describe('TOK scanner — H stale plugin-cache versions (v5.9 B3)', () => {
assert.ok(!result.findings.some(x => /stale plugin-cache/i.test(x.title || '')));
});
});
describe('TOK scanner — CLAUDE.md HTML-comment token discount (M-BUG-6)', () => {
it('estimates a comment-padded CLAUDE.md below its raw byte heuristic', async () => {
const dir = join(
tmpdir(),
`config-audit-tok-mbug6-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
await mkdir(dir, { recursive: true });
try {
// Big block-level comment CC strips before injection — the hotspot's
// estimated_tokens must reflect the stripped size, not the raw on-disk byte
// count. (Hotspot output exposes `path` + `estimated_tokens`, not `size`.)
const comment = `<!-- ${'maintainer note '.repeat(80)} -->`;
const content = `# Root\n\n${comment}\n\nReal instruction body.\n`;
const rawBytes = Buffer.byteLength(content, 'utf8');
await writeFile(join(dir, 'CLAUDE.md'), content);
resetCounter();
const discovery = await discoverConfigFiles(dir);
const result = await withHermeticHome(() => scan(dir, discovery));
const hs = result.hotspots.find(
h => typeof h.path === 'string' && h.path.endsWith('CLAUDE.md'),
);
assert.ok(hs, 'expected a CLAUDE.md hotspot for the fixture');
assert.ok(
hs.estimated_tokens < Math.ceil(rawBytes / 4),
`expected discounted tokens (${hs.estimated_tokens}) below raw heuristic ` +
`(${Math.ceil(rawBytes / 4)}) for ${rawBytes} raw bytes`,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

@ -165,7 +165,7 @@
"id": "CA-GAP-003",
"scanner": "GAP",
"severity": "low",
"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.",
"file": null,
"line": null,
@ -309,7 +309,7 @@
"id": "CA-GAP-012",
"scanner": "GAP",
"severity": "info",
"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.",
"file": null,
"line": null,