New GAP finding CA-GAP-028: authored subagents exist and not one of them names `model:` or `effort:`, so every delegated task runs on the main conversation's model (`model` defaults to `inherit`). Cites BP-MODEL-001/002, landed in C1. `whats-active` and `manifest` now carry `model`/`effort` per agent. Shipped as a conditional LEVER rather than a 25th dimension, and the choice was made by measurement: as a t3 dimension the agent-less marketplace-medium fixture would count it vacuously-present, moving the denominators 41->42 and utilization 44->45 — which flips `segment` "Developing"->"Competent" in the frozen v5.0.0 posture baseline, a field strip-retired-gap.mjs does not mask. A lever never enters those denominators. The general rule is now an invariant in CLAUDE.md. One check across both axes, not one per axis: it fires only when neither is used anywhere, so a deliberate everything-on-one-model policy stays silent. Cost is recall, chosen for precision. Found by dogfooding, fixed red-first: `model: inherit` is the documented default spelled out, so it must not count as routing — otherwise a config opts out of the opportunity without changing anything real. Two pre-existing defects surfaced and closed on the way: - The humanizer guard asserted TRANSLATIONS.GAP.static EQUALS the dimension titles, which forbade humanizing any lever — all three existing levers fell through to the generic "feature opportunity" default, wrong for a budget lever. Guard now requires coverage of every emittable title, seen red against those three before the entries were written. - Two hand-written copies of the lever list (finding-codes guard, humanizer guard) merged into one exported LEVERS registry carrying code AND title. - suppression-validation pinned CA-GAP-028 as an unoccupied number; C4 claimed it. Fixed structurally with a derived first-free id, not by picking a new literal — same class as #60's "bump this again". Suite 1596/0. Frozen v5.0.0 snapshots untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq3nye21RVYk4pZLeT8pGz
64 KiB
Config-Audit — Scanner internals
Detailed scanner inventory, lib modules, action engines, knowledge base. Imported from CLAUDE.md via pointer.
Deterministic Scanners
Node.js scanners (zero external dependencies), run via node scanners/scan-orchestrator.mjs <path>.
Posture CLI: node scanners/posture.mjs <path> [--json] [--global] [--full-machine] [--output-file path].
Scanner CLI: node scanners/scan-orchestrator.mjs <path> [--global] [--full-machine] [--no-suppress].
| Scanner | Prefix | Detects |
|---|---|---|
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, 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 | 24 feature checks across 4 tiers — shown as opportunities, not grades |
token-hotspots.mjs |
TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascade, bloated SKILL.md descriptions, MCP tool-schema budget, MCP tool-schema deferral (CA-TOK-006), stale plugin-cache disk-cleanup (prompt-cache patterns) |
cache-prefix-scanner.mjs |
CPS | Volatile content in lines 31–150 of CLAUDE.md cascade (beyond Pattern A's top-30 window); plus volatile content inside @import-ed files (v5.10 B6, one hop) |
disabled-in-schema-scanner.mjs |
DIS | Dead/ineffective permission entries (low). (1) Tools in BOTH permissions.deny AND permissions.allow — deny wins; dominance is param-aware and treats the Tool(*) deny-all glob as equivalent to a bare deny (covers a bare allow). (2) Unanchored allow wildcards (*, B*, mcp__*) that Claude Code silently skips — CC accepts allow globs only after a literal glob-free mcp__<server>__ prefix. Predicates shared with CNF live in lib/permission-rules.mjs |
collision-scanner.mjs |
COL | Cross-plugin skill name collisions (low); user-vs-plugin overlaps (medium); details.namespaces payload |
skill-listing-scanner.mjs |
SKL | (1) CA-SKL-001 (medium): active skill descriptions over the verified 1,536-char listing cap (CC 2.1.105) → silently truncated in the model's skill listing. (2) CA-SKL-002 (low): sum of active descriptions (each counted up to the cap) over the listing budget (~2% of context, CC 2.1.32), anchored on a conservative 200k window with a calibration note that the budget scales 5× on 1M-context models — leads with the measured sum, an estimate not telemetry. HOME-scoped (all user + plugin skills). Remediation surfaces disableBundledSkills / skillOverrides / trim. Distinct lens from TOK pattern F (project-local 500-char bloat heuristic) |
output-style-scanner.mjs |
OST | (1) CA-OST-001 (medium): a user/project custom output style not setting keep-coding-instructions: true (defaults false) → silently strips Claude Code's built-in software-engineering instructions when active (V10). (2) CA-OST-002 (low): a plugin style with force-for-plugin: true auto-applies and overrides the user's outputStyle (V11; plugin-styles-only per docs). (3) CA-OST-003 (medium): a settings outputStyle matching no built-in (Default/Explanatory/Learning/Proactive, case-insensitive) nor discovered custom style → dead config (CC falls back to default). Reads each style's frontmatter via parseFrontmatter; fixture-gated (silent with no output styles). New scanner family in v5.6 C (count 13→14) |
optimization-lens-scanner.mjs |
OPT | CA-OPT-001 (low, Missed opportunity): a CLAUDE.md procedure (≥6 consecutive numbered steps) that belongs in a skill (mechanism-fit, BP-MECH-003). Reads the machine-readable best-practices register (best-practices-register.mjs) for recommendation + provenance. Conservative — negative corpus proves null false-positive; prose-judgment cases (lifecycle→hook, "never"→permission) deferred to the Chunk 2b opus analyzer. Scoring area CLAUDE.md (existing → byte-stable). New scanner family in v5.7 Fase 1 Chunk 2a (count 14→15) |
Scanner Lib (scanners/lib/)
| Module | Purpose |
|---|---|
severity.mjs |
Severity constants, risk scoring, verdict logic, WEIGHTS named export (v5 F3) |
output.mjs |
Finding objects (CA-XXX-NNN format), scanner results, envelope, optional details payload (v5 N6) |
file-discovery.mjs |
Config file discovery: single-path, multi-path (discoverConfigFilesMulti), full-machine (discoverFullMachinePaths) |
yaml-parser.mjs |
Frontmatter parsing, JSON parsing, @import/section extraction |
string-utils.mjs |
Line counting, truncation, similarity, key extraction |
scoring.mjs |
Severity-weighted scoreByArea (v5 F3), health scorecard, dedup-by-area (v5 N3), scoringVersion: 'v5' |
backup.mjs |
Backup creation, manifest parsing, checksum verification |
diff-engine.mjs |
Drift diffing: diffEnvelopes(), formatDiffReport() |
baseline.mjs |
Baseline save/load/list/delete for drift detection |
report-generator.mjs |
Unified markdown reports: posture, drift, plugin health |
suppression.mjs |
.config-audit-ignore parsing, finding suppression, audit trail |
active-config-reader.mjs |
Read-only inventory: readActiveConfig(), detectGitRoot(), walkClaudeMdCascade(), readClaudeJsonProjectSlice() (longest-prefix match), enumeratePlugins(), enumerateSkills(), readActiveHooks(), readActiveMcpServers() (with cache → package.json tool-count fallback), estimateTokens() (v5: 'mcp' kind = 500 + toolCount × 200) |
tokenizer-api.mjs |
Anthropic count_tokens wrapper for --accurate-tokens (v5 N5); 5s AbortController timeout, exponential 429 backoff, key masking |
humanizer.mjs |
Plain-language output translator (v5.1.0): humanizeFinding, humanizeFindings, humanizeEnvelope, computeRelevanceContext. Pure functions; never mutate inputs. Adds userImpactCategory, userActionLanguage, relevanceContext fields and replaces title/description/recommendation when a translation exists. Bypassed by --raw and --json paths. |
cli-args.mjs |
Argv precondition shared by the CLIs: findArgError(args, spec) / requireValidArgs(args, spec). Rejects an unknown flag, and a value-taking flag whose next token is missing or is itself a flag — exit 3, never a verdict. Runs BEFORE each CLI's own parse loop, so valid argv reaches the existing parser unchanged (see Implementation notes → arg-sluk) |
require-target-dir.mjs |
Target-path precondition: a scan root that does not exist, or is not a directory, is exit 3 rather than a graded verdict (#56) |
humanizer-data.mjs |
TRANSLATIONS table for 16 scanner prefixes (CML/SET/HKV/RUL/MCP/IMP/CNF/COL/TOK/CPS/DIS/GAP/PLH/SKL/OST/OPT). Three-step lookup: exact title → regex pattern → _default → fall through to original |
Action Engines (scanners/)
| Module | Purpose |
|---|---|
fix-engine.mjs |
planFixes(), applyFixes(), verifyFixes() — 9 fix types |
rollback-engine.mjs |
listBackups(), restoreBackup(), deleteBackup() |
fix-cli.mjs |
CLI: node fix-cli.mjs <path> [--apply] [--json] [--global] |
drift-cli.mjs |
CLI: node drift-cli.mjs <path> [--save] [--baseline name] [--json] |
whats-active.mjs |
CLI: node whats-active.mjs <path> [--json] [--verbose] [--suggest-disables] — read-only active-config inventory |
token-hotspots-cli.mjs |
CLI: node token-hotspots-cli.mjs <path> [--json] [--global] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe] — prompt-cache token hotspots ranking (each hotspot tagged with its load pattern, v5.6 B2) with optional API calibration |
manifest.mjs |
CLI: node manifest.mjs <path> [--json] — ranked component-level token-source table, each source tagged with its load pattern + an always-loaded subtotal (v5 N2; load-pattern accounting v5.6 B) |
Standalone Scanner
| Module | Prefix | Purpose |
|---|---|---|
plugin-health-scanner.mjs |
PLH | Plugin structure, frontmatter, cross-plugin conflicts (runs independently) |
self-audit.mjs |
— | Runs all scanners + plugin health on this plugin itself |
Knowledge Base (knowledge/)
| File | Content |
|---|---|
claude-code-capabilities.md |
Feature register: 18 config surfaces, Anthropic guidance, relevance table |
configuration-best-practices.md |
Per-layer best practices (v5: cache-stability guidance replaces Sonnet-era 200-line rule) |
anti-patterns.md |
Common mistakes mapped to scanner IDs |
hook-events-reference.md |
All 28 hook events with details |
feature-evolution.md |
Feature timeline for staleness detection |
gap-closure-templates.md |
Config-specific templates for closing gaps |
prompt-cache-patterns.md |
Token-cost dynamics (prompt-cache patterns) — patterns powering the TOK scanner |
cache-telemetry-recipe.md |
Manual jq recipe for verifying prompt-cache hit rate from session transcripts (v5 M7) |
Implementation notes (per scanner / build block)
Detailed design rationale, primary-source verification, and byte-stability lessons for each scanner family and v5.6/v5.7 build block. Moved out of CLAUDE.md (kept lean per the "invariants only" rule); each note records why a change is correct and which frozen baselines it touched. Read on demand when working on the named scanner/block.
active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation)
scanners/lib/active-config-reader.mjs now enumerates the three source kinds it previously
missed — rules (enumerateRules), agents (enumerateAgents), and output styles
(enumerateOutputStyles) — alongside the existing CLAUDE.md/plugins/skills/hooks/MCP enumerators.
Each new item, plus a pure deriveLoadPattern(kind, {scoped}) helper, carries a
loadPattern ∈ {always, on-demand, external}, survivesCompaction ∈ {yes, no, n/a}, and
derivationConfidence ∈ {confirmed, inferred} derived from the published Claude Code loading
model (the V-rows in docs/v5.5-steering-model-plan.md). readActiveConfig exposes rules/
agents/outputStyles arrays + totals counts/subtotals (folded into grandTotal). This is
internal plumbing for v5.6 B (manifest/tokens rendering) — no command output changes yet, so
--json/--raw/SC-5 stay byte-stable. Output-style discovery is done directly (mirroring
enumerateSkills), not via a new file-discovery type, to keep the discovery surface stable.
The frontmatter parser (scanners/lib/yaml-parser.mjs) now also reads YAML block sequences
(paths:\n - a\n - b), not just inline paths: "a, b". This resolves a pre-existing RUL
false-positive (a block-sequence-scoped rule was misread as unscoped). An empty-valued key with
no following - items still resolves to null (backwards-compatible); only a real - item
list becomes an array.
manifest — load-pattern accounting (v5.6 B)
buildManifest (scanners/manifest.mjs) now consumes the Foundation enumeration. Two changes:
- Component-level sources (plugin roll-up dropped). The coarse
kind:'plugin'aggregate is gone. A plugin contributes via its skills/rules/agents/output-styles/hooks/MCP — each already enumerated once byreadActiveConfig— so the old roll-up double-counted them (the plugin aggregate'sestimatedTokensalready summed its components). Source kinds are nowclaude-md/skill/rule/agent/output-style/mcp-server/hook. - Load-pattern triple on every record + a
summary. Each source carriesloadPattern/survivesCompaction/derivationConfidence. Rules/agents/output-styles propagate the foundation-derived values (rules vary byscoped); CLAUDE.md mapsscope→ kind viaCLAUDE_MD_SCOPE_KIND(all cascade files walk up, so all are always-loaded); skills are tagged on-demand viaderiveLoadPattern('skill-body')— the measured tokens are the skill body (paid on invoke), not the tiny always-loaded name+desc listing (tracked byskill-listing-budget/posture), so tagging the body always would inflate the headline. The newsummarybuckets sources intoalways/onDemand/external/unknown{tokens,count}; the always-loaded subtotal ("≈X tokens enter context every turn before you type") is the headline.
Byte-stability. manifest is an environment-aware CLI → SC-6/SC-7 verify it by
mode-equivalence (--json == --raw), not byte-equal against a frozen snapshot, and it is not in
SC-5 default-output. Adding fields in place therefore keeps all snapshots green with no regen
(verified). total changes (de-duped, component-level) — that is the intended correctness fix.
token-hotspots — load-pattern column (v5.6 B2)
TOK now annotates every ranked hotspot with the same load-pattern triple (hotspotLoadPattern
maps each discovery type→a deriveLoadPattern kind; rules reuse activeConfig.rules for precise
scoped handling; claude-md maps by scope). Two new deriveLoadPattern kinds back this:
command (on-demand — body loads on /invoke) and harness-config (external — settings/
keybindings/.mcp.json/hooks.json/plugin.json configure the CLI, not the model context, so they
cost no per-turn context tokens). Note the honest split: the .mcp.json file is external,
while the MCP server's tool schemas are a separate always hotspot.
Byte-stability — the opposite of manifest. token-hotspots is a byte-equal SC-6/SC-7 CLI,
and its hotspots ride inside the scan-orchestrator + posture payloads, so the change broke
six frozen-v5.0.0 comparisons across five test files (json/raw-backcompat + the three Step 5/6/7
humanizer tests). Resolved by preserving the frozen v5.0.0 baselines: a shared
tests/helpers/strip-hotspot-load-pattern.mjs strips the additive triple before each byte-equal
compare (proves the original schema is byte-identical), and the SC-5 default-output snapshots
(scan-orchestrator + token-hotspots) were regenerated (UPDATE_SNAPSHOT=1) since their job is to
track current output — diff reviewed as additive-only. Lesson for any future hotspot/scanner-output
field: grep every frozen-v5.0.0 comparator (it is 5 files, not 2) before assuming the blast radius.
token-hotspots — MCP tool-schema deferral (v5.10 B4, CA-TOK-006)
By default Claude Code defers MCP tool schemas: only tool names enter the always-loaded prefix (~120 tokens total) and full schemas load on demand via tool search. Several signals force the FULL schemas into the prefix every turn instead. CA-TOK-006 detects them from config files only, so the finding is deterministic and hermetic-safe (mirrors Pattern G's project-local scoping):
| Signal | Source | Confidence |
|---|---|---|
ENABLE_TOOL_SEARCH: "false" |
merged project+local settings.json env block |
high |
"ToolSearch" in permissions.deny |
settings.json | high |
configured model matches /haiku/ |
settings.json (Haiku lacks tool_reference support) |
medium |
per-server alwaysLoad: true |
project .mcp.json (CC v2.1.121+) |
high |
ENABLE_TOOL_SEARCH: "auto[:N]" |
settings env |
threshold mode — info, not a trigger |
The engine (lib/mcp-deferral.mjs) splits a pure assessMcpDeferral({settings, mcpServers})
(fully unit-tested, no IO) from a thin IO wrapper assessMcpDeferralForRepo(repoPath, {mcpServers})
shared by TOK and GAP. Severity scales with the aggregate forced-upfront token cost
(severityForForcedSchemas: ≥5000→high, ≥1500→medium; medium-confidence reasons cap at medium).
Honest scoping decision (Verifiseringsplikt). The detector deliberately does NOT read
process.env shell vars. Tool search is also disabled on Vertex AI, with a custom
ANTHROPIC_BASE_URL (non-first-party host), or after a runtime /model switch to Haiku — but
those are launch/runtime state, not config files, so triggering on them would make the finding
machine-dependent (the marketplace-medium snapshot has MCP servers; an ambient ANTHROPIC_BASE_URL
would flap it). They are disclosed in every finding (DEFERRAL_DISCLOSURE), never triggered.
Tool-level anthropic/alwaysLoad (set server-side in the tools/list _meta) and claude.ai
connectors are likewise invisible to a static scan and disclosed. Mechanism verified 2026-06-23
against code.claude.com/docs: context-window.md (MCP deferred, ~120 tok),
mcp.md#configure-tool-search + #exempt-a-server-from-deferral, costs.md. The prefix-cache
connect/disconnect-invalidation claim from the raw research was [NOT CONFIRMED] in docs and is
NOT asserted by this finding.
feature-gap companion. cliOverMcpLeverFinding (GAP) fires only when CA-TOK-006's assessment
shows schemas forced upfront — recommends preferring CLI (gh/aws/gcloud) over MCP for common
operations (CLI adds zero context tokens until invoked). Deferred MCP is effectively free, so the
lever stays silent in the default case (opportunity, not noise — mirrors the bundledSkills lever).
alwaysLoad was added to CA-MCP's VALID_SERVER_FIELDS so it is never flagged as an unknown field.
hook-validator — unfiltered additionalContext advisory (v5.10 B5)
A hook that emits hookSpecificOutput.additionalContext has that payload injected into Claude's
context every time it fires — plain stdout on exit 0 does NOT (it goes to the debug log only). A
hook that dumps large, un-grepped command output into additionalContext is therefore a recurring,
compaction-sensitive per-turn token cost. HKV flags it as an info advisory (weight 0 — never
severity-bearing, excluded from the self-audit nonInfo set), paired with a feature-gap lever.
The heuristic lives in lib/hook-additional-context.mjs as a pure assessHookAdditionalContext({scriptContent})
(unit-tested, no IO) plus a thin IO wrapper assessHookContextForRepo(discovery) (walk hooks → scripts
→ assess) used by GAP; HKV calls the pure function inline on scripts it already reads. The signal:
| Condition | Detected by | Effect |
|---|---|---|
references additionalContext |
/additionalContext/ |
gate (else not applicable) |
| captures verbose-prone output | cat/find/ls/git log|diff|status|show/npm/pytest/jest/curl/execSync/readFileSync… |
hasVerboseCapture |
| applies any truncating filter | grep/head/tail/sed/awk/jq/cut/wc/uniq/sort or .slice/.substring |
suppresses (assumed bounded) |
flagged = buildsAdditionalContext && hasVerboseCapture && !hasFilter. A filtered capture (e.g.
cat … | grep ERROR) or a cheap-only capture ($(date)) is not flagged.
Why info, not a hard finding (Verifiseringsplikt). This is deliberately low precision — a
static scan cannot run the hook or measure the real payload, and a filter we don't recognise would be
a false positive. So it ships as an advisory with the precision caveat in its own description, never a
graded/severity-bearing finding. Mechanism verified 2026-06-23 against code.claude.com/docs:
context-window.md — "A PostToolUse hook … reports back via hookSpecificOutput.additionalContext.
That field enters Claude's context. Plain stdout on exit 0 does not." + the tip to keep output concise
(it enters context without truncation).
feature-gap companion. filterHookLeverFinding (GAP) fires only when assessHookContextForRepo
returns ≥1 chatty hook — surfaces the documented filter-before-Claude-reads lever (filter-test-output.sh:
grep ERROR and return only matches instead of a 10,000-line log). No chatty hook → silent (opportunity,
not noise — same contract as the cliOverMcp / bundledSkills levers).
feature-gap — agent model/effort routing lever (v5.14 C4, CA-GAP-028)
agentModelRoutingLeverFinding fires only when the target has authored subagents (the same
isAuthoredConfig set the presence checks use, so plugin-bundled and fixture agents cannot make a
machine look routed — M-BUG-13) and not one of them names model: or effort:. Cites
BP-MODEL-001 (a subagent's model defaults to inherit, so omitting it is a choice to pay the
session's rate) and BP-MODEL-002 (effort is a separate axis with its own frontmatter field).
Why a lever and not a 25th dimension — decided by measurement, not taste. A dimension is always
evaluated, so "no agents at all" would have to read as present, and present weight feeds the
utilization score. Measured on tests/fixtures/marketplace-medium (hermetic HOME) before the change:
utilization.score 44, segment "Developing", where the "Competent" boundary is 45. As a t3
dimension the denominators move 41→42 and the vacuous present pushes 18/41→19/42 = 45 — flipping
segment in the frozen v5.0.0/posture.json, which strip-retired-gap.mjs does not mask (it
drops only utilization.score/overhang and feature_coverage.score). A lever leaves every
denominator alone and cannot move a score it never enters. The general rule now lives in CLAUDE.md
(GAP dimensions vs. levers).
One check across both axes, not one per axis. It fires only when neither axis is used anywhere,
so a deliberate everything-on-one-model policy stays silent. The cost is recall: a config that pins
model: everywhere but never effort: gets no nudge. That is the v1 boundary, chosen for precision.
model: inherit is not routing — found by dogfooding, where installed agents write it out
explicitly. inherit is the documented default, so spelling it out changes nothing about what the
agent costs; counting it as a pin would let a config opt out of the opportunity without changing
anything real. Effort has no documented sentinel of this kind, so it has no counterpart rule.
Two silences that must not be conflated. "No authored agents" (owned by dimension t2_6,
No custom subagents) and "the only agents on disk are plugin-bundled" produce the same quiet
output for different reasons. tests/scanners/gap-agent-model-routing.test.mjs P5 pins the second
one specifically — it asserts the agent file was discovered before asserting silence, so the arm
cannot pass for P4's reason.
Humanizer coverage is now a blanket invariant. The old guard asserted TRANSLATIONS.GAP.static
keys equal GAP_CHECKS titles, which forbade humanizing any lever — so all three existing levers
fell through to the generic GAP _default ("You have a feature opportunity worth a look"), wrong for
a budget lever. The guard now requires a static entry for every title GAP can emit (dimensions ∪
levers), and it was seen red against those three before the four entries were written. TITLE_TO_ID
keeps strict equality with GAP_CHECKS: levers are not dimensions and must stay out of scoring.
cache-prefix-scanner — @import extension (v5.10 B6)
CPS originally scanned only the files discovery classifies as claude-md. But a CLAUDE.md can pull
arbitrary files into context with @import directives, and those targets are usually not claude-md
in discovery (e.g. @shared/conventions.md) — so their content was inlined into the cached prefix yet
never inspected. Neither TOK Pattern A (top-30 of cascade files) nor the in-file CPS scan reaches past
the importing file, so volatility in an imported file was invisible.
B6 closes the gap: for each @import whose import site sits within the cached-prefix window
(imp.line ≤ CACHED_PREFIX_LINES), CPS resolves the path (resolveImportPath, mirroring
import-resolver/token-hotspots semantics), reads the target, and runs findVolatileLines over its first
150 lines. A hit emits a distinct medium finding — "Volatile content in @imported file breaks cached
prefix" — keyed on the resolved file (so the fix points at the right place), with evidence naming the
importer (imported by <file> (@<path> at line N)).
Scope boundaries (deliberate):
- One hop only. Imports-of-imports are not followed — IMP owns deep-chain analysis. The verified win is the direct import; transitive resolution adds cycle/depth complexity for marginal coverage.
- No lines-1–30 skip for imported content. That exclusion exists only to avoid duplicating TOK Pattern A's territory in the root file; Pattern A never reads imported files, so all of the imported prefix counts.
- No double-reporting. An import resolving to a file that is itself a discovered
claude-mdis skipped (it gets its own in-file iteration); areportedImportsset 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:
-
Demo/vendored config masks real gaps. This plugin's own
examples/optimal-setup/is a complete config (setsoutputStyle/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. BecauseanySettingsHas/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:isAuthoredConfigfiltersctx.files/parsedSettingsto 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 underexamples/ortests/fixtures/. relPath (not absPath) is deliberate: a fixture scanned AS the target keeps its own files, so the frozen v5.0.0 snapshots (scanned fromtests/fixtures/marketplace-medium, which has no such nested trees) are byte-stable. -
The real
~/.claude/settings.jsonis invisible to the settings-key checks. Discovery misses it (its relPath carries no.claudesegment when the walk root IS~/.claude— the gotcha) AND, when vendored plugins flood the walk, themaxFiles=2000cap drops it. After (1) removed the demo maskers,statusLine/autoMode/permissions(which the user HAS) would flip to false positives. Fix:readSettingsCascadereads the four canonical cascade paths (usersettings.json/.local, projectsettings.json/.local) directly and merges them INTOparsedSettings— immune to the cap and the gotcha. Merge (not replace) keeps non-canonical project settings and leaves the snapshot (hermetic empty HOME → cascade adds nothing new) byte-stable.
Net: an empty target now surfaces ~18 humanized opportunities (was masked to ~0); config-audit's own
repo still shows 0 in output via its intentional .config-audit-ignore CA-GAP-* self-suppression
(a plugin repo legitimately lacks user-project features) — suppression is an envelope-layer concern,
orthogonal to this scanner fix. Scoped GAP-local; the includeGlobal discovery gotcha itself is left
to other consumers (see auto-memory discovery-includeglobal-user-settings-gotcha).
CML scanner — context-window-scaled char budget
Beyond the line-count checks (200/500 lines, both MEDIUM), the CML scanner mirrors
Claude Code's own startup warning — "Large CLAUDE.md will impact performance
(X chars > 40.0k)" — as a char-based finding:
- Char budget — flags a CLAUDE.md over ~40.0k chars (CC's startup-warning
figure at a 200k-context model). CC 2.1.169 scales that threshold with the model's
context window, so the finding anchors on the conservative 200k window (we cannot
observe the user's window; the anchor fires earliest) and discloses the relaxed
~200,000-char figure at 1M context. Severity MEDIUM (token cost, not an adherence
cliff). New
CA-CMLfinding.
It keys on chars, not lines, so it is complementary to the line checks: a file can be
long by lines yet under budget (short lines), or short by lines yet over it (long lines).
The 200k/1M window constants live in the shared scanners/lib/context-window.mjs
(single source of truth, also re-exported by skill-listing-budget.mjs). The 40.0k
figure and context-window scaling are verified against the CC changelog (2.1.169) and
the live startup-warning text.
DIS scanner — permission-rule hygiene
Beyond deny/allow overlap, the DIS scanner now also flags:
- Ineffective allow wildcards — unanchored tool-name globs in
permissions.allow(*,B*,mcp__*) that Claude Code silently skips (auto-approve nothing). Valid only as a glob-freemcp__<server>__*. NewCA-DISfinding, severity low. Tool(*)deny-all glob — treated as equivalent to a bare deny (Bash(*)≡Bash), so a bare allow killed by it is correctly reported as dead config.- Forbidden-param rules —
Tool(param:value)whose key is the tool's own canonicalizing field (commandfor Bash/PowerShell,file_pathfor Read/Edit/Write,pathfor Grep/Glob,notebook_pathfor NotebookEdit,urlfor WebFetch). CC ignores these and emits a startup warning. Severity follows intent: deny/ask = false security (medium) — the block never applies; allow = dead config (low) —param:valuematching is deny/ask-only. Valid forms (Bash(npm:*),WebFetch(domain:host),Agent(model:opus)) are never flagged. PredicateforbiddenParamRuleinpermission-rules.mjs.
These predicates live in scanners/lib/permission-rules.mjs (shared with the CNF
conflict-detector). Behavior verified against code.claude.com/docs/en/permissions.
PLH scanner — plugin namespace collision
The standalone PLH scanner (cross-plugin checks in scan()) flags plugin namespace
collisions: two or more discovered plugins that declare the same name in
plugin.json. The search-first finding that shaped this check: Claude Code namespaces
every plugin component by the declared name — /name:command, name:skill, agent
name (verified against code.claude.com/docs/en/plugins, and observable in any session's
namespaced skill listing). A plugin component therefore can never shadow a user- or
project-level one; the only shadow that loses components is a same-name collision, where
the namespaces collapse into one and CC must pick a winner. Resolution between two installed
same-name plugins is undocumented, so the loser's commands/skills/agents go silently
unreachable — hence severity MEDIUM (dead config), category: 'plugin-hygiene', with a
COL-shaped details.namespaces payload ({ source: 'plugin:<dir>', name, path }).
Two design notes: (1) the check keys on the declared name field, not basename(dir) —
the folder name is irrelevant to the namespace; scanSinglePlugin now returns declaredName
for this. (2) Name-less plugins are excluded from the collision map (they are flagged by the
missing-field check and must never group on an undefined key).
The sibling cross-plugin command-name check was corrected to match the same model. Because
commands are namespaced (/name:command), a command name shared by two differently-named
plugins is ambiguity — not a hard conflict — so it now mirrors COL's plugin-vs-plugin skill
finding: severity LOW, category: 'plugin-hygiene', COL-shaped details.namespaces, and a
group-first shape (one finding per command name listing every namespace, not pairwise). It keys
on the declared namespace and fires only when a name spans 2+ distinct namespaces; when two
plugins share the same declared name, the namespace-collision finding above is the right (more
severe) signal, so the command check stays silent there to avoid a redundant "dup, dup" report.
The earlier HIGH Cross-plugin command name conflict finding (basename-keyed, "only one wins")
is gone, along with its now-inaccurate humanizer entry.
PLH scanner — plugin-folder shadowing (CA-PLH-015)
Per-plugin check (in scanSinglePlugin, right after the required-field loop): a plugin.json
component-path key that replaces its default folder while that folder still exists on disk →
the folder is silently ignored (dead config). Severity MEDIUM, category: 'plugin-hygiene',
details: { field, ignoredDir, customPaths }. Mirrors Claude Code's own warning in /doctor,
claude plugin list, and the /plugin detail view (v2.1.140+).
The field set is primary-source-pinned to the replaces category only —
SHADOWING_PATH_FIELDS = commands/agents/outputStyles (defaults commands/, agents/,
output-styles/). Deliberately excluded: skills (per
code.claude.com/docs/.../path-behavior-rules it adds to the default skills/ scan — both
load, never a shadow), and hooks/mcpServers/lspServers (own merge rules, not a
folder-shadow). Experimental themes/monitors are omitted because the docs warn their manifest
schema may change between releases. The check also honors the doc's explicit-address exception: a
custom path that resolves into the default folder ("commands": ["./commands/x.md"]) is not
flagged, because Claude Code keeps scanning the folder in that case (addressesDefaultDir
predicate). The v5.4.0 plan originally listed commands/agents/skills/hooks; that set was
corrected here against the live docs (Verifiseringsplikt).
PLH scanner — skills:-array validation (CA-PLH-016)
Per-plugin check (in scanSinglePlugin, after the shadow check): when plugin.json has a
skills field (string or array), each entry must resolve to an existing directory inside the
plugin root. The value is normalized Array.isArray(v) ? v : [v], so a single string is one
entry — and a non-string top-level value (e.g. 42) is naturally caught as a single non-string
entry (no separate top-level check needed). One finding per bad entry, severity MEDIUM,
category: 'plugin-hygiene', details: { field: 'skills', entry, problem } where problem is
one of non-string / escapes-root / not-found / not-a-directory. Mirrors
claude plugin validate (~2.1.145).
Escape detection uses skillsEntryEscapesRoot (resolve + startsWith(pluginDir + sep)
containment — robust against a literal ..foo dir name), backed by the docs' path-traversal rule
("Installed plugins cannot reference files outside their directory … such as ../shared-utils").
statOrNull distinguishes missing from file-vs-dir. Verifiseringsplikt note: the v5.4.0 plan
claimed CC "suggests the parent directory when an entry points at a file"; that exact error text is
not in the primary docs, so it was dropped — the finding asserts only the four
primary-source-verified conditions. skills is deliberately not in SHADOWING_PATH_FIELDS
(it adds to the default scan, never shadows).
PLH scanner — scanDetailed, --output-file, and the marketplace.json exemption (økt #46)
scan() returns the frozen v5.0.0 envelope (scanner, status, files_scanned, duration_ms, findings, counts) and nothing else — --raw/--json print it verbatim and are snapshot-gated. Two
things the /config-audit plugin-health report requires therefore cannot live there: one row per
plugin (the | Plugin | Grade | Commands | Agents | table) and the cross-plugin/per-plugin split.
Both were computed inside scan() and discarded at the return: pluginResults never escaped, and
the only grade code — formatPluginHealthReport — had no caller anywhere in the repo.
scanDetailed(targetPath) is the seam. It returns { result, plugins, crossPluginFindings };
scan() is now (await scanDetailed(p)).result, so the byte-stable envelope is unchanged by
construction. plugins[] carries name, declaredName, path, commandCount, agentCount, findingCount
plus score/grade from the shared pluginGrade(issueCount) helper (which
formatPluginHealthReport now also calls, so the formula has exactly one home).
Cross-plugin findings are identified positionally, not by predicate: crossPluginStart = allFindings.length is taken immediately before the namespace/command-name sections, and the tail is
sliced off at the end. A predicate would have to key on category: 'plugin-hygiene', which the
per-plugin shadow and skills findings share. The marker (crossPlugin: true) is stamped only on the
humanized copies in the --output-file payload — never inside finding(), which would add a key
to the frozen envelope.
--output-file follows the drift-cli contract: humanized payload in default mode, stdout
untouched. This matters because default mode writes its report to stderr, and ux-rules rule 2
requires the command to run under 2>/dev/null — before this, commands/plugin-health.md (and both
optional scanner calls in commands/posture.md) captured zero bytes. Argument parsing uses the same
BOOL_FLAGS/VALUE_FLAGS + unknown-flag-throws shape as drift-cli/fix-cli (M-BUG-21, third
arm); here the swallowed-flag failure mode was worse than an error — scanning the dropped flag's
value found no plugins, so the scanner answered No plugins found (info) with exit 0.
marketplace.json exemption: .claude-plugin/'s known-file set is plugin.json and
marketplace.json. The catalog's location is documented and required ("Create
.claude-plugin/marketplace.json in your repository root"), and a marketplace entry with
"source": "./" makes the repo root its own plugin — so one .claude-plugin/ legitimately holds
both. Verified against the primary docs before the change; the check was a false positive, latent in
this marketplace only because catalog/ ships no plugin.json and is thus not scanned as a plugin.
SET scanner — autoMode validation (CA-SET)
Per-file check in settings-validator.mjs (autoMode was in KNOWN_KEYS but had no nested
validation). Two sub-checks, both primary-source-verified against
code.claude.com/docs/en/auto-mode-config:
- Structure (severity MEDIUM):
autoMode, if present, must be an object whose only keys areenvironment/allow/soft_deny/hard_deny(AUTO_MODE_SUBKEYS), each a string array (the literal"$defaults"is a valid entry, so it passes the string check for free).problem∈not-an-object/unknown-subkey/not-string-arrayindetails. - Dead-config (severity LOW): Claude Code does not read
autoModefrom shared project settings — verbatim: "The classifier does not readautoModefrom shared project settings in.claude/settings.json, so a checked-in repo cannot inject its own allow rules." The check keys onfile.scope === 'project'(file-discovery'sclassifyScopereturns'project'for a committed.claude/settings.json;'local'/'user'/'managed'are read and not flagged).problem: 'shared-project-scope'. This is why the plan's "test per-file scope first" gate passed —ConfigFilealready carriesscope.
The two sub-checks are independent (a malformed autoMode in shared scope yields both). SET is in the
orchestrator, so SC-5 was re-checked after this change — byte-equal (the snapshot fixture has no
autoMode, so the block never fires there).
OST scanner — output-style validation (CA-OST, v5.6 C, count 13→14)
New orchestrated scanner output-style-scanner.mjs — the first new scanner family since SKL
(v5.2.0). It reads the active config (readActiveConfig) and each output-style file's frontmatter
(via parseFrontmatter, keys hyphen→underscore-normalized, so it reads keep_coding_instructions /
force_for_plugin). Three findings, every claim pinned to a CONFIRMED row of
docs/v5.5-steering-model-plan.md (V9/V10/V11/V12), re-verified against
code.claude.com/docs/en/output-styles + .../plugins-reference:
CA-OST-001(medium) — a user/project custom style not settingkeep-coding-instructions: true. The flag defaults to false, so the style silently removes Claude Code's built-in software-engineering instructions when active (V10). Scoped to user/project (the styles the user authors); a plugin author's choice is out of scope.CA-OST-002(low) — a plugin style withforce-for-plugin: true, which auto-applies and overrides the user's selectedoutputStyle(V11). Verifiseringsplikt correction: the v5.5+ plan's CA-OST-002 bullet said "in a project/user style," butforce-for-pluginis plugin-styles-only per the docs (its own cited V11 +output-styles.md), so the check keys onsource === 'plugin'— a user/project style with the flag is simply ignored, not an override.CA-OST-003(medium) — a settingsoutputStylevalue resolving to no built-in (Default/Explanatory/Learning/Proactive, matched case-insensitively) and no discovered custom style → dead config (CC falls back to default; the configured behavior never applies).
Byte-stability — a scanner addition, NOT a field addition. Adding the 14th scanner grows
envelope.scanners by one entry and bumps aggregate.scanners_ok 12→13 on the deterministic
fixture regardless of findings — a field-strip helper cannot paper this over. The SKL precedent
(7bb2547) re-seeded the frozen v5.0.0 snapshots, but that predates B2's strip-preservation regime;
re-seeding now would bake in B2's hotspot triple + claudeMdEstimatedTokens drift (verified by
inspecting the seed diff). So, consistent with the B2 lesson ("preserve frozen via strip-helper;
regen ONLY SC-5"), C preserves the frozen v5.0.0 snapshots and strips the OST entry at compare
time: shared tests/helpers/strip-added-scanner.mjs (stripAddedScanners removes OST entries +
decrements scanners_ok; stripAddedScannerStderr drops the [OST] progress line) is wired into
json/raw-backcompat + the Step 5/6 humanizer wiring tests (cli-humanizer did not break — its
v5.0.0 compares don't grow a scanners array). Only SC-5 default-output (scan-orchestrator +
posture) is regenerated (additive OST entry only — diff reviewed). OST is fixture-gated: the
marketplace-medium fixture and the hermetic HOME have no output styles, so it emits nothing there.
Wiring: orchestrator import + SCANNERS entry; humanizer.mjs SCANNER_TO_CATEGORY
(OST: 'Configuration mistake'); humanizer-data.mjs OST family (title-coupled to the three exact
finding titles); scoring.mjs SCANNER_AREA_MAP (OST: 'Settings' — keeps the 10 quality areas,
byte-stable on zero-finding projects). Count badges: self-audit scanner count 13→14; humanizer-data
TRANSLATIONS families 14→15 (PLH is a translation family but not orchestrated).
best-practices register — machine-readable knowledge layer (v5.7 Fase 1 Chunk 1)
knowledge/best-practices.json: provenance-stamped, schema-validated register (entry =
id/claim/confidence/source + optional mechanism/lensCheck/…). First runtime-consumed
file in knowledge/ (the *.md stay human-only); source of truth for the v5.7 optimization lens
(CA-OPT); seeded from the v5.5 V-rows + the Anthropic "Steering Claude Code" blog. Only
confirmed entries are user-facing (Verifiseringsplikt). Loaded/validated by
scanners/lib/best-practices-register.mjs (loadRegister/validateRegister/getEntry; zero-dep
JSON, not YAML — yaml-parser.mjs can't do arrays-of-objects). Byte-stable until a scanner
consumes it (Chunk 2). Full design: docs/v5.7-optimization-lens-plan.md.
OPT scanner — optimization lens / mechanism-fit (CA-OPT, v5.7 Fase 1 Chunk 2a, count 14→15)
First detector of the «optimal?» axis (vs «correct?»). optimization-lens-scanner.mjs reads the
best-practices register and flags config that works but fits a better mechanism. CA-OPT-001
(low, Missed opportunity): a CLAUDE.md procedure (≥6 consecutive numbered steps) that belongs in a
skill — recommendation/provenance from register BP-MECH-003. Conservative (negative corpus = null
false-positive); prose-judgment cases (lifecycle→hook, unscoped path→rule, «never»→permission) are
handled by the Chunk 2b opus analyzer (below). Wiring mirrors OST: orchestrator entry, humanizer
OPT:'Missed opportunity' + family, scoring OPT:'CLAUDE.md' (existing area → no new posture row →
byte-stable), strip-helper OPT, SC-5 regenerated (additive).
Optimization lens Chunk 2b — opus analyzer (prose-judgment half, /config-audit optimize)
The hybrid motor's recall + precision halves for the three cases the deterministic OPT scanner skips.
Pre-filter (scanners/lib/lens-prefilter.mjs, pure + tested): cheap, recall-oriented line scan
of CLAUDE.md body for lifecycle phrasing (BP-MECH-001→hook), unscoped path-specific instructions
(BP-MECH-002→rule), and absolute «never» prohibitions (BP-MECH-004→permission); skips fenced
code, gates the path class on an instruction verb. Detector names = the register lensCheck fields.
CLI (optimize-lens-cli.mjs, -cli → not a scanner): runs discovery + OPT scanner + pre-filter,
attaches the confirmed register entry to each candidate (unverifiable → dropped, Verifiseringsplikt),
emits {deterministic, candidates, register, counts}. Agent (optimization-lens-agent, opus,
orange — the 7th agent, precision gate): reads the real CLAUDE.md, drops low-confidence candidates,
keeps only genuine opportunities, cites register id + source. Command /config-audit optimize
orchestrates pre-filter→agent→report. Agent-driven → deliberately NOT byte-stable (own command,
outside the snapshot suite); the pre-filter lib is unit-tested (13 tests). No new orchestrated
scanner → scanner count stays 15; agents 6→7, commands 18→19, suite 1055→1068.
Subtraction lens (optimize --subtract, BP-SUB-001) — the inverse axis
Why a mode, not a command or scanner. Every other command asks an addition question; nothing
asked what is no longer earning its rent. A hand-built ground truth over a real 250-line global
CLAUDE.md put the honest payoff at ~850–1400 always-loaded tokens of ~4300 (~20 %, not the source
anecdote's 80 %), with 26 of 34 blocks load-bearing. That proportion justifies a fourth lensCheck
on the existing hybrid motor — not a new scanner (no badge bump 16→17, no frozen-snapshot risk) and
not a 22nd command. Cross-session state is what would have forced a command, and the earned-re-add
ledger is deliberately out of v1.
Polarity is flipped from lens-prefilter. That module is recall-first because a false candidate
only costs the judge a moment. Here a false candidate is a proposal to delete, so
subtraction-prefilter.mjs is precision-first and carries a blocking deterministic guarantee.
Floor-exclusion is a separate module on purpose (lib/floor-exclusion.mjs). §6.0's asymmetry —
a missed dead line costs a few tokens per turn, a deleted load-bearing line costs a wrong remote —
means the guarantee must not rest on a probabilistic judge, so the floor runs before the agent
and is legible as its own unit. Markers: code span, URL/host, filename, rooted path, version pin,
policy invariant, and unresolved-entity (a mixed-case capitalized word mid-sentence). The last is
a deliberate conservative default — resolving "Forgejo" from an ordinary capitalized word needs a
dictionary, so the mechanism declines and keeps the block.
Granularity: leaf block + two structural exceptions. (1) A paragraph ending in : merges with
the list it introduces — a stem often carries no literal of its own, and deleting it without its
list is meaningless. (2) An ordered list is a contract: steps inherit floor from any sibling,
because deleting step 2 of a five-step protocol is not like dropping one platitude. Unordered lists
do not inherit — a load-bearing bullet and a disposable one routinely share a list, and
container-reasoning is exactly the error the ground truth was built to catch.
Three lessons from the dogfood run, all invisible to the synthesized fixture and worth keeping:
- JS
\bis 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 theLB/RBlookaround constants, never\b, around that vocabulary. - A bare
word/wordis not a path.pros/consvetoed the single largest deletable block untilPATH_REwas tightened to rooted paths and globs; real filenames areFILENAME_RE's job. - "Mid-sentence" must key on a preceding lowercase letter, not on "anything that is not a full
stop". The loose version read
**Bold labels:**and quoted openers ("Som AI kan jeg ikke…") as entities and cost 4 of 11 deletable groups.
Measured against the ground truth: zero load-bearing blocks proposed (the blocking §8 gate), 11/18 deletable groups surfaced, ≈756 tok ≈ 18 % of the file — inside the pre-registered band. The misses are all the conservative default working as designed (entity names, code spans, and declarative/infinitive phrasing that carries no imperative). No new scanner: scanners stay 16, commands 21, agents 7; suite 1365→1382.
Note for a future narrowing of the veto: the two failure modes the agent prompt hardens against — staleness-is-not-deletion and tier-2-is-not-tier-3 — are currently also covered by exclusion (both example blocks carry code spans/version pins and never reach the judge). The prompt language is the only protection if those markers are ever loosened.
Test-isolation fix (this session): token-hotspots.test.mjs runScanner now wraps scan() in
the shared withHermeticHome helper — the suite is green on BOTH a real and a clean HOME (the OPT
section's old «run with clean HOME» caveat is resolved). Snapshot/byte tests were already hermetic.
knowledge-refresh — the "living" half of the register (v5.7 Fase 1 Chunk 3, commands 19→20)
Keeps knowledge/best-practices.json current so the optimization lens never reads stale rules.
Same hybrid split as Chunk 2b — a deterministic, byte-stable, unit-tested core + a web/judgment shell:
- Deterministic core (
scanners/lib/knowledge-refresh.mjs, pure, 15 tests):assessFreshness(register, {referenceDate, staleAfterDays})classifies each entryfresh/staleby the age of itssource.verifiedstamp.referenceDateis injected (not read from the clock) so the function is fully deterministic; default thresholdSTALE_AFTER_DAYS_DEFAULT = 90(quarterly re-verify cadence). An unparseable/missingverified→ stale withageDays: null(defensive; the schema-validated bundle never hits this, but the command's hand-built candidates might). «Source changed» detection is a web responsibility (command layer), not in this core. - CLI (
scanners/knowledge-refresh-cli.mjs,-cli→ NOT an orchestrated scanner → scanner count stays 15, suite byte-stable; 8 tests): read-only — it NEVER writes the register and NEVER hits the network.--reference-date(defaults to today; the only place the clock is read) makes it deterministically testable against the bundled register.--stale-after N,--dry-run(implicit + only mode, echoed asrequestedDryRun). Exit 0 = all fresh, 1 = some stale (advisory), 3 = error. - Command (
commands/knowledge-refresh.md, opus): orchestrates CLI stale-report → re-verify each stale entry by re-reading itssource.url(WebFetch) → poll CC changelog + Anthropic blog for new/changed practices (WebSearch) → present everything → apply ONLY human-approved writes, then re-run the register schema test before declaring done. No unverified claim is ever auto-written (Verifiseringsplikt). Web/judgment-driven → deliberately NOT byte-stable (own command, outside the snapshot suite), exactly like/config-audit optimize. No new agent (web poll runs in the command's own context), no new orchestrated scanner. suite 1068→1091.
campaign-ledger — durable machine-wide campaign core (v5.7 Fase 2, Block 3a THIN)
scanners/lib/campaign-ledger.mjs: the durable ledger that sits ABOVE individual sessions for a
machine-wide audit campaign — repo list + per-repo lifecycle (STATUSES = pending→audited→planned
→implemented) + a machine-wide rollUp (counts by status + severity aggregated across repos). It
persists to a single JSON file outside the plugin dir (~/.claude/config-audit/campaign-ledger .json, next to sessions/) so it survives uninstall/reinstall/upgrade. Same hybrid split as
knowledge-refresh: PURE transforms (createLedger/addRepo/setRepoStatus/rollUp) with now
injected (YYYY-MM-DD, never the clock) + soft validateLedger (returns {valid,errors}, never
throws) + a thin IO shell (defaultLedgerPath/loadLedger→null-on-ENOENT/saveLedger). Transforms
throw on programmer error (invalid status, unknown path); schemaVersion stamped from the start so a
Block 4 migration is cheap. THIN: ledger + roll-up + persistence only — NO execution, CLI, or
command surface (Blocks 3b/3c/4). Internal plumbing, byte-stable until consumed: no export async function scan + lives in lib/ → scanner count stays 15, no orchestrator wiring, SC-5 unchanged.
28 tests, suite 1091→1119.
campaign-cli — read-only ledger reporter (v5.7 Fase 2, Block 3b)
scanners/campaign-cli.mjs (-cli → NOT an orchestrated scanner → scanner count stays 15, suite
byte-stable; 8 tests): the DETERMINISTIC, READ-ONLY half of the campaign motor, mirroring
knowledge-refresh-cli. It loadLedgers the durable ledger, validateLedgers it, and emits
{status, initialized, ledgerPath, schemaVersion, createdDate, updatedDate, repos, rollUp} as JSON.
It NEVER writes — a missing ledger is reported gracefully (initialized:false, all-zero roll-up),
never created; init + every status transition belong to the Block 3c command layer (human-approved
writes, Verifiseringsplikt). --ledger-file overrides the default path (deterministic testing);
--output-file mirrors the sibling. Exit codes: 0 = initialized & valid, 1 = not initialized
yet (advisory), 3 = error (parse/corrupt/invalid). suite 1119→1127.
campaign-write-cli + /config-audit campaign — the WRITE half (v5.7 Fase 2, Block 3c, commands 20→21)
The human-approved mutation half of the campaign motor, completing the THIN campaign surface (ledger + roll-up + status). Two pieces:
scanners/campaign-write-cli.mjs(-cli→ NOT an orchestrated scanner → scanner count stays 15, suite byte-stable; 11 tests): the sibling ofcampaign-clithat mutates. Subcommandsinit/add <path>.../set-status <path> <status>, each a thin wrapper over the invariant-enforcing lib transforms (createLedger/addRepo/setRepoStatus) +saveLedger— so path-normalization/dedup, idempotent add, the status-lifecycle guard, and theupdatedDatebump are never re-implemented by hand.initrefuses to clobber an existing (or corrupt) ledger (exit 1 advisory, file untouched);addauto-inits when no ledger exists and reportsaddedvsskipped;set-statusaccepts--findings '<json>'+--session <id>. Determinism mirrors the lib +knowledge-refresh-cli:--reference-dateis the only place the clock is read (defaults to today), passed to the transforms as the injectednow. Exit: 0 = write performed, 1 = advisory no-op (init-clobber), 3 = error (unknown subcommand, bad args, invalid status, untracked repo, no/corrupt ledger).commands/campaign.md(opus,allowed-tools: Read/Write/Edit/Bash/Glob— no Web, judgment-free): a thin orchestrator. It always reports first (read-onlycampaign-cli), then forinit/add/set-statusit proposes the change and, only on explicit human approval, invokes one write-CLI subcommand (Verifiseringsplikt — it never hand-edits the ledger JSON).add --discover <root>finds git repos under a root and lets the user pick. When marking a repoauditedit attaches findings-by-severity from the repo's session (or user-provided counts) — never invented.
Not a new scanner, not byte-stable. Both CLIs carry the -cli suffix (out of the
scan-orchestrator → scanner count stays 15, snapshot suite untouched); the command's
orchestration is judgment-driven and deliberately outside the snapshot suite, exactly like
/config-audit optimize + knowledge-refresh. No new agent (web/judgment-free, runs in the
command's own context). suite 1127→1138.
campaign backlog — cross-repo prioritized pick-list (v5.7 Fase 2, Block 4b)
The first half of Block 4 ("one cross-repo prioritized backlog the user picks from"). A pure lib transform + a read-only CLI-payload field — no schema change, no new scanner, byte-stable.
buildBacklog(ledger)(scanners/lib/campaign-ledger.mjs, pure, mirrorsrollUp): the single machine-wide prioritized work list. The actionable unit is a repo (the ledger tracks per-repo severity counts, not individual findings — it tracks state, it does not re-run audits), so each item is one repo:{path, name, status, sessionId, findingsBySeverity (normalized), totalFindings, weightedScore, rank}. Inclusion:status !== 'implemented'ANDtotalFindings > 0(implemented = done; pending / zero-finding repos have nothing known to fix — they still surface inrollUp.byStatus). Order: DESC byweightedScore(exportedSEVERITY_WEIGHTS = {critical:1000, high:100, medium:10, low:1}), tie-broken lexicographically by critical→high→medium→low count, then ascendingname— fully deterministic, and the tie-break keeps "criticals always win" even on a weighted-score collision (1 critical vs 10 high).rankis 1-based after the sort.campaign-clinow emitsbacklog: buildBacklog(ledger)in both branches (uninitialized →[]). Purely additive + read-only → fits the Block 3b read-only contract; the existing CLI tests use targeted asserts (not fulldeepEqual), so the new field doesn't break them.commands/campaign.mdrenders the backlog as a "Prioritized backlog" pick-list and points the user at the top item (still a pick-list, NOT an executor — execution is the later 4c block).
Byte-stability. -cli/lib/command only → scanner count stays 15, snapshot/backcompat
suite untouched. suite 1138→1150 (lib +9, campaign-cli +3). Deferred to 4c: per-repo plan
export to each repo's docs/ + reuse of backup/rollback for execution. Deferred until the first
breaking schema change: migrateLedger (4a) — backlog needs no schema bump, so building
migration now would be speculative (schemaVersion is already stamped for when it's needed).
campaign plan-export + execution-by-reuse (v5.7 Fase 2, Block 4c — the rest of Block 4)
The second half of Block 4. Asymmetric: plan export is the new testable code; execution is pure reuse (no new machinery), per the plan's "reuse existing backup/rollback".
- Plan export (
scanners/lib/campaign-export.mjs, pure, 8 tests):planExportPath(repoPath, sessionId)→<repo>/docs/config-audit-plan-<sessionId>.md(keyed on the timestamp-unique sessionId, not the date, so same-day re-audits don't collide);buildPlanExportDocument({...,now})→ provenance header (repo/session/how-to-execute-and-undo) + the verbatim session plan.nowinjected → deterministic. CLIscanners/campaign-export-cli.mjs(-cli, read-only by default, 10 tests):--repo <path>resolves the repo's linked session, reads itsaction-plan.md, assembles the doc, emits{exportable, problems, targetPath, document, ...}. Two gates → exit 1 advisory:no-session-linked(repo has nosessionId),no-action-plan(linked session has no plan yet). Writes the file only under opt-in--write— the CLI does the byte-faithful copy so a 200-line plan is never re-typed/mutated by the LLM.--sessions-diroverride for hermetic tests; exit 0/1/3 mirror the sibling CLIs. - Execution = reuse. No campaign-side execution code. The exported
docs/file is the repo's durable record;/config-audit implementstill reads the canonical plan from the session (backup + apply + verify),/config-audit rollbackundoes, thenset-status <path> implementedrecords it. The command (commands/campaign.md, newexport <path>mode) previews → asks → on approval invokes--write→ routes the user to that existing machinery. - Byte-stable. lib +
-cli+ command-doc only → scanner count stays 15, agents 7, commands 21 (export is a mode, not a new command), snapshot/backcompat suite untouched. suite 1150→1168 (lib +8, export-cli +10). Block 4a (migrateLedger) still deferred to the first breaking schema change (export needs no schema bump).
arg-sluk — the CLI argument class, measured across all fourteen CLIs (v5.14, #57)
scanners/lib/cli-args.mjs. Every CLI in scanners/ parses argv with a chain of
if (a === '--x') … else if …, and two things fell through that chain in silence.
Arm 1 — the unknown flag. With no else branch, --zzz left no trace: exit 0, full
payload, a confident answer to a question the caller did not ask. First costed in #51, when
knowledge-refresh's only knob reached the CLI malformed and the command reported "all 14
register entries were re-verified within the last 90 days" — about a threshold the user had
just overridden.
Arm 2 — the value that was really a flag. a === '--output-file' && args[i + 1] asks
only whether a next token exists, never whether it is a value. So --output-file --json
took --json as the filename. Measured: manifest, campaign-cli and
knowledge-refresh-cli each wrote a file literally named --json into the caller's
working directory, exit 0, with --json mode silently dropped. A wrong answer is bad; an
unintended file on disk is worse.
Width — the deferral list was a prediction, not a measurement. KNOWN_OPEN in
tests/scanners/cli-unknown-flag-rejection.test.mjs named two CLIs. Measuring all
fourteen found 7 open on arm 1 and 10 on arm 2 — including campaign-cli and
knowledge-refresh-cli, which were already in GUARDED and passing the arm-1 test while
arm 2 stood open a few lines away. Three CLIs (drift-cli, fix-cli,
plugin-health-scanner) were already correct on both arms because they use a different
parse form; they were moved into GUARDED rather than left unguarded.
Why a gate and not a rewrite. The module runs before each CLI's existing loop and does
not replace it. Valid argv therefore reaches the existing parser byte-for-byte unchanged, so
no frozen snapshot can move — the byte-stability argument is structural, not empirical. The
three CLIs that carried a bespoke else if (a.startsWith('--')) fail(…) branch had it removed
once the gate made it unreachable, along with the now-redundant && args[i + 1] guards.
Exit code is 3 by the exit-code contract: a malformed argument means the scanner never got to do its job, which is categorically different from 0/1/2 — verdicts about a configuration that was examined.