config-audit/docs/scanner-internals.md
Kjell Tore Guttormsen 9ae4be26d2 feat(scanners): model/effort routing becomes a lever, not a 25th dimension (C4)
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
2026-08-10 05:07:23 +02:00

64 KiB
Raw Blame History

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 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)
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:

  1. Component-level sources (plugin roll-up dropped). The coarse kind:'plugin' aggregate is gone. A plugin contributes via its skills/rules/agents/output-styles/hooks/MCP — each already enumerated once by readActiveConfig — so the old roll-up double-counted them (the plugin aggregate's estimatedTokens already summed its components). Source kinds are now claude-md/skill/rule/agent/output-style/mcp-server/hook.
  2. Load-pattern triple on every record + a summary. Each source carries loadPattern/survivesCompaction/derivationConfidence. Rules/agents/output-styles propagate the foundation-derived values (rules vary by scoped); CLAUDE.md maps scope→ kind via CLAUDE_MD_SCOPE_KIND (all cascade files walk up, so all are always-loaded); skills are tagged on-demand via deriveLoadPattern('skill-body') — the measured tokens are the skill body (paid on invoke), not the tiny always-loaded name+desc listing (tracked by skill-listing-budget/posture), so tagging the body always would inflate the headline. The new summary buckets sources into always/onDemand/external/unknown {tokens,count}; the always-loaded subtotal ("≈X tokens enter context every turn before you type") is the headline.

Byte-stability. manifest is an environment-aware CLI → SC-6/SC-7 verify it by mode-equivalence (--json == --raw), not byte-equal against a frozen snapshot, and it is not in SC-5 default-output. Adding fields in place therefore keeps all snapshots green with no regen (verified). total changes (de-duped, component-level) — that is the intended correctness fix.

token-hotspots — load-pattern column (v5.6 B2)

TOK now annotates every ranked hotspot with the same load-pattern triple (hotspotLoadPattern maps each discovery type→a deriveLoadPattern kind; rules reuse activeConfig.rules for precise scoped handling; claude-md maps by scope). Two new deriveLoadPattern kinds back this: command (on-demand — body loads on /invoke) and harness-config (external — settings/ keybindings/.mcp.json/hooks.json/plugin.json configure the CLI, not the model context, so they cost no per-turn context tokens). Note the honest split: the .mcp.json file is external, while the MCP server's tool schemas are a separate always hotspot.

Byte-stability — the opposite of manifest. token-hotspots is a byte-equal SC-6/SC-7 CLI, and its hotspots ride inside the scan-orchestrator + posture payloads, so the change broke six frozen-v5.0.0 comparisons across five test files (json/raw-backcompat + the three Step 5/6/7 humanizer tests). Resolved by preserving the frozen v5.0.0 baselines: a shared tests/helpers/strip-hotspot-load-pattern.mjs strips the additive triple before each byte-equal compare (proves the original schema is byte-identical), and the SC-5 default-output snapshots (scan-orchestrator + token-hotspots) were regenerated (UPDATE_SNAPSHOT=1) since their job is to track current output — diff reviewed as additive-only. Lesson for any future hotspot/scanner-output field: grep every frozen-v5.0.0 comparator (it is 5 files, not 2) before assuming the blast radius.

token-hotspots — MCP tool-schema deferral (v5.10 B4, CA-TOK-006)

By default Claude Code defers MCP tool schemas: only tool names enter the always-loaded prefix (~120 tokens total) and full schemas load on demand via tool search. Several signals force the FULL schemas into the prefix every turn instead. CA-TOK-006 detects them from config files only, so the finding is deterministic and hermetic-safe (mirrors Pattern G's project-local scoping):

Signal Source Confidence
ENABLE_TOOL_SEARCH: "false" merged project+local settings.json env block high
"ToolSearch" in permissions.deny settings.json high
configured model matches /haiku/ settings.json (Haiku lacks tool_reference support) medium
per-server alwaysLoad: true project .mcp.json (CC v2.1.121+) high
ENABLE_TOOL_SEARCH: "auto[:N]" settings env threshold mode — info, not a trigger

The engine (lib/mcp-deferral.mjs) splits a pure assessMcpDeferral({settings, mcpServers}) (fully unit-tested, no IO) from a thin IO wrapper assessMcpDeferralForRepo(repoPath, {mcpServers}) shared by TOK and GAP. Severity scales with the aggregate forced-upfront token cost (severityForForcedSchemas: ≥5000→high, ≥1500→medium; medium-confidence reasons cap at medium).

Honest scoping decision (Verifiseringsplikt). The detector deliberately does NOT read process.env shell vars. Tool search is also disabled on Vertex AI, with a custom ANTHROPIC_BASE_URL (non-first-party host), or after a runtime /model switch to Haiku — but those are launch/runtime state, not config files, so triggering on them would make the finding machine-dependent (the marketplace-medium snapshot has MCP servers; an ambient ANTHROPIC_BASE_URL would flap it). They are disclosed in every finding (DEFERRAL_DISCLOSURE), never triggered. Tool-level anthropic/alwaysLoad (set server-side in the tools/list _meta) and claude.ai connectors are likewise invisible to a static scan and disclosed. Mechanism verified 2026-06-23 against code.claude.com/docs: context-window.md (MCP deferred, ~120 tok), mcp.md#configure-tool-search + #exempt-a-server-from-deferral, costs.md. The prefix-cache connect/disconnect-invalidation claim from the raw research was [NOT CONFIRMED] in docs and is NOT asserted by this finding.

feature-gap companion. cliOverMcpLeverFinding (GAP) fires only when CA-TOK-006's assessment shows schemas forced upfront — recommends preferring CLI (gh/aws/gcloud) over MCP for common operations (CLI adds zero context tokens until invoked). Deferred MCP is effectively free, so the lever stays silent in the default case (opportunity, not noise — mirrors the bundledSkills lever). alwaysLoad was added to CA-MCP's VALID_SERVER_FIELDS so it is never flagged as an unknown field.

hook-validator — unfiltered additionalContext advisory (v5.10 B5)

A hook that emits hookSpecificOutput.additionalContext has that payload injected into Claude's context every time it fires — plain stdout on exit 0 does NOT (it goes to the debug log only). A hook that dumps large, un-grepped command output into additionalContext is therefore a recurring, compaction-sensitive per-turn token cost. HKV flags it as an info advisory (weight 0 — never severity-bearing, excluded from the self-audit nonInfo set), paired with a feature-gap lever.

The heuristic lives in lib/hook-additional-context.mjs as a pure assessHookAdditionalContext({scriptContent}) (unit-tested, no IO) plus a thin IO wrapper assessHookContextForRepo(discovery) (walk hooks → scripts → assess) used by GAP; HKV calls the pure function inline on scripts it already reads. The signal:

Condition Detected by Effect
references additionalContext /additionalContext/ gate (else not applicable)
captures verbose-prone output cat/find/ls/git log|diff|status|show/npm/pytest/jest/curl/execSync/readFileSync hasVerboseCapture
applies any truncating filter grep/head/tail/sed/awk/jq/cut/wc/uniq/sort or .slice/.substring suppresses (assumed bounded)

flagged = buildsAdditionalContext && hasVerboseCapture && !hasFilter. A filtered capture (e.g. cat … | grep ERROR) or a cheap-only capture ($(date)) is not flagged.

Why info, not a hard finding (Verifiseringsplikt). This is deliberately low precision — a static scan cannot run the hook or measure the real payload, and a filter we don't recognise would be a false positive. So it ships as an advisory with the precision caveat in its own description, never a graded/severity-bearing finding. Mechanism verified 2026-06-23 against code.claude.com/docs: context-window.md"A PostToolUse hook … reports back via hookSpecificOutput.additionalContext. That field enters Claude's context. Plain stdout on exit 0 does not." + the tip to keep output concise (it enters context without truncation).

feature-gap companion. filterHookLeverFinding (GAP) fires only when assessHookContextForRepo returns ≥1 chatty hook — surfaces the documented filter-before-Claude-reads lever (filter-test-output.sh: grep ERROR and return only matches instead of a 10,000-line log). No chatty hook → silent (opportunity, not noise — same contract as the cliOverMcp / bundledSkills levers).

feature-gap — agent model/effort routing lever (v5.14 C4, CA-GAP-028)

agentModelRoutingLeverFinding fires only when the target has authored subagents (the same isAuthoredConfig set the presence checks use, so plugin-bundled and fixture agents cannot make a machine look routed — M-BUG-13) and not one of them names model: or effort:. Cites BP-MODEL-001 (a subagent's model defaults to inherit, so omitting it is a choice to pay the session's rate) and BP-MODEL-002 (effort is a separate axis with its own frontmatter field).

Why a lever and not a 25th dimension — decided by measurement, not taste. A dimension is always evaluated, so "no agents at all" would have to read as present, and present weight feeds the utilization score. Measured on tests/fixtures/marketplace-medium (hermetic HOME) before the change: utilization.score 44, segment "Developing", where the "Competent" boundary is 45. As a t3 dimension the denominators move 41→42 and the vacuous present pushes 18/41→19/42 = 45 — flipping segment in the frozen v5.0.0/posture.json, which strip-retired-gap.mjs does not mask (it drops only utilization.score/overhang and feature_coverage.score). A lever leaves every denominator alone and cannot move a score it never enters. The general rule now lives in CLAUDE.md (GAP dimensions vs. levers).

One check across both axes, not one per axis. It fires only when neither axis is used anywhere, so a deliberate everything-on-one-model policy stays silent. The cost is recall: a config that pins model: everywhere but never effort: gets no nudge. That is the v1 boundary, chosen for precision.

model: inherit is not routing — found by dogfooding, where installed agents write it out explicitly. inherit is the documented default, so spelling it out changes nothing about what the agent costs; counting it as a pin would let a config opt out of the opportunity without changing anything real. Effort has no documented sentinel of this kind, so it has no counterpart rule.

Two silences that must not be conflated. "No authored agents" (owned by dimension t2_6, No custom subagents) and "the only agents on disk are plugin-bundled" produce the same quiet output for different reasons. tests/scanners/gap-agent-model-routing.test.mjs P5 pins the second one specifically — it asserts the agent file was discovered before asserting silence, so the arm cannot pass for P4's reason.

Humanizer coverage is now a blanket invariant. The old guard asserted TRANSLATIONS.GAP.static keys equal GAP_CHECKS titles, which forbade humanizing any lever — so all three existing levers fell through to the generic GAP _default ("You have a feature opportunity worth a look"), wrong for a budget lever. The guard now requires a static entry for every title GAP can emit (dimensions levers), and it was seen red against those three before the four entries were written. TITLE_TO_ID keeps strict equality with GAP_CHECKS: levers are not dimensions and must stay out of scoring.

cache-prefix-scanner — @import extension (v5.10 B6)

CPS originally scanned only the files discovery classifies as claude-md. But a CLAUDE.md can pull arbitrary files into context with @import directives, and those targets are usually not claude-md in discovery (e.g. @shared/conventions.md) — so their content was inlined into the cached prefix yet never inspected. Neither TOK Pattern A (top-30 of cascade files) nor the in-file CPS scan reaches past the importing file, so volatility in an imported file was invisible.

B6 closes the gap: for each @import whose import site sits within the cached-prefix window (imp.line ≤ CACHED_PREFIX_LINES), CPS resolves the path (resolveImportPath, mirroring import-resolver/token-hotspots semantics), reads the target, and runs findVolatileLines over its first 150 lines. A hit emits a distinct medium finding — "Volatile content in @imported file breaks cached prefix" — keyed on the resolved file (so the fix points at the right place), with evidence naming the importer (imported by <file> (@<path> at line N)).

Scope boundaries (deliberate):

  • One hop only. Imports-of-imports are not followed — IMP owns deep-chain analysis. The verified win is the direct import; transitive resolution adds cycle/depth complexity for marginal coverage.
  • No lines-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 Claude Code's own startup warning — "Large CLAUDE.md will impact performance (X chars > 40.0k)" — as a char-based finding:

  • Char budget — flags a CLAUDE.md over ~40.0k chars (CC's startup-warning figure at a 200k-context model). CC 2.1.169 scales that threshold with the model's context window, so the finding anchors on the conservative 200k window (we cannot observe the user's window; the anchor fires earliest) and discloses the relaxed ~200,000-char figure at 1M context. Severity MEDIUM (token cost, not an adherence cliff). New CA-CML finding.

It keys on chars, not lines, so it is complementary to the line checks: a file can be long by lines yet under budget (short lines), or short by lines yet over it (long lines). The 200k/1M window constants live in the shared scanners/lib/context-window.mjs (single source of truth, also re-exported by skill-listing-budget.mjs). The 40.0k figure and context-window scaling are verified against the CC changelog (2.1.169) and the live startup-warning text.

DIS scanner — permission-rule hygiene

Beyond deny/allow overlap, the DIS scanner now also flags:

  • Ineffective allow wildcards — unanchored tool-name globs in permissions.allow (*, B*, mcp__*) that Claude Code silently skips (auto-approve nothing). Valid only as a glob-free mcp__<server>__*. New CA-DIS finding, severity low.
  • Tool(*) deny-all glob — treated as equivalent to a bare deny (Bash(*)Bash), so a bare allow killed by it is correctly reported as dead config.
  • Forbidden-param rulesTool(param:value) whose key is the tool's own canonicalizing field (command for Bash/PowerShell, file_path for Read/Edit/Write, path for Grep/Glob, notebook_path for NotebookEdit, url for WebFetch). CC ignores these and emits a startup warning. Severity follows intent: deny/ask = false security (medium) — the block never applies; allow = dead config (low)param:value matching is deny/ask-only. Valid forms (Bash(npm:*), WebFetch(domain:host), Agent(model:opus)) are never flagged. Predicate forbiddenParamRule in permission-rules.mjs.

These predicates live in scanners/lib/permission-rules.mjs (shared with the CNF conflict-detector). Behavior verified against code.claude.com/docs/en/permissions.

PLH scanner — plugin namespace collision

The standalone PLH scanner (cross-plugin checks in scan()) flags plugin namespace collisions: two or more discovered plugins that declare the same name in plugin.json. The search-first finding that shaped this check: Claude Code namespaces every plugin component by the declared name/name:command, name:skill, agent name (verified against code.claude.com/docs/en/plugins, and observable in any session's namespaced skill listing). A plugin component therefore can never shadow a user- or project-level one; the only shadow that loses components is a same-name collision, where the namespaces collapse into one and CC must pick a winner. Resolution between two installed same-name plugins is undocumented, so the loser's commands/skills/agents go silently unreachable — hence severity MEDIUM (dead config), category: 'plugin-hygiene', with a COL-shaped details.namespaces payload ({ source: 'plugin:<dir>', name, path }).

Two design notes: (1) the check keys on the declared name field, not basename(dir) — the folder name is irrelevant to the namespace; scanSinglePlugin now returns declaredName for this. (2) Name-less plugins are excluded from the collision map (they are flagged by the missing-field check and must never group on an undefined key).

The sibling cross-plugin command-name check was corrected to match the same model. Because commands are namespaced (/name:command), a command name shared by two differently-named plugins is ambiguity — not a hard conflict — so it now mirrors COL's plugin-vs-plugin skill finding: severity LOW, category: 'plugin-hygiene', COL-shaped details.namespaces, and a group-first shape (one finding per command name listing every namespace, not pairwise). It keys on the declared namespace and fires only when a name spans 2+ distinct namespaces; when two plugins share the same declared name, the namespace-collision finding above is the right (more severe) signal, so the command check stays silent there to avoid a redundant "dup, dup" report. The earlier HIGH Cross-plugin command name conflict finding (basename-keyed, "only one wins") is gone, along with its now-inaccurate humanizer entry.

PLH scanner — plugin-folder shadowing (CA-PLH-015)

Per-plugin check (in scanSinglePlugin, right after the required-field loop): a plugin.json component-path key that replaces its default folder while that folder still exists on disk → the folder is silently ignored (dead config). Severity MEDIUM, category: 'plugin-hygiene', details: { field, ignoredDir, customPaths }. Mirrors Claude Code's own warning in /doctor, claude plugin list, and the /plugin detail view (v2.1.140+).

The field set is primary-source-pinned to the replaces category only — SHADOWING_PATH_FIELDS = commands/agents/outputStyles (defaults commands/, agents/, output-styles/). Deliberately excluded: skills (per code.claude.com/docs/.../path-behavior-rules it adds to the default skills/ scan — both load, never a shadow), and hooks/mcpServers/lspServers (own merge rules, not a folder-shadow). Experimental themes/monitors are omitted because the docs warn their manifest schema may change between releases. The check also honors the doc's explicit-address exception: a custom path that resolves into the default folder ("commands": ["./commands/x.md"]) is not flagged, because Claude Code keeps scanning the folder in that case (addressesDefaultDir predicate). The v5.4.0 plan originally listed commands/agents/skills/hooks; that set was corrected here against the live docs (Verifiseringsplikt).

PLH scanner — skills:-array validation (CA-PLH-016)

Per-plugin check (in scanSinglePlugin, after the shadow check): when plugin.json has a skills field (string or array), each entry must resolve to an existing directory inside the plugin root. The value is normalized Array.isArray(v) ? v : [v], so a single string is one entry — and a non-string top-level value (e.g. 42) is naturally caught as a single non-string entry (no separate top-level check needed). One finding per bad entry, severity MEDIUM, category: 'plugin-hygiene', details: { field: 'skills', entry, problem } where problem is one of non-string / escapes-root / not-found / not-a-directory. Mirrors claude plugin validate (~2.1.145).

Escape detection uses skillsEntryEscapesRoot (resolve + startsWith(pluginDir + sep) containment — robust against a literal ..foo dir name), backed by the docs' path-traversal rule ("Installed plugins cannot reference files outside their directory … such as ../shared-utils"). statOrNull distinguishes missing from file-vs-dir. Verifiseringsplikt note: the v5.4.0 plan claimed CC "suggests the parent directory when an entry points at a file"; that exact error text is not in the primary docs, so it was dropped — the finding asserts only the four primary-source-verified conditions. skills is deliberately not in SHADOWING_PATH_FIELDS (it adds to the default scan, never shadows).

PLH scanner — scanDetailed, --output-file, and the marketplace.json exemption (økt #46)

scan() returns the frozen v5.0.0 envelope (scanner, status, files_scanned, duration_ms, findings, counts) and nothing else — --raw/--json print it verbatim and are snapshot-gated. Two things the /config-audit plugin-health report requires therefore cannot live there: one row per plugin (the | Plugin | Grade | Commands | Agents | table) and the cross-plugin/per-plugin split. Both were computed inside scan() and discarded at the return: pluginResults never escaped, and the only grade code — formatPluginHealthReport — had no caller anywhere in the repo.

scanDetailed(targetPath) is the seam. It returns { result, plugins, crossPluginFindings }; scan() is now (await scanDetailed(p)).result, so the byte-stable envelope is unchanged by construction. plugins[] carries name, declaredName, path, commandCount, agentCount, findingCount plus score/grade from the shared pluginGrade(issueCount) helper (which formatPluginHealthReport now also calls, so the formula has exactly one home).

Cross-plugin findings are identified positionally, not by predicate: crossPluginStart = allFindings.length is taken immediately before the namespace/command-name sections, and the tail is sliced off at the end. A predicate would have to key on category: 'plugin-hygiene', which the per-plugin shadow and skills findings share. The marker (crossPlugin: true) is stamped only on the humanized copies in the --output-file payload — never inside finding(), which would add a key to the frozen envelope.

--output-file follows the drift-cli contract: humanized payload in default mode, stdout untouched. This matters because default mode writes its report to stderr, and ux-rules rule 2 requires the command to run under 2>/dev/null — before this, commands/plugin-health.md (and both optional scanner calls in commands/posture.md) captured zero bytes. Argument parsing uses the same BOOL_FLAGS/VALUE_FLAGS + unknown-flag-throws shape as drift-cli/fix-cli (M-BUG-21, third arm); here the swallowed-flag failure mode was worse than an error — scanning the dropped flag's value found no plugins, so the scanner answered No plugins found (info) with exit 0.

marketplace.json exemption: .claude-plugin/'s known-file set is plugin.json and marketplace.json. The catalog's location is documented and required ("Create .claude-plugin/marketplace.json in your repository root"), and a marketplace entry with "source": "./" makes the repo root its own plugin — so one .claude-plugin/ legitimately holds both. Verified against the primary docs before the change; the check was a false positive, latent in this marketplace only because catalog/ ships no plugin.json and is thus not scanned as a plugin.

SET scanner — autoMode validation (CA-SET)

Per-file check in settings-validator.mjs (autoMode was in KNOWN_KEYS but had no nested validation). Two sub-checks, both primary-source-verified against code.claude.com/docs/en/auto-mode-config:

  1. Structure (severity MEDIUM): autoMode, if present, must be an object whose only keys are environment/allow/soft_deny/hard_deny (AUTO_MODE_SUBKEYS), each a string array (the literal "$defaults" is a valid entry, so it passes the string check for free). problemnot-an-object / unknown-subkey / not-string-array in details.
  2. Dead-config (severity LOW): Claude Code does not read autoMode from shared project settings — verbatim: "The classifier does not read autoMode from shared project settings in .claude/settings.json, so a checked-in repo cannot inject its own allow rules." The check keys on file.scope === 'project' (file-discovery's classifyScope returns 'project' for a committed .claude/settings.json; 'local'/'user'/'managed' are read and not flagged). problem: 'shared-project-scope'. This is why the plan's "test per-file scope first" gate passed — ConfigFile already carries scope.

The two sub-checks are independent (a malformed autoMode in shared scope yields both). SET is in the orchestrator, so SC-5 was re-checked after this change — byte-equal (the snapshot fixture has no autoMode, so the block never fires there).

OST scanner — output-style validation (CA-OST, v5.6 C, count 13→14)

New orchestrated scanner output-style-scanner.mjs — the first new scanner family since SKL (v5.2.0). It reads the active config (readActiveConfig) and each output-style file's frontmatter (via parseFrontmatter, keys hyphen→underscore-normalized, so it reads keep_coding_instructions / force_for_plugin). Three findings, every claim pinned to a CONFIRMED row of docs/v5.5-steering-model-plan.md (V9/V10/V11/V12), re-verified against code.claude.com/docs/en/output-styles + .../plugins-reference:

  • CA-OST-001 (medium) — a user/project custom style not setting keep-coding-instructions: true. The flag defaults to false, so the style silently removes Claude Code's built-in software-engineering instructions when active (V10). Scoped to user/project (the styles the user authors); a plugin author's choice is out of scope.
  • CA-OST-002 (low) — a plugin style with force-for-plugin: true, which auto-applies and overrides the user's selected outputStyle (V11). Verifiseringsplikt correction: the v5.5+ plan's CA-OST-002 bullet said "in a project/user style," but force-for-plugin is plugin-styles-only per the docs (its own cited V11 + output-styles.md), so the check keys on source === 'plugin' — a user/project style with the flag is simply ignored, not an override.
  • CA-OST-003 (medium) — a settings outputStyle value resolving to no built-in (Default/Explanatory/Learning/Proactive, matched case-insensitively) and no discovered custom style → dead config (CC falls back to default; the configured behavior never applies).

Byte-stability — a scanner addition, NOT a field addition. Adding the 14th scanner grows envelope.scanners by one entry and bumps aggregate.scanners_ok 12→13 on the deterministic fixture regardless of findings — a field-strip helper cannot paper this over. The SKL precedent (7bb2547) re-seeded the frozen v5.0.0 snapshots, but that predates B2's strip-preservation regime; re-seeding now would bake in B2's hotspot triple + claudeMdEstimatedTokens drift (verified by inspecting the seed diff). So, consistent with the B2 lesson ("preserve frozen via strip-helper; regen ONLY SC-5"), C preserves the frozen v5.0.0 snapshots and strips the OST entry at compare time: shared tests/helpers/strip-added-scanner.mjs (stripAddedScanners removes OST entries + decrements scanners_ok; stripAddedScannerStderr drops the [OST] progress line) is wired into json/raw-backcompat + the Step 5/6 humanizer wiring tests (cli-humanizer did not break — its v5.0.0 compares don't grow a scanners array). Only SC-5 default-output (scan-orchestrator + posture) is regenerated (additive OST entry only — diff reviewed). OST is fixture-gated: the marketplace-medium fixture and the hermetic HOME have no output styles, so it emits nothing there.

Wiring: orchestrator import + SCANNERS entry; humanizer.mjs SCANNER_TO_CATEGORY (OST: 'Configuration mistake'); humanizer-data.mjs OST family (title-coupled to the three exact finding titles); scoring.mjs SCANNER_AREA_MAP (OST: 'Settings' — keeps the 10 quality areas, byte-stable on zero-finding projects). Count badges: self-audit scanner count 13→14; humanizer-data TRANSLATIONS families 14→15 (PLH is a translation family but not orchestrated).

best-practices register — machine-readable knowledge layer (v5.7 Fase 1 Chunk 1)

knowledge/best-practices.json: provenance-stamped, schema-validated register (entry = id/claim/confidence/source + optional mechanism/lensCheck/…). First runtime-consumed file in knowledge/ (the *.md stay human-only); source of truth for the v5.7 optimization lens (CA-OPT); seeded from the v5.5 V-rows + the Anthropic "Steering Claude Code" blog. Only confirmed entries are user-facing (Verifiseringsplikt). Loaded/validated by scanners/lib/best-practices-register.mjs (loadRegister/validateRegister/getEntry; zero-dep JSON, not YAML — yaml-parser.mjs can't do arrays-of-objects). Byte-stable until a scanner consumes it (Chunk 2). Full design: docs/v5.7-optimization-lens-plan.md.

OPT scanner — optimization lens / mechanism-fit (CA-OPT, v5.7 Fase 1 Chunk 2a, count 14→15)

First detector of the «optimal?» axis (vs «correct?»). optimization-lens-scanner.mjs reads the best-practices register and flags config that works but fits a better mechanism. CA-OPT-001 (low, Missed opportunity): a CLAUDE.md procedure (≥6 consecutive numbered steps) that belongs in a skill — recommendation/provenance from register BP-MECH-003. Conservative (negative corpus = null false-positive); prose-judgment cases (lifecycle→hook, unscoped path→rule, «never»→permission) are handled by the Chunk 2b opus analyzer (below). Wiring mirrors OST: orchestrator entry, humanizer OPT:'Missed opportunity' + family, scoring OPT:'CLAUDE.md' (existing area → no new posture row → byte-stable), strip-helper OPT, SC-5 regenerated (additive).

Optimization lens Chunk 2b — opus analyzer (prose-judgment half, /config-audit optimize)

The hybrid motor's recall + precision halves for the three cases the deterministic OPT scanner skips. Pre-filter (scanners/lib/lens-prefilter.mjs, pure + tested): cheap, recall-oriented line scan of CLAUDE.md body for lifecycle phrasing (BP-MECH-001→hook), unscoped path-specific instructions (BP-MECH-002→rule), and absolute «never» prohibitions (BP-MECH-004→permission); skips fenced code, gates the path class on an instruction verb. Detector names = the register lensCheck fields. CLI (optimize-lens-cli.mjs, -cli → not a scanner): runs discovery + OPT scanner + pre-filter, attaches the confirmed register entry to each candidate (unverifiable → dropped, Verifiseringsplikt), emits {deterministic, candidates, register, counts}. Agent (optimization-lens-agent, opus, orange — the 7th agent, precision gate): reads the real CLAUDE.md, drops low-confidence candidates, keeps only genuine opportunities, cites register id + source. Command /config-audit optimize orchestrates pre-filter→agent→report. Agent-driven → deliberately NOT byte-stable (own command, outside the snapshot suite); the pre-filter lib is unit-tested (13 tests). No new orchestrated scanner → scanner count stays 15; agents 6→7, commands 18→19, suite 1055→1068.

Subtraction lens (optimize --subtract, BP-SUB-001) — the inverse axis

Why a mode, not a command or scanner. Every other command asks an addition question; nothing asked what is no longer earning its rent. A hand-built ground truth over a real 250-line global CLAUDE.md put the honest payoff at ~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.

knowledge-refresh — the "living" half of the register (v5.7 Fase 1 Chunk 3, commands 19→20)

Keeps knowledge/best-practices.json current so the optimization lens never reads stale rules. Same hybrid split as Chunk 2b — a deterministic, byte-stable, unit-tested core + a web/judgment shell:

  • Deterministic core (scanners/lib/knowledge-refresh.mjs, pure, 15 tests): assessFreshness(register, {referenceDate, staleAfterDays}) classifies each entry fresh/stale by the age of its source.verified stamp. referenceDate is injected (not read from the clock) so the function is fully deterministic; default threshold STALE_AFTER_DAYS_DEFAULT = 90 (quarterly re-verify cadence). An unparseable/missing verified → stale with ageDays: null (defensive; the schema-validated bundle never hits this, but the command's hand-built candidates might). «Source changed» detection is a web responsibility (command layer), not in this core.
  • CLI (scanners/knowledge-refresh-cli.mjs, -cliNOT an orchestrated scanner → scanner count stays 15, suite byte-stable; 8 tests): read-only — it NEVER writes the register and NEVER hits the network. --reference-date (defaults to today; the only place the clock is read) makes it deterministically testable against the bundled register. --stale-after N, --dry-run (implicit + only mode, echoed as requestedDryRun). Exit 0 = all fresh, 1 = some stale (advisory), 3 = error.
  • Command (commands/knowledge-refresh.md, opus): orchestrates CLI stale-report → re-verify each stale entry by re-reading its source.url (WebFetch) → poll CC changelog + Anthropic blog for new/changed practices (WebSearch) → present everything → apply ONLY human-approved writes, then re-run the register schema test before declaring done. No unverified claim is ever auto-written (Verifiseringsplikt). Web/judgment-driven → deliberately NOT byte-stable (own command, outside the snapshot suite), exactly like /config-audit optimize. No new agent (web poll runs in the command's own context), no new orchestrated scanner. suite 1068→1091.

campaign-ledger — durable machine-wide campaign core (v5.7 Fase 2, Block 3a THIN)

scanners/lib/campaign-ledger.mjs: the durable ledger that sits ABOVE individual sessions for a machine-wide audit campaign — repo list + per-repo lifecycle (STATUSES = pending→audited→planned →implemented) + a machine-wide rollUp (counts by status + severity aggregated across repos). It persists to a single JSON file outside the plugin dir (~/.claude/config-audit/campaign-ledger .json, next to sessions/) so it survives uninstall/reinstall/upgrade. Same hybrid split as knowledge-refresh: PURE transforms (createLedger/addRepo/setRepoStatus/rollUp) with now injected (YYYY-MM-DD, never the clock) + soft validateLedger (returns {valid,errors}, never throws) + a thin IO shell (defaultLedgerPath/loadLedger→null-on-ENOENT/saveLedger). Transforms throw on programmer error (invalid status, unknown path); schemaVersion stamped from the start so a Block 4 migration is cheap. THIN: ledger + roll-up + persistence only — NO execution, CLI, or command surface (Blocks 3b/3c/4). Internal plumbing, byte-stable until consumed: no export async function scan + lives in lib/ → scanner count stays 15, no orchestrator wiring, SC-5 unchanged. 28 tests, suite 1091→1119.

campaign-cli — read-only ledger reporter (v5.7 Fase 2, Block 3b)

scanners/campaign-cli.mjs (-cli → NOT an orchestrated scanner → scanner count stays 15, suite byte-stable; 8 tests): the DETERMINISTIC, READ-ONLY half of the campaign motor, mirroring knowledge-refresh-cli. It 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 of campaign-cli that mutates. Subcommands init / add <path>... / set-status <path> <status>, each a thin wrapper over the invariant-enforcing lib transforms (createLedger/addRepo/setRepoStatus) + saveLedger — so path-normalization/dedup, idempotent add, the status-lifecycle guard, and the updatedDate bump are never re-implemented by hand. init refuses to clobber an existing (or corrupt) ledger (exit 1 advisory, file untouched); add auto-inits when no ledger exists and reports added vs skipped; set-status accepts --findings '<json>' + --session <id>. Determinism mirrors the lib + knowledge-refresh-cli: --reference-date is the only place the clock is read (defaults to today), passed to the transforms as the injected now. Exit: 0 = write performed, 1 = advisory no-op (init-clobber), 3 = error (unknown subcommand, bad args, invalid status, untracked repo, no/corrupt ledger).
  • commands/campaign.md (opus, allowed-tools: Read/Write/Edit/Bash/Globno Web, judgment-free): a thin orchestrator. It always reports first (read-only campaign-cli), then for init/add/set-status it proposes the change and, only on explicit human approval, invokes one write-CLI subcommand (Verifiseringsplikt — it never hand-edits the ledger JSON). add --discover <root> finds git repos under a root and lets the user pick. When marking a repo audited it attaches findings-by-severity from the repo's session (or user-provided counts) — never invented.

Not a new scanner, not byte-stable. Both CLIs carry the -cli suffix (out of the scan-orchestrator → scanner count stays 15, snapshot suite untouched); the command's orchestration is judgment-driven and deliberately outside the snapshot suite, exactly like /config-audit optimize + knowledge-refresh. No new agent (web/judgment-free, runs in the command's own context). suite 1127→1138.

campaign backlog — cross-repo prioritized pick-list (v5.7 Fase 2, Block 4b)

The first half of Block 4 ("one cross-repo prioritized backlog the user picks from"). A pure lib transform + a read-only CLI-payload field — no schema change, no new scanner, byte-stable.

  • buildBacklog(ledger) (scanners/lib/campaign-ledger.mjs, pure, mirrors rollUp): the single machine-wide prioritized work list. The actionable unit is a repo (the ledger tracks per-repo severity counts, not individual findings — it tracks state, it does not re-run audits), so each item is one repo: {path, name, status, sessionId, findingsBySeverity (normalized), totalFindings, weightedScore, rank}. Inclusion: status !== 'implemented' AND totalFindings > 0 (implemented = done; pending / zero-finding repos have nothing known to fix — they still surface in rollUp.byStatus). Order: DESC by weightedScore (exported SEVERITY_WEIGHTS = {critical:1000, high:100, medium:10, low:1}), tie-broken lexicographically by critical→high→medium→low count, then ascending name — fully deterministic, and the tie-break keeps "criticals always win" even on a weighted-score collision (1 critical vs 10 high). rank is 1-based after the sort.
  • campaign-cli now emits backlog: buildBacklog(ledger) in both branches (uninitialized → []). Purely additive + read-only → fits the Block 3b read-only contract; the existing CLI tests use targeted asserts (not full deepEqual), so the new field doesn't break them.
  • commands/campaign.md renders the backlog as a "Prioritized backlog" pick-list and points the user at the top item (still a pick-list, NOT an executor — execution is the later 4c block).

Byte-stability. -cli/lib/command only → scanner count stays 15, snapshot/backcompat suite untouched. suite 1138→1150 (lib +9, campaign-cli +3). Deferred to 4c: per-repo plan export to each repo's docs/ + reuse of backup/rollback for execution. Deferred until the first breaking schema change: migrateLedger (4a) — backlog needs no schema bump, so building migration now would be speculative (schemaVersion is already stamped for when it's needed).

campaign plan-export + execution-by-reuse (v5.7 Fase 2, Block 4c — the rest of Block 4)

The second half of Block 4. Asymmetric: plan export is the new testable code; execution is pure reuse (no new machinery), per the plan's "reuse existing backup/rollback".

  • Plan export (scanners/lib/campaign-export.mjs, pure, 8 tests): planExportPath(repoPath, sessionId)<repo>/docs/config-audit-plan-<sessionId>.md (keyed on the timestamp-unique sessionId, not the date, so same-day re-audits don't collide); buildPlanExportDocument({...,now}) → provenance header (repo/session/how-to-execute-and-undo) + the verbatim session plan. now injected → deterministic. CLI scanners/campaign-export-cli.mjs (-cli, read-only by default, 10 tests): --repo <path> resolves the repo's linked session, reads its action-plan.md, assembles the doc, emits {exportable, problems, targetPath, document, ...}. Two gates → exit 1 advisory: no-session-linked (repo has no sessionId), no-action-plan (linked session has no plan yet). Writes the file only under opt-in --write — the CLI does the byte-faithful copy so a 200-line plan is never re-typed/mutated by the LLM. --sessions-dir override for hermetic tests; exit 0/1/3 mirror the sibling CLIs.
  • Execution = reuse. No campaign-side execution code. The exported docs/ file is the repo's durable record; /config-audit implement still reads the canonical plan from the session (backup + apply + verify), /config-audit rollback undoes, then set-status <path> implemented records it. The command (commands/campaign.md, new export <path> mode) previews → asks → on approval invokes --write → routes the user to that existing machinery.
  • Byte-stable. lib + -cli + command-doc only → scanner count stays 15, agents 7, commands 21 (export is a mode, not a new command), snapshot/backcompat suite untouched. suite 1150→1168 (lib +8, export-cli +10). Block 4a (migrateLedger) still deferred to the first breaking schema change (export needs no schema bump).

arg-sluk — the CLI argument class, measured across all fourteen CLIs (v5.14, #57)

scanners/lib/cli-args.mjs. Every CLI in scanners/ parses argv with a chain of if (a === '--x') … else if …, and two things fell through that chain in silence.

Arm 1 — the unknown flag. With no else branch, --zzz left no trace: exit 0, full payload, a confident answer to a question the caller did not ask. First costed in #51, when knowledge-refresh's only knob reached the CLI malformed and the command reported "all 14 register entries were re-verified within the last 90 days" — about a threshold the user had just overridden.

Arm 2 — the value that was really a flag. a === '--output-file' && args[i + 1] asks only whether a next token exists, never whether it is a value. So --output-file --json took --json as the filename. Measured: manifest, campaign-cli and knowledge-refresh-cli each wrote a file literally named --json into the caller's working directory, exit 0, with --json mode silently dropped. A wrong answer is bad; an unintended file on disk is worse.

Width — the deferral list was a prediction, not a measurement. KNOWN_OPEN in tests/scanners/cli-unknown-flag-rejection.test.mjs named two CLIs. Measuring all fourteen found 7 open on arm 1 and 10 on arm 2 — including campaign-cli and knowledge-refresh-cli, which were already in GUARDED and passing the arm-1 test while arm 2 stood open a few lines away. Three CLIs (drift-cli, fix-cli, plugin-health-scanner) were already correct on both arms because they use a different parse form; they were moved into GUARDED rather than left unguarded.

Why a gate and not a rewrite. The module runs before each CLI's existing loop and does not replace it. Valid argv therefore reaches the existing parser byte-for-byte unchanged, so no frozen snapshot can move — the byte-stability argument is structural, not empirical. The three CLIs that carried a bespoke else if (a.startsWith('--')) fail(…) branch had it removed once the gate made it unreachable, along with the now-redundant && args[i + 1] guards.

Exit code is 3 by the exit-code contract: a malformed argument means the scanner never got to do its job, which is categorically different from 0/1/2 — verdicts about a configuration that was examined.