R1+R2 as one chunk — both KRITISK rows of the Q3 severity table sit on the restore path, and neither closes alone. R1: rollback-engine.mjs verified every checksum before AND after each write, resolved the legacy backup root and reported createdNotRemoved — and none of it was reachable. Measured: 16 files under scanners/ carry a process.argv entry; the engine was not one of them. commands/rollback.md drove the restore as model prose: an ESM import block a template cannot execute, ad-hoc `cp` offered underneath as the runnable path, and "(checksum verified)" pre-rendered three times in the success output. `cp` establishes no checksum, so the verification was a property of the template rather than of the run — on the one surface that runs when the user is already in trouble. R2: implement.md Step 3 hand-built its backup (mkdir, cp, a date-derived id, a manifest typed out in the template) while parseManifest knew one frozen sample of that format, pinned by a HAND-WRITTEN fixture instead of by the template's own text. Rename a key and parseManifest returns zero files while rollback reports success. Fixing only R1 leaves the new CLI parsing a prose format; fixing only R2 leaves a clean format with no runnable entry. - scanners/rollback-cli.mjs — --list / --create / --restore / --delete over the existing engine, on the shared requireValidArgs gate. Exit 0 done, 1 outstanding (gate refusal with nothing written, or a backup that covered fewer targets than given), 2 a file failed, 3 could not do the job. A gated restore is 1, not 3: "this write leaves your project" is a verdict about a write that WAS examined, and it rides in the payload where a command under 2>/dev/null can act on it. - createBackup gains `created` (recorded, never copied — no backup can hold a file that does not exist) and `skipped`, so a backup covering fewer files than asked is no longer indistinguishable from a clean one. - implement.md Step 3 and rollback.md now call the CLI. parseManifest's implement-format branch stays: nothing writes that shape now, but every backup made before this chunk is on disk in it. - backup-restore-contract.test.mjs checks every field rollback.md renders against a payload produced by RUNNING the CLI. That is what replaced "(checksum verified)". 20 guards seen red against the original state before any production code, then each against its own defect. Two holes that surfaced there were mine: the implement assertion matched `--create` as a substring of `--created` and stayed green when the call was removed; and mutating the argv gate showed requireValidArgs sets exit 3 by itself, so a CLI can report that it could not parse its arguments and still run the restore underneath — that case is now asserted on the bytes. Suite 1752 -> 1777, 0 fail. Frozen tests/snapshots/v5.0.0 untouched. Dogfooded through the templates' own command lines against a sandboxed HOME, including the machine-wide arm: refused with the file unchanged, then restored under --approve-scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Logq8GGWKhtyDem63FTEnG
875 lines
68 KiB
Markdown
875 lines
68 KiB
Markdown
# 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:
|
||
|
||
1. **Component-level sources (plugin roll-up dropped).** The coarse `kind:'plugin'` aggregate is
|
||
gone. A plugin contributes via its skills/rules/agents/output-styles/hooks/MCP — each already
|
||
enumerated **once** by `readActiveConfig` — so the old roll-up double-counted them (the plugin
|
||
aggregate's `estimatedTokens` already summed its components). Source kinds are now
|
||
`claude-md`/`skill`/`rule`/`agent`/`output-style`/`mcp-server`/`hook`.
|
||
2. **Load-pattern triple on every record + a `summary`.** Each source carries
|
||
`loadPattern`/`survivesCompaction`/`derivationConfidence`. Rules/agents/output-styles
|
||
**propagate** the foundation-derived values (rules vary by `scoped`); CLAUDE.md maps `scope`→
|
||
kind via `CLAUDE_MD_SCOPE_KIND` (all cascade files walk **up**, so all are always-loaded);
|
||
skills are tagged **on-demand** via `deriveLoadPattern('skill-body')` — the measured tokens are
|
||
the skill **body** (paid on invoke), not the tiny always-loaded name+desc listing (tracked by
|
||
`skill-listing-budget`/posture), so tagging the body always would inflate the headline. The new
|
||
`summary` buckets sources into `always`/`onDemand`/`external`/`unknown` `{tokens,count}`; the
|
||
**always-loaded subtotal** ("≈X tokens enter context every turn before you type") is the headline.
|
||
|
||
**Byte-stability.** manifest is an **environment-aware CLI** → SC-6/SC-7 verify it by
|
||
**mode-equivalence** (`--json == --raw`), not byte-equal against a frozen snapshot, and it is not in
|
||
SC-5 default-output. Adding fields in place therefore keeps all snapshots green with **no regen**
|
||
(verified). `total` changes (de-duped, component-level) — that is the intended correctness fix.
|
||
|
||
### token-hotspots — load-pattern column (v5.6 B2)
|
||
|
||
TOK now annotates every ranked hotspot with the same load-pattern triple (`hotspotLoadPattern`
|
||
maps each discovery `type`→a `deriveLoadPattern` kind; rules reuse `activeConfig.rules` for precise
|
||
`scoped` handling; `claude-md` maps by scope). Two new `deriveLoadPattern` kinds back this:
|
||
**`command`** (on-demand — body loads on `/invoke`) and **`harness-config`** (external — settings/
|
||
keybindings/`.mcp.json`/hooks.json/plugin.json configure the CLI, **not** the model context, so they
|
||
cost no per-turn context tokens). Note the honest split: the `.mcp.json` **file** is `external`,
|
||
while the MCP **server**'s tool schemas are a separate `always` hotspot.
|
||
|
||
**Byte-stability — the opposite of manifest.** token-hotspots **is** a byte-equal SC-6/SC-7 CLI,
|
||
**and** its hotspots ride inside the scan-orchestrator + posture payloads, so the change broke
|
||
**six** frozen-v5.0.0 comparisons across five test files (json/raw-backcompat + the three Step 5/6/7
|
||
humanizer tests). Resolved by **preserving the frozen v5.0.0 baselines**: a shared
|
||
`tests/helpers/strip-hotspot-load-pattern.mjs` strips the additive triple before each byte-equal
|
||
compare (proves the original schema is byte-identical), and the **SC-5 default-output** snapshots
|
||
(scan-orchestrator + token-hotspots) were **regenerated** (`UPDATE_SNAPSHOT=1`) since their job is to
|
||
track current output — diff reviewed as additive-only. **Lesson for any future hotspot/scanner-output
|
||
field:** grep every frozen-v5.0.0 comparator (it is 5 files, not 2) before assuming the blast radius.
|
||
|
||
### token-hotspots — MCP tool-schema deferral (v5.10 B4, CA-TOK-006)
|
||
|
||
By default Claude Code **defers** MCP tool schemas: only tool *names* enter the always-loaded prefix
|
||
(~120 tokens total) and full schemas load on demand via tool search. Several signals force the FULL
|
||
schemas into the prefix every turn instead. CA-TOK-006 detects them from **config files only**, so the
|
||
finding is deterministic and hermetic-safe (mirrors Pattern G's project-local scoping):
|
||
|
||
| Signal | Source | Confidence |
|
||
|--------|--------|------------|
|
||
| `ENABLE_TOOL_SEARCH: "false"` | merged project+local settings.json `env` block | high |
|
||
| `"ToolSearch"` in `permissions.deny` | settings.json | high |
|
||
| configured `model` matches `/haiku/` | settings.json (Haiku lacks `tool_reference` support) | medium |
|
||
| per-server `alwaysLoad: true` | project `.mcp.json` (CC v2.1.121+) | high |
|
||
| `ENABLE_TOOL_SEARCH: "auto[:N]"` | settings `env` | threshold mode — **info, not a trigger** |
|
||
|
||
The engine (`lib/mcp-deferral.mjs`) splits a **pure** `assessMcpDeferral({settings, mcpServers})`
|
||
(fully unit-tested, no IO) from a thin IO wrapper `assessMcpDeferralForRepo(repoPath, {mcpServers})`
|
||
shared by TOK and GAP. Severity scales with the aggregate forced-upfront token cost
|
||
(`severityForForcedSchemas`: ≥5000→high, ≥1500→medium; **medium-confidence reasons cap at medium**).
|
||
|
||
**Honest scoping decision (Verifiseringsplikt).** The detector deliberately does **NOT** read
|
||
`process.env` shell vars. Tool search is also disabled on **Vertex AI**, with a custom
|
||
**`ANTHROPIC_BASE_URL`** (non-first-party host), or after a runtime **`/model`** switch to Haiku — but
|
||
those are launch/runtime state, not config files, so triggering on them would make the finding
|
||
machine-dependent (the marketplace-medium snapshot has MCP servers; an ambient `ANTHROPIC_BASE_URL`
|
||
would flap it). They are **disclosed** in every finding (`DEFERRAL_DISCLOSURE`), never triggered.
|
||
Tool-level `anthropic/alwaysLoad` (set server-side in the `tools/list` `_meta`) and claude.ai
|
||
connectors are likewise invisible to a static scan and disclosed. Mechanism verified 2026-06-23
|
||
against `code.claude.com/docs`: `context-window.md` (MCP deferred, ~120 tok),
|
||
`mcp.md#configure-tool-search` + `#exempt-a-server-from-deferral`, `costs.md`. The prefix-cache
|
||
connect/disconnect-invalidation claim from the raw research was **`[NOT CONFIRMED]`** in docs and is
|
||
NOT asserted by this finding.
|
||
|
||
**feature-gap companion.** `cliOverMcpLeverFinding` (GAP) fires **only** when CA-TOK-006's assessment
|
||
shows schemas forced upfront — recommends preferring CLI (`gh`/`aws`/`gcloud`) over MCP for common
|
||
operations (CLI adds zero context tokens until invoked). Deferred MCP is effectively free, so the
|
||
lever stays silent in the default case (opportunity, not noise — mirrors the bundledSkills lever).
|
||
`alwaysLoad` was added to CA-MCP's `VALID_SERVER_FIELDS` so it is never flagged as an unknown field.
|
||
|
||
### hook-validator — unfiltered additionalContext advisory (v5.10 B5)
|
||
|
||
A hook that emits `hookSpecificOutput.additionalContext` has that payload injected into Claude's
|
||
context **every time it fires** — plain stdout on exit 0 does NOT (it goes to the debug log only). A
|
||
hook that dumps large, un-grepped command output into `additionalContext` is therefore a recurring,
|
||
compaction-sensitive per-turn token cost. HKV flags it as an **`info` advisory** (weight 0 — never
|
||
severity-bearing, excluded from the self-audit `nonInfo` set), paired with a feature-gap lever.
|
||
|
||
The heuristic lives in `lib/hook-additional-context.mjs` as a **pure** `assessHookAdditionalContext({scriptContent})`
|
||
(unit-tested, no IO) plus a thin IO wrapper `assessHookContextForRepo(discovery)` (walk hooks → scripts
|
||
→ assess) used by GAP; HKV calls the pure function inline on scripts it already reads. The signal:
|
||
|
||
| Condition | Detected by | Effect |
|
||
|-----------|-------------|--------|
|
||
| references `additionalContext` | `/additionalContext/` | gate (else not applicable) |
|
||
| captures verbose-prone output | `cat`/`find`/`ls`/`git log\|diff\|status\|show`/`npm`/`pytest`/`jest`/`curl`/`execSync`/`readFileSync`… | `hasVerboseCapture` |
|
||
| applies any truncating filter | `grep`/`head`/`tail`/`sed`/`awk`/`jq`/`cut`/`wc`/`uniq`/`sort` or `.slice`/`.substring` | suppresses (assumed bounded) |
|
||
|
||
`flagged = buildsAdditionalContext && hasVerboseCapture && !hasFilter`. A filtered capture (e.g.
|
||
`cat … | grep ERROR`) or a cheap-only capture (`$(date)`) is not flagged.
|
||
|
||
**Why `info`, not a hard finding (Verifiseringsplikt).** This is deliberately **low precision** — a
|
||
static scan cannot run the hook or measure the real payload, and a filter we don't recognise would be
|
||
a false positive. So it ships as an advisory with the precision caveat in its own description, never a
|
||
graded/severity-bearing finding. Mechanism verified 2026-06-23 against `code.claude.com/docs`:
|
||
`context-window.md` — *"A PostToolUse hook … reports back via `hookSpecificOutput.additionalContext`.
|
||
That field enters Claude's context. Plain stdout on exit 0 does not."* + the tip to keep output concise
|
||
(it enters context without truncation).
|
||
|
||
**feature-gap companion.** `filterHookLeverFinding` (GAP) fires **only** when `assessHookContextForRepo`
|
||
returns ≥1 chatty hook — surfaces the documented **filter-before-Claude-reads** lever (`filter-test-output.sh`:
|
||
grep ERROR and return only matches instead of a 10,000-line log). No chatty hook → silent (opportunity,
|
||
not noise — same contract as the cliOverMcp / bundledSkills levers).
|
||
|
||
### feature-gap — agent model/effort routing lever (v5.14 C4, `CA-GAP-028`)
|
||
|
||
`agentModelRoutingLeverFinding` fires only when the target has **authored** subagents (the same
|
||
`isAuthoredConfig` set the presence checks use, so plugin-bundled and fixture agents cannot make a
|
||
machine look routed — M-BUG-13) and **not one of them** names `model:` or `effort:`. Cites
|
||
`BP-MODEL-001` (a subagent's `model` defaults to `inherit`, so omitting it is a choice to pay the
|
||
session's rate) and `BP-MODEL-002` (effort is a separate axis with its own frontmatter field).
|
||
|
||
**Why a lever and not a 25th dimension — decided by measurement, not taste.** A dimension is always
|
||
evaluated, so "no agents at all" would have to read as *present*, and present weight feeds the
|
||
utilization score. Measured on `tests/fixtures/marketplace-medium` (hermetic HOME) before the change:
|
||
`utilization.score` 44, `segment` "Developing", where the "Competent" boundary is 45. As a t3
|
||
dimension the denominators move 41→42 and the vacuous present pushes 18/41→19/42 = **45** — flipping
|
||
`segment` in the frozen `v5.0.0/posture.json`, which `strip-retired-gap.mjs` does **not** mask (it
|
||
drops only `utilization.score`/`overhang` and `feature_coverage.score`). A lever leaves every
|
||
denominator alone and cannot move a score it never enters. The general rule now lives in CLAUDE.md
|
||
(*GAP dimensions vs. levers*).
|
||
|
||
**One check across both axes, not one per axis.** It fires only when *neither* axis is used anywhere,
|
||
so a deliberate everything-on-one-model policy stays silent. The cost is recall: a config that pins
|
||
`model:` everywhere but never `effort:` gets no nudge. That is the v1 boundary, chosen for precision.
|
||
|
||
**`model: inherit` is not routing** — found by dogfooding, where installed agents write it out
|
||
explicitly. `inherit` is the documented default, so spelling it out changes nothing about what the
|
||
agent costs; counting it as a pin would let a config opt out of the opportunity without changing
|
||
anything real. Effort has no documented sentinel of this kind, so it has no counterpart rule.
|
||
|
||
**Two silences that must not be conflated.** "No authored agents" (owned by dimension `t2_6`,
|
||
*No custom subagents*) and "the only agents on disk are plugin-bundled" produce the same quiet
|
||
output for different reasons. `tests/scanners/gap-agent-model-routing.test.mjs` P5 pins the second
|
||
one specifically — it asserts the agent file *was* discovered before asserting silence, so the arm
|
||
cannot pass for P4's reason.
|
||
|
||
**Humanizer coverage is now a blanket invariant.** The old guard asserted `TRANSLATIONS.GAP.static`
|
||
keys *equal* `GAP_CHECKS` titles, which forbade humanizing any lever — so all three existing levers
|
||
fell through to the generic GAP `_default` ("You have a feature opportunity worth a look"), wrong for
|
||
a budget lever. The guard now requires a static entry for **every title GAP can emit** (dimensions ∪
|
||
levers), and it was seen red against those three before the four entries were written. `TITLE_TO_ID`
|
||
keeps strict equality with `GAP_CHECKS`: levers are not dimensions and must stay out of scoring.
|
||
|
||
### cache-prefix-scanner — @import extension (v5.10 B6)
|
||
|
||
CPS originally scanned only the files discovery classifies as `claude-md`. But a CLAUDE.md can pull
|
||
arbitrary files into context with `@import` directives, and those targets are usually *not* `claude-md`
|
||
in discovery (e.g. `@shared/conventions.md`) — so their content was inlined into the cached prefix yet
|
||
never inspected. Neither TOK Pattern A (top-30 of cascade files) nor the in-file CPS scan reaches past
|
||
the importing file, so volatility in an imported file was invisible.
|
||
|
||
B6 closes the gap: for each `@import` whose **import site** sits within the cached-prefix window
|
||
(`imp.line ≤ CACHED_PREFIX_LINES`), CPS resolves the path (`resolveImportPath`, mirroring
|
||
import-resolver/token-hotspots semantics), reads the target, and runs `findVolatileLines` over its first
|
||
150 lines. A hit emits a distinct medium finding — *"Volatile content in @imported file breaks cached
|
||
prefix"* — keyed on the resolved file (so the fix points at the right place), with evidence naming the
|
||
importer (`imported by <file> (@<path> at line N)`).
|
||
|
||
**Scope boundaries (deliberate):**
|
||
- **One hop only.** Imports-of-imports are not followed — IMP owns deep-chain analysis. The verified win
|
||
is the direct import; transitive resolution adds cycle/depth complexity for marginal coverage.
|
||
- **No lines-1–30 skip for imported content.** That exclusion exists only to avoid duplicating TOK
|
||
Pattern A's territory in the *root* file; Pattern A never reads imported files, so all of the imported
|
||
prefix counts.
|
||
- **No double-reporting.** An import resolving to a file that is itself a discovered `claude-md` is
|
||
skipped (it gets its own in-file iteration); a `reportedImports` set dedupes a target imported by
|
||
several CLAUDE.md files.
|
||
|
||
**Byte-stability.** The in-file finding is emitted under exactly the same condition and with byte-identical
|
||
evidence/description as before (the `continue`-skip was refactored to an `if`-emit — behaviour-preserving).
|
||
New findings fire only when a discovered CLAUDE.md imports a volatile file, which no frozen v5.0.0 fixture
|
||
does — snapshots and SC-5 verified untouched by the full suite.
|
||
|
||
**Dropped from B6 (per plan `verdict`).** Confident behavioral cache-buster detection (opusplan /
|
||
model-switch is a *runtime* behaviour, not static config a scanner can reliably flag) and jq-transcript
|
||
automation. "No overstated behavioral finding ships" — so even the permitted opusplan *info*-advisory was
|
||
left out; the verified @import extension is the whole of B6.
|
||
|
||
### GAP scanner — authored-config scoping + direct cascade read (M-BUG-13)
|
||
|
||
The 25 presence checks ask "does the user's effective config have feature X?" and GAP **always**
|
||
runs `includeGlobal: true`. Two failure modes made the answer wrong on a real machine, both surfaced
|
||
by dogfooding `feature-gap`/`posture --global`:
|
||
|
||
1. **Demo/vendored config masks real gaps.** This plugin's own `examples/optimal-setup/` is a complete
|
||
config (sets `outputStyle`/`statusLine`/`worktree`/`model`/`keybindings.json`/`.lsp.json`), and its
|
||
copies vendored under `~/.claude/plugins/cache/.../config-audit/<ver>/examples/` are pulled into the
|
||
includeGlobal discovery. Because `anySettingsHas`/`files.some(...)` accept ANY discovered file, that
|
||
one demo file drove every tier-3 check to "present" → **GAP=0 on any target** (false negative).
|
||
Fix: `isAuthoredConfig` filters `ctx.files`/`parsedSettings` to the user's authored cascade —
|
||
excludes `~/.claude/plugins/` (absPath marker, mirrors CNF's M-BUG-2 exclusion) and any file whose
|
||
path **relative to the scan target** sits under `examples/` or `tests/fixtures/`. relPath (not
|
||
absPath) is deliberate: a fixture scanned AS the target keeps its own files, so the frozen v5.0.0
|
||
snapshots (scanned from `tests/fixtures/marketplace-medium`, which has no such nested trees) are
|
||
byte-stable.
|
||
|
||
2. **The real `~/.claude/settings.json` is invisible to the settings-key checks.** Discovery misses it
|
||
(its relPath carries no `.claude` segment when the walk root IS `~/.claude` — the gotcha) AND, when
|
||
vendored plugins flood the walk, the `maxFiles=2000` cap drops it. After (1) removed the demo
|
||
maskers, `statusLine`/`autoMode`/`permissions` (which the user HAS) would flip to false **positives**.
|
||
Fix: `readSettingsCascade` reads the four canonical cascade paths (user `settings.json`/`.local`,
|
||
project `settings.json`/`.local`) directly and merges them INTO `parsedSettings` — immune to the
|
||
cap and the gotcha. Merge (not replace) keeps non-canonical project settings and leaves the snapshot
|
||
(hermetic empty HOME → cascade adds nothing new) byte-stable.
|
||
|
||
Net: an empty target now surfaces ~18 humanized opportunities (was masked to ~0); config-audit's own
|
||
repo still shows 0 in output via its intentional `.config-audit-ignore` `CA-GAP-*` self-suppression
|
||
(a plugin repo legitimately lacks user-project features) — suppression is an envelope-layer concern,
|
||
orthogonal to this scanner fix. Scoped GAP-local; the includeGlobal discovery gotcha itself is left
|
||
to other consumers (see auto-memory `discovery-includeglobal-user-settings-gotcha`).
|
||
|
||
### CML scanner — context-window-scaled char budget
|
||
|
||
Beyond the line-count checks (200/500 lines, both MEDIUM), the CML scanner mirrors
|
||
Claude Code's own startup warning — *"Large CLAUDE.md will impact performance
|
||
(X chars > 40.0k)"* — as a `char`-based finding:
|
||
|
||
- **Char budget** — flags a CLAUDE.md over **~40.0k chars** (CC's startup-warning
|
||
figure at a 200k-context model). CC 2.1.169 scales that threshold with the model's
|
||
context window, so the finding anchors on the conservative 200k window (we cannot
|
||
observe the user's window; the anchor fires earliest) and discloses the relaxed
|
||
~200,000-char figure at 1M context. Severity MEDIUM (token cost, not an adherence
|
||
cliff). New `CA-CML` finding.
|
||
|
||
It keys on chars, not lines, so it is complementary to the line checks: a file can be
|
||
long by lines yet under budget (short lines), or short by lines yet over it (long lines).
|
||
The 200k/1M window constants live in the shared `scanners/lib/context-window.mjs`
|
||
(single source of truth, also re-exported by `skill-listing-budget.mjs`). The 40.0k
|
||
figure and context-window scaling are verified against the CC changelog (2.1.169) and
|
||
the live startup-warning text.
|
||
|
||
### DIS scanner — permission-rule hygiene
|
||
|
||
Beyond deny/allow overlap, the DIS scanner now also flags:
|
||
|
||
- **Ineffective allow wildcards** — unanchored tool-name globs in `permissions.allow`
|
||
(`*`, `B*`, `mcp__*`) that Claude Code silently skips (auto-approve nothing). Valid
|
||
only as a glob-free `mcp__<server>__*`. New `CA-DIS` finding, severity low.
|
||
- **`Tool(*)` deny-all glob** — treated as equivalent to a bare deny (`Bash(*)` ≡ `Bash`),
|
||
so a bare allow killed by it is correctly reported as dead config.
|
||
- **Forbidden-param rules** — `Tool(param:value)` whose key is the tool's own canonicalizing
|
||
field (`command` for Bash/PowerShell, `file_path` for Read/Edit/Write, `path` for
|
||
Grep/Glob, `notebook_path` for NotebookEdit, `url` for WebFetch). CC ignores these and
|
||
emits a startup warning. Severity follows intent: **deny/ask = false security (medium)** —
|
||
the block never applies; **allow = dead config (low)** — `param:value` matching is
|
||
deny/ask-only. Valid forms (`Bash(npm:*)`, `WebFetch(domain:host)`, `Agent(model:opus)`)
|
||
are never flagged. Predicate `forbiddenParamRule` in `permission-rules.mjs`.
|
||
|
||
These predicates live in `scanners/lib/permission-rules.mjs` (shared with the CNF
|
||
conflict-detector). Behavior verified against `code.claude.com/docs/en/permissions`.
|
||
|
||
### PLH scanner — plugin namespace collision
|
||
|
||
The standalone PLH scanner (cross-plugin checks in `scan()`) flags **plugin namespace
|
||
collisions**: two or more discovered plugins that declare the **same `name`** in
|
||
`plugin.json`. The search-first finding that shaped this check: Claude Code namespaces
|
||
every plugin component by the declared `name` — `/name:command`, `name:skill`, agent
|
||
`name` (verified against `code.claude.com/docs/en/plugins`, and observable in any session's
|
||
namespaced skill listing). A plugin component therefore can **never** shadow a user- or
|
||
project-level one; the only shadow that loses components is a same-`name` collision, where
|
||
the namespaces collapse into one and CC must pick a winner. Resolution between two installed
|
||
same-name plugins is **undocumented**, so the loser's commands/skills/agents go silently
|
||
unreachable — hence severity **MEDIUM** (dead config), `category: 'plugin-hygiene'`, with a
|
||
COL-shaped `details.namespaces` payload (`{ source: 'plugin:<dir>', name, path }`).
|
||
|
||
Two design notes: (1) the check keys on the declared `name` field, **not** `basename(dir)` —
|
||
the folder name is irrelevant to the namespace; `scanSinglePlugin` now returns `declaredName`
|
||
for this. (2) Name-less plugins are excluded from the collision map (they are flagged by the
|
||
missing-field check and must never group on an `undefined` key).
|
||
|
||
The sibling cross-plugin **command-name** check was corrected to match the same model. Because
|
||
commands are namespaced (`/name:command`), a command name shared by two **differently-named**
|
||
plugins is ambiguity — not a hard conflict — so it now mirrors COL's plugin-vs-plugin skill
|
||
finding: severity **LOW**, `category: 'plugin-hygiene'`, COL-shaped `details.namespaces`, and a
|
||
group-first shape (one finding per command name listing every namespace, not pairwise). It keys
|
||
on the declared namespace and fires only when a name spans **2+ distinct** namespaces; when two
|
||
plugins share the same declared name, the namespace-collision finding above is the right (more
|
||
severe) signal, so the command check stays silent there to avoid a redundant `"dup, dup"` report.
|
||
The earlier HIGH `Cross-plugin command name conflict` finding (basename-keyed, "only one wins")
|
||
is gone, along with its now-inaccurate humanizer entry.
|
||
|
||
### PLH scanner — plugin-folder shadowing (`CA-PLH-015`)
|
||
|
||
Per-plugin check (in `scanSinglePlugin`, right after the required-field loop): a `plugin.json`
|
||
component-path key that **replaces** its default folder while that folder still exists on disk →
|
||
the folder is silently ignored (dead config). Severity **MEDIUM**, `category: 'plugin-hygiene'`,
|
||
`details: { field, ignoredDir, customPaths }`. Mirrors Claude Code's own warning in `/doctor`,
|
||
`claude plugin list`, and the `/plugin` detail view (v2.1.140+).
|
||
|
||
The field set is **primary-source-pinned** to the *replaces* category only —
|
||
`SHADOWING_PATH_FIELDS` = `commands`/`agents`/`outputStyles` (defaults `commands/`, `agents/`,
|
||
`output-styles/`). Deliberately excluded: **`skills`** (per
|
||
`code.claude.com/docs/.../path-behavior-rules` it *adds to* the default `skills/` scan — both
|
||
load, never a shadow), and **`hooks`/`mcpServers`/`lspServers`** (own merge rules, not a
|
||
folder-shadow). Experimental `themes`/`monitors` are omitted because the docs warn their manifest
|
||
schema may change between releases. The check also honors the doc's explicit-address exception: a
|
||
custom path that resolves *into* the default folder (`"commands": ["./commands/x.md"]`) is not
|
||
flagged, because Claude Code keeps scanning the folder in that case (`addressesDefaultDir`
|
||
predicate). The v5.4.0 plan originally listed `commands/agents/skills/hooks`; that set was
|
||
corrected here against the live docs (Verifiseringsplikt).
|
||
|
||
### PLH scanner — skills:-array validation (`CA-PLH-016`)
|
||
|
||
Per-plugin check (in `scanSinglePlugin`, after the shadow check): when `plugin.json` has a
|
||
`skills` field (string or array), each entry must resolve to an **existing directory inside the
|
||
plugin root**. The value is normalized `Array.isArray(v) ? v : [v]`, so a single string is one
|
||
entry — and a non-string top-level value (e.g. `42`) is naturally caught as a single non-string
|
||
entry (no separate top-level check needed). One finding per bad entry, severity **MEDIUM**,
|
||
`category: 'plugin-hygiene'`, `details: { field: 'skills', entry, problem }` where `problem` is
|
||
one of `non-string` / `escapes-root` / `not-found` / `not-a-directory`. Mirrors
|
||
`claude plugin validate` (~2.1.145).
|
||
|
||
Escape detection uses `skillsEntryEscapesRoot` (resolve + `startsWith(pluginDir + sep)`
|
||
containment — robust against a literal `..foo` dir name), backed by the docs' path-traversal rule
|
||
(*"Installed plugins cannot reference files outside their directory … such as `../shared-utils`"*).
|
||
`statOrNull` distinguishes missing from file-vs-dir. **Verifiseringsplikt note:** the v5.4.0 plan
|
||
claimed CC "suggests the parent directory when an entry points at a file"; that exact error text is
|
||
**not** in the primary docs, so it was dropped — the finding asserts only the four
|
||
primary-source-verified conditions. `skills` is deliberately *not* in `SHADOWING_PATH_FIELDS`
|
||
(it adds to the default scan, never shadows).
|
||
|
||
### PLH scanner — `scanDetailed`, `--output-file`, and the marketplace.json exemption (økt #46)
|
||
|
||
`scan()` returns the **frozen v5.0.0 envelope** (`scanner, status, files_scanned, duration_ms,
|
||
findings, counts`) and nothing else — `--raw`/`--json` print it verbatim and are snapshot-gated. Two
|
||
things the `/config-audit plugin-health` report requires therefore cannot live there: one row per
|
||
plugin (the `| Plugin | Grade | Commands | Agents |` table) and the cross-plugin/per-plugin split.
|
||
Both were computed inside `scan()` and discarded at the return: `pluginResults` never escaped, and
|
||
the only grade code — `formatPluginHealthReport` — had no caller anywhere in the repo.
|
||
|
||
`scanDetailed(targetPath)` is the seam. It returns `{ result, plugins, crossPluginFindings }`;
|
||
`scan()` is now `(await scanDetailed(p)).result`, so the byte-stable envelope is unchanged by
|
||
construction. `plugins[]` carries `name, declaredName, path, commandCount, agentCount, findingCount`
|
||
plus `score`/`grade` from the shared `pluginGrade(issueCount)` helper (which
|
||
`formatPluginHealthReport` now also calls, so the formula has exactly one home).
|
||
|
||
Cross-plugin findings are identified **positionally**, not by predicate: `crossPluginStart =
|
||
allFindings.length` is taken immediately before the namespace/command-name sections, and the tail is
|
||
sliced off at the end. A predicate would have to key on `category: 'plugin-hygiene'`, which the
|
||
per-plugin shadow and skills findings share. The marker (`crossPlugin: true`) is stamped only on the
|
||
**humanized copies** in the `--output-file` payload — never inside `finding()`, which would add a key
|
||
to the frozen envelope.
|
||
|
||
`--output-file` follows the `drift-cli` contract: humanized payload in default mode, stdout
|
||
untouched. This matters because default mode writes its report to **stderr**, and ux-rules rule 2
|
||
requires the command to run under `2>/dev/null` — before this, `commands/plugin-health.md` (and both
|
||
optional scanner calls in `commands/posture.md`) captured zero bytes. Argument parsing uses the same
|
||
`BOOL_FLAGS`/`VALUE_FLAGS` + unknown-flag-throws shape as `drift-cli`/`fix-cli` (M-BUG-21, third
|
||
arm); here the swallowed-flag failure mode was *worse than an error* — scanning the dropped flag's
|
||
value found no plugins, so the scanner answered `No plugins found` (info) with exit `0`.
|
||
|
||
**`marketplace.json` exemption:** `.claude-plugin/`'s known-file set is `plugin.json` **and**
|
||
`marketplace.json`. The catalog's location is documented and required (*"Create
|
||
`.claude-plugin/marketplace.json` in your repository root"*), and a marketplace entry with
|
||
`"source": "./"` makes the repo root its own plugin — so one `.claude-plugin/` legitimately holds
|
||
both. Verified against the primary docs before the change; the check was a false positive, latent in
|
||
this marketplace only because `catalog/` ships no `plugin.json` and is thus not scanned as a plugin.
|
||
|
||
### SET scanner — autoMode validation (`CA-SET`)
|
||
|
||
Per-file check in `settings-validator.mjs` (`autoMode` was in `KNOWN_KEYS` but had no nested
|
||
validation). Two sub-checks, both primary-source-verified against
|
||
`code.claude.com/docs/en/auto-mode-config`:
|
||
|
||
1. **Structure** (severity **MEDIUM**): `autoMode`, if present, must be an object whose only keys
|
||
are `environment`/`allow`/`soft_deny`/`hard_deny` (`AUTO_MODE_SUBKEYS`), each a **string
|
||
array** (the literal `"$defaults"` is a valid entry, so it passes the string check for free).
|
||
`problem` ∈ `not-an-object` / `unknown-subkey` / `not-string-array` in `details`.
|
||
2. **Dead-config** (severity **LOW**): Claude Code does **not** read `autoMode` from *shared*
|
||
project settings — verbatim: *"The classifier does not read `autoMode` from shared project
|
||
settings in `.claude/settings.json`, so a checked-in repo cannot inject its own allow rules."*
|
||
The check keys on **`file.scope === 'project'`** (file-discovery's `classifyScope` returns
|
||
`'project'` for a committed `.claude/settings.json`; `'local'`/`'user'`/`'managed'` are read and
|
||
not flagged). `problem: 'shared-project-scope'`. This is why the plan's "test per-file scope
|
||
first" gate passed — `ConfigFile` already carries `scope`.
|
||
|
||
The two sub-checks are independent (a malformed autoMode in shared scope yields both). SET is in the
|
||
orchestrator, so SC-5 was re-checked after this change — byte-equal (the snapshot fixture has no
|
||
`autoMode`, so the block never fires there).
|
||
|
||
### OST scanner — output-style validation (`CA-OST`, v5.6 C, count 13→14)
|
||
|
||
New orchestrated scanner `output-style-scanner.mjs` — the first new scanner family since SKL
|
||
(v5.2.0). It reads the active config (`readActiveConfig`) and each output-style file's frontmatter
|
||
(via `parseFrontmatter`, keys hyphen→underscore-normalized, so it reads `keep_coding_instructions` /
|
||
`force_for_plugin`). Three findings, every claim pinned to a CONFIRMED row of
|
||
`docs/v5.5-steering-model-plan.md` (V9/V10/V11/V12), re-verified against
|
||
`code.claude.com/docs/en/output-styles` + `.../plugins-reference`:
|
||
|
||
- **`CA-OST-001`** (medium) — a **user/project** custom style not setting `keep-coding-instructions:
|
||
true`. The flag defaults to **false**, so the style silently **removes** Claude Code's built-in
|
||
software-engineering instructions when active (V10). Scoped to user/project (the styles the user
|
||
authors); a plugin author's choice is out of scope.
|
||
- **`CA-OST-002`** (low) — a **plugin** style with `force-for-plugin: true`, which auto-applies and
|
||
**overrides** the user's selected `outputStyle` (V11). **Verifiseringsplikt correction:** the v5.5+
|
||
plan's CA-OST-002 bullet said "in a project/user style," but `force-for-plugin` is
|
||
**plugin-styles-only** per the docs (its own cited V11 + `output-styles.md`), so the check keys on
|
||
`source === 'plugin'` — a user/project style with the flag is simply ignored, not an override.
|
||
- **`CA-OST-003`** (medium) — a settings `outputStyle` value resolving to **no** built-in
|
||
(`Default`/`Explanatory`/`Learning`/`Proactive`, matched case-insensitively) and **no** discovered
|
||
custom style → dead config (CC falls back to default; the configured behavior never applies).
|
||
|
||
**Byte-stability — a scanner addition, NOT a field addition.** Adding the 14th scanner grows
|
||
`envelope.scanners` by one entry and bumps `aggregate.scanners_ok` 12→13 on the deterministic
|
||
fixture **regardless of findings** — a field-strip helper cannot paper this over. The SKL precedent
|
||
(`7bb2547`) re-seeded the frozen v5.0.0 snapshots, but that predates B2's strip-preservation regime;
|
||
re-seeding now would **bake in** B2's hotspot triple + `claudeMdEstimatedTokens` drift (verified by
|
||
inspecting the seed diff). So, consistent with the B2 lesson ("preserve frozen via strip-helper;
|
||
regen ONLY SC-5"), C **preserves** the frozen v5.0.0 snapshots and **strips the OST entry at compare
|
||
time**: shared `tests/helpers/strip-added-scanner.mjs` (`stripAddedScanners` removes OST entries +
|
||
decrements `scanners_ok`; `stripAddedScannerStderr` drops the `[OST]` progress line) is wired into
|
||
json/raw-backcompat + the Step 5/6 humanizer wiring tests (cli-humanizer did **not** break — its
|
||
v5.0.0 compares don't grow a scanners array). Only **SC-5 default-output** (scan-orchestrator +
|
||
posture) is regenerated (additive OST entry only — diff reviewed). OST is fixture-gated: the
|
||
`marketplace-medium` fixture and the hermetic HOME have no output styles, so it emits nothing there.
|
||
|
||
Wiring: orchestrator import + `SCANNERS` entry; `humanizer.mjs` `SCANNER_TO_CATEGORY`
|
||
(`OST: 'Configuration mistake'`); `humanizer-data.mjs` OST family (title-coupled to the three exact
|
||
finding titles); `scoring.mjs` `SCANNER_AREA_MAP` (`OST: 'Settings'` — keeps the 10 quality areas,
|
||
byte-stable on zero-finding projects). Count badges: self-audit scanner count 13→14; humanizer-data
|
||
TRANSLATIONS families 14→15 (PLH is a translation family but not orchestrated).
|
||
|
||
### best-practices register — machine-readable knowledge layer (v5.7 Fase 1 Chunk 1)
|
||
|
||
`knowledge/best-practices.json`: provenance-stamped, schema-validated register (entry =
|
||
`id`/`claim`/`confidence`/`source` + optional `mechanism`/`lensCheck`/…). First runtime-consumed
|
||
file in `knowledge/` (the `*.md` stay human-only); source of truth for the v5.7 optimization lens
|
||
(`CA-OPT`); seeded from the v5.5 V-rows + the Anthropic "Steering Claude Code" blog. Only
|
||
**confirmed** entries are user-facing (Verifiseringsplikt). Loaded/validated by
|
||
`scanners/lib/best-practices-register.mjs` (`loadRegister`/`validateRegister`/`getEntry`; zero-dep
|
||
JSON, **not** YAML — `yaml-parser.mjs` can't do arrays-of-objects). Byte-stable until a scanner
|
||
consumes it (Chunk 2). Full design: `docs/v5.7-optimization-lens-plan.md`.
|
||
|
||
### OPT scanner — optimization lens / mechanism-fit (`CA-OPT`, v5.7 Fase 1 Chunk 2a, count 14→15)
|
||
|
||
First detector of the «optimal?» axis (vs «correct?»). `optimization-lens-scanner.mjs` reads the
|
||
best-practices register and flags config that works but fits a better mechanism. **`CA-OPT-001`**
|
||
(low, *Missed opportunity*): a CLAUDE.md procedure (≥6 consecutive numbered steps) that belongs in a
|
||
skill — recommendation/provenance from register `BP-MECH-003`. Conservative (negative corpus = null
|
||
false-positive); prose-judgment cases (lifecycle→hook, unscoped path→rule, «never»→permission) are
|
||
handled by the Chunk 2b opus analyzer (below). Wiring mirrors OST: orchestrator entry, humanizer
|
||
`OPT:'Missed opportunity'` + family, scoring `OPT:'CLAUDE.md'` (existing area → no new posture row →
|
||
byte-stable), strip-helper `OPT`, SC-5 regenerated (additive).
|
||
|
||
### Optimization lens Chunk 2b — opus analyzer (prose-judgment half, `/config-audit optimize`)
|
||
|
||
The hybrid motor's recall + precision halves for the three cases the deterministic OPT scanner skips.
|
||
**Pre-filter** (`scanners/lib/lens-prefilter.mjs`, pure + tested): cheap, recall-oriented line scan
|
||
of CLAUDE.md body for lifecycle phrasing (`BP-MECH-001`→hook), unscoped path-specific instructions
|
||
(`BP-MECH-002`→rule), and absolute «never» prohibitions (`BP-MECH-004`→permission); skips fenced
|
||
code, gates the path class on an instruction verb. Detector names = the register `lensCheck` fields.
|
||
**CLI** (`optimize-lens-cli.mjs`, `-cli` → not a scanner): runs discovery + OPT scanner + pre-filter,
|
||
attaches the **confirmed** register entry to each candidate (unverifiable → dropped, Verifiseringsplikt),
|
||
emits `{deterministic, candidates, register, counts}`. **Agent** (`optimization-lens-agent`, opus,
|
||
orange — the 7th agent, **precision gate**): reads the real CLAUDE.md, drops low-confidence candidates,
|
||
keeps only genuine opportunities, cites register id + source. **Command** `/config-audit optimize`
|
||
orchestrates pre-filter→agent→report. **Agent-driven → deliberately NOT byte-stable** (own command,
|
||
outside the snapshot suite); the pre-filter lib *is* unit-tested (13 tests). No new orchestrated
|
||
scanner → scanner count stays 15; agents 6→7, commands 18→19, suite 1055→1068.
|
||
|
||
### Subtraction lens (`optimize --subtract`, `BP-SUB-001`) — the inverse axis
|
||
|
||
**Why a mode, not a command or scanner.** Every other command asks an addition question; nothing
|
||
asked what is no longer earning its rent. A hand-built ground truth over a real 250-line global
|
||
CLAUDE.md put the honest payoff at ~850–1400 always-loaded tokens of ~4300 (~20 %, not the source
|
||
anecdote's 80 %), with 26 of 34 blocks load-bearing. That proportion justifies a fourth `lensCheck`
|
||
on the existing hybrid motor — not a new scanner (no badge bump 16→17, no frozen-snapshot risk) and
|
||
not a 22nd command. Cross-session state is what would have forced a command, and the earned-re-add
|
||
*ledger* is deliberately out of v1.
|
||
|
||
**Polarity is flipped from `lens-prefilter`.** That module is recall-first because a false candidate
|
||
only costs the judge a moment. Here a false candidate is a proposal to *delete*, so
|
||
`subtraction-prefilter.mjs` is precision-first and carries a blocking deterministic guarantee.
|
||
|
||
**Floor-exclusion is a separate module on purpose** (`lib/floor-exclusion.mjs`). §6.0's asymmetry —
|
||
a missed dead line costs a few tokens per turn, a deleted load-bearing line costs a wrong remote —
|
||
means the guarantee must not rest on a probabilistic judge, so the floor runs *before* the agent
|
||
and is legible as its own unit. Markers: code span, URL/host, filename, rooted path, version pin,
|
||
policy invariant, and `unresolved-entity` (a mixed-case capitalized word mid-sentence). The last is
|
||
a deliberate conservative default — resolving "Forgejo" from an ordinary capitalized word needs a
|
||
dictionary, so the mechanism declines and keeps the block.
|
||
|
||
**Granularity: leaf block + two structural exceptions.** (1) A paragraph ending in `:` merges with
|
||
the list it introduces — a stem often carries no literal of its own, and deleting it without its
|
||
list is meaningless. (2) An *ordered* list is a contract: steps inherit floor from any sibling,
|
||
because deleting step 2 of a five-step protocol is not like dropping one platitude. Unordered lists
|
||
do **not** inherit — a load-bearing bullet and a disposable one routinely share a list, and
|
||
container-reasoning is exactly the error the ground truth was built to catch.
|
||
|
||
**Three lessons from the dogfood run**, all invisible to the synthesized fixture and worth keeping:
|
||
- **JS `\b` is ASCII-only.** `/\bunngå\b/` never matches — the trailing `å` is not a word character,
|
||
so there is no boundary after it. Every Norwegian keyword ending in æ/ø/å was silently dead. Use
|
||
the `LB`/`RB` lookaround constants, never `\b`, around that vocabulary.
|
||
- **A bare `word/word` is not a path.** `pros/cons` vetoed the single largest deletable block until
|
||
`PATH_RE` was tightened to rooted paths and globs; real filenames are `FILENAME_RE`'s job.
|
||
- **"Mid-sentence" must key on a preceding lowercase letter**, not on "anything that is not a full
|
||
stop". The loose version read `**Bold labels:**` and quoted openers (`"Som AI kan jeg ikke…"`) as
|
||
entities and cost 4 of 11 deletable groups.
|
||
|
||
**Measured against the ground truth:** zero load-bearing blocks proposed (the blocking §8 gate),
|
||
11/18 deletable groups surfaced, ≈756 tok ≈ 18 % of the file — inside the pre-registered band. The
|
||
misses are all the conservative default working as designed (entity names, code spans, and
|
||
declarative/infinitive phrasing that carries no imperative). No new scanner: scanners stay 16,
|
||
commands 21, agents 7; suite 1365→1382.
|
||
|
||
**Note for a future narrowing of the veto:** the two failure modes the agent prompt hardens against
|
||
— staleness-is-not-deletion and tier-2-is-not-tier-3 — are currently *also* covered by exclusion
|
||
(both example blocks carry code spans/version pins and never reach the judge). The prompt language
|
||
is the only protection if those markers are ever loosened.
|
||
|
||
**Test-isolation fix (this session):** `token-hotspots.test.mjs` `runScanner` now wraps `scan()` in
|
||
the shared `withHermeticHome` helper — the suite is green on BOTH a real and a clean `HOME` (the OPT
|
||
section's old «run with clean HOME» caveat is resolved). Snapshot/byte tests were already hermetic.
|
||
|
||
### knowledge-refresh — the "living" half of the register (v5.7 Fase 1 Chunk 3, commands 19→20)
|
||
|
||
Keeps `knowledge/best-practices.json` current so the optimization lens never reads stale rules.
|
||
Same hybrid split as Chunk 2b — a deterministic, byte-stable, unit-tested core + a web/judgment shell:
|
||
|
||
- **Deterministic core** (`scanners/lib/knowledge-refresh.mjs`, pure, 15 tests): `assessFreshness(register,
|
||
{referenceDate, staleAfterDays})` classifies each entry `fresh`/`stale` by the age of its
|
||
`source.verified` stamp. `referenceDate` is **injected** (not read from the clock) so the function is
|
||
fully deterministic; default threshold `STALE_AFTER_DAYS_DEFAULT = 90` (quarterly re-verify cadence).
|
||
An unparseable/missing `verified` → stale with `ageDays: null` (defensive; the schema-validated bundle
|
||
never hits this, but the command's hand-built candidates might). «Source changed» detection is a **web
|
||
responsibility** (command layer), **not** in this core.
|
||
- **CLI** (`scanners/knowledge-refresh-cli.mjs`, `-cli` → **NOT** an orchestrated scanner → scanner count
|
||
stays 15, suite byte-stable; 8 tests): read-only — it NEVER writes the register and NEVER hits the
|
||
network. `--reference-date` (defaults to today; the **only** place the clock is read) makes it
|
||
deterministically testable against the bundled register. `--stale-after N`, `--dry-run` (implicit + only
|
||
mode, echoed as `requestedDryRun`). Exit **0** = all fresh, **1** = some stale (advisory), **3** = error.
|
||
- **Command** (`commands/knowledge-refresh.md`, opus): orchestrates CLI stale-report → re-verify each stale
|
||
entry by re-reading its `source.url` (WebFetch) → poll CC changelog + Anthropic blog for new/changed
|
||
practices (WebSearch) → present everything → **apply ONLY human-approved writes**, then re-run the
|
||
register schema test before declaring done. **No unverified claim is ever auto-written** (Verifiseringsplikt).
|
||
Web/judgment-driven → **deliberately NOT byte-stable** (own command, outside the snapshot suite), exactly
|
||
like `/config-audit optimize`. **No new agent** (web poll runs in the command's own context), **no new
|
||
orchestrated scanner**. suite 1068→1091.
|
||
|
||
### campaign-ledger — durable machine-wide campaign core (v5.7 Fase 2, Block 3a THIN)
|
||
|
||
`scanners/lib/campaign-ledger.mjs`: the durable ledger that sits ABOVE individual sessions for a
|
||
machine-wide audit campaign — repo list + per-repo lifecycle (`STATUSES` = pending→audited→planned
|
||
→implemented) + a machine-wide `rollUp` (counts by status + severity aggregated across repos). It
|
||
persists to a single JSON file **outside** the plugin dir (`~/.claude/config-audit/campaign-ledger
|
||
.json`, next to `sessions/`) so it survives uninstall/reinstall/upgrade. Same hybrid split as
|
||
knowledge-refresh: PURE transforms (`createLedger`/`addRepo`/`setRepoStatus`/`rollUp`) with `now`
|
||
**injected** (YYYY-MM-DD, never the clock) + soft `validateLedger` (returns `{valid,errors}`, never
|
||
throws) + a thin IO shell (`defaultLedgerPath`/`loadLedger`→null-on-ENOENT/`saveLedger`). Transforms
|
||
throw on programmer error (invalid status, unknown path); `schemaVersion` stamped from the start so a
|
||
Block 4 migration is cheap. **THIN**: ledger + roll-up + persistence only — NO execution, CLI, or
|
||
command surface (Blocks 3b/3c/4). **Internal plumbing, byte-stable until consumed**: no `export async
|
||
function scan` + lives in `lib/` → scanner count stays 15, no orchestrator wiring, SC-5 unchanged.
|
||
28 tests, suite 1091→1119.
|
||
|
||
### campaign-cli — read-only ledger reporter (v5.7 Fase 2, Block 3b)
|
||
|
||
`scanners/campaign-cli.mjs` (`-cli` → NOT an orchestrated scanner → scanner count stays 15, suite
|
||
byte-stable; 8 tests): the DETERMINISTIC, READ-ONLY half of the campaign motor, mirroring
|
||
`knowledge-refresh-cli`. It `loadLedger`s the durable ledger, `validateLedger`s it, and emits
|
||
`{status, initialized, ledgerPath, schemaVersion, createdDate, updatedDate, repos, rollUp}` as JSON.
|
||
It NEVER writes — a missing ledger is reported gracefully (`initialized:false`, all-zero roll-up),
|
||
**never created**; init + every status transition belong to the Block 3c command layer (human-approved
|
||
writes, Verifiseringsplikt). `--ledger-file` overrides the default path (deterministic testing);
|
||
`--output-file` mirrors the sibling. Exit codes: **0** = initialized & valid, **1** = not initialized
|
||
yet (advisory), **3** = error (parse/corrupt/invalid). suite 1119→1127.
|
||
|
||
### campaign-write-cli + `/config-audit campaign` — the WRITE half (v5.7 Fase 2, Block 3c, commands 20→21)
|
||
|
||
The human-approved mutation half of the campaign motor, completing the THIN campaign surface
|
||
(ledger + roll-up + status). Two pieces:
|
||
|
||
- **`scanners/campaign-write-cli.mjs`** (`-cli` → NOT an orchestrated scanner → scanner count stays
|
||
15, suite byte-stable; 11 tests): the sibling of `campaign-cli` that *mutates*. Subcommands
|
||
`init` / `add <path>...` / `set-status <path> <status>`, each a thin wrapper over the
|
||
invariant-enforcing lib transforms (`createLedger`/`addRepo`/`setRepoStatus`) + `saveLedger` — so
|
||
path-normalization/dedup, idempotent add, the status-lifecycle guard, and the `updatedDate` bump
|
||
are **never re-implemented by hand**. `init` refuses to clobber an existing (or corrupt) ledger
|
||
(**exit 1** advisory, file untouched); `add` **auto-inits** when no ledger exists and reports
|
||
`added` vs `skipped`; `set-status` accepts `--findings '<json>'` + `--session <id>`. Determinism
|
||
mirrors the lib + `knowledge-refresh-cli`: `--reference-date` is the **only** place the clock is
|
||
read (defaults to today), passed to the transforms as the injected `now`. Exit: **0** = write
|
||
performed, **1** = advisory no-op (init-clobber), **3** = error (unknown subcommand, bad args,
|
||
invalid status, untracked repo, no/corrupt ledger).
|
||
- **`commands/campaign.md`** (opus, `allowed-tools: Read/Write/Edit/Bash/Glob` — **no Web**,
|
||
judgment-free): a thin orchestrator. It always **reports** first (read-only `campaign-cli`), then
|
||
for `init`/`add`/`set-status` it proposes the change and, **only on explicit human approval**,
|
||
invokes one write-CLI subcommand (Verifiseringsplikt — it never hand-edits the ledger JSON).
|
||
`add --discover <root>` finds git repos under a root and lets the user pick. When marking a repo
|
||
`audited` it attaches findings-by-severity from the repo's session (or user-provided counts) —
|
||
**never invented**.
|
||
|
||
**Not a new scanner, not byte-stable.** Both CLIs carry the `-cli` suffix (out of the
|
||
scan-orchestrator → scanner count stays **15**, snapshot suite untouched); the command's
|
||
orchestration is judgment-driven and deliberately outside the snapshot suite, exactly like
|
||
`/config-audit optimize` + `knowledge-refresh`. **No new agent** (web/judgment-free, runs in the
|
||
command's own context). suite 1127→1138.
|
||
|
||
### campaign backlog — cross-repo prioritized pick-list (v5.7 Fase 2, Block 4b)
|
||
|
||
The first half of Block 4 ("one cross-repo prioritized backlog the user picks from"). A pure
|
||
lib transform + a read-only CLI-payload field — **no schema change, no new scanner, byte-stable**.
|
||
|
||
- **`buildBacklog(ledger)`** (`scanners/lib/campaign-ledger.mjs`, pure, mirrors `rollUp`): the
|
||
single machine-wide prioritized work list. The actionable unit is a **repo** (the ledger tracks
|
||
per-repo severity *counts*, not individual findings — it tracks state, it does not re-run
|
||
audits), so each item is one repo: `{path, name, status, sessionId, findingsBySeverity
|
||
(normalized), totalFindings, weightedScore, rank}`. **Inclusion:** `status !== 'implemented'`
|
||
AND `totalFindings > 0` (implemented = done; pending / zero-finding repos have nothing known to
|
||
fix — they still surface in `rollUp.byStatus`). **Order:** DESC by `weightedScore` (exported
|
||
`SEVERITY_WEIGHTS = {critical:1000, high:100, medium:10, low:1}`), tie-broken lexicographically
|
||
by critical→high→medium→low count, then ascending `name` — fully deterministic, and the
|
||
tie-break keeps "criticals always win" even on a weighted-score collision (1 critical vs 10 high).
|
||
`rank` is 1-based after the sort.
|
||
- **`campaign-cli`** now emits `backlog: buildBacklog(ledger)` in both branches (uninitialized →
|
||
`[]`). Purely additive + read-only → fits the Block 3b read-only contract; the existing CLI
|
||
tests use targeted asserts (not full `deepEqual`), so the new field doesn't break them.
|
||
- **`commands/campaign.md`** renders the backlog as a "Prioritized backlog" pick-list and points
|
||
the user at the top item (still a pick-list, NOT an executor — execution is the later 4c block).
|
||
|
||
**Byte-stability.** `-cli`/lib/command only → scanner count stays **15**, snapshot/backcompat
|
||
suite untouched. suite 1138→1150 (lib +9, campaign-cli +3). **Deferred to 4c:** per-repo plan
|
||
export to each repo's `docs/` + reuse of backup/rollback for execution. **Deferred until the first
|
||
breaking schema change:** `migrateLedger` (4a) — backlog needs no schema bump, so building
|
||
migration now would be speculative (`schemaVersion` is already stamped for when it's needed).
|
||
|
||
### campaign plan-export + execution-by-reuse (v5.7 Fase 2, Block 4c — the rest of Block 4)
|
||
|
||
The second half of Block 4. **Asymmetric:** plan export is the new testable code; execution is
|
||
*pure reuse* (no new machinery), per the plan's "reuse existing backup/rollback".
|
||
|
||
- **Plan export** (`scanners/lib/campaign-export.mjs`, pure, 8 tests): `planExportPath(repoPath,
|
||
sessionId)` → `<repo>/docs/config-audit-plan-<sessionId>.md` (keyed on the timestamp-unique
|
||
sessionId, not the date, so same-day re-audits don't collide); `buildPlanExportDocument({...,now})`
|
||
→ provenance header (repo/session/how-to-execute-and-undo) + the verbatim session plan. `now`
|
||
injected → deterministic. **CLI** `scanners/campaign-export-cli.mjs` (`-cli`, read-only by
|
||
default, 10 tests): `--repo <path>` resolves the repo's linked session, reads its
|
||
`action-plan.md`, assembles the doc, emits `{exportable, problems, targetPath, document, ...}`.
|
||
Two gates → exit 1 advisory: `no-session-linked` (repo has no `sessionId`), `no-action-plan`
|
||
(linked session has no plan yet). Writes the file **only** under opt-in `--write` — the CLI does
|
||
the byte-faithful copy so a 200-line plan is never re-typed/mutated by the LLM. `--sessions-dir`
|
||
override for hermetic tests; exit 0/1/3 mirror the sibling CLIs.
|
||
- **Execution = reuse.** No campaign-side execution code. The exported `docs/` file is the repo's
|
||
durable record; `/config-audit implement` still reads the canonical plan from the session
|
||
(backup + apply + verify), `/config-audit rollback` undoes, then `set-status <path> implemented`
|
||
records it. The command (`commands/campaign.md`, new `export <path>` mode) previews → asks → on
|
||
approval invokes `--write` → routes the user to that existing machinery.
|
||
- **Byte-stable.** lib + `-cli` + command-doc only → scanner count stays **15**, agents **7**,
|
||
commands **21** (export is a *mode*, not a new command), snapshot/backcompat suite untouched.
|
||
suite 1150→1168 (lib +8, export-cli +10). **Block 4a (`migrateLedger`) still deferred** to the
|
||
first breaking schema change (export needs no schema bump).
|
||
|
||
### arg-sluk — the CLI argument class, measured across all fourteen CLIs (v5.14, #57)
|
||
|
||
`scanners/lib/cli-args.mjs`. Every CLI in `scanners/` parses argv with a chain of
|
||
`if (a === '--x') … else if …`, and two things fell through that chain in silence.
|
||
|
||
**Arm 1 — the unknown flag.** With no `else` branch, `--zzz` left no trace: exit 0, full
|
||
payload, a confident answer to a question the caller did not ask. First costed in #51, when
|
||
`knowledge-refresh`'s only knob reached the CLI malformed and the command reported "all 14
|
||
register entries were re-verified within the last 90 days" — about a threshold the user had
|
||
just overridden.
|
||
|
||
**Arm 2 — the value that was really a flag.** `a === '--output-file' && args[i + 1]` asks
|
||
only whether a next token *exists*, never whether it is a value. So `--output-file --json`
|
||
took `--json` as the filename. Measured: `manifest`, `campaign-cli` and
|
||
`knowledge-refresh-cli` each **wrote a file literally named `--json`** into the caller's
|
||
working directory, exit 0, with `--json` mode silently dropped. A wrong answer is bad; an
|
||
unintended file on disk is worse.
|
||
|
||
**Width — the deferral list was a prediction, not a measurement.** `KNOWN_OPEN` in
|
||
`tests/scanners/cli-unknown-flag-rejection.test.mjs` named **two** CLIs. Measuring all
|
||
fourteen found **7** open on arm 1 and **10** on arm 2 — including `campaign-cli` and
|
||
`knowledge-refresh-cli`, which were already in `GUARDED` and *passing* the arm-1 test while
|
||
arm 2 stood open a few lines away. Three CLIs (`drift-cli`, `fix-cli`,
|
||
`plugin-health-scanner`) were already correct on both arms because they use a different
|
||
parse form; they were moved into `GUARDED` rather than left unguarded.
|
||
|
||
**Why a gate and not a rewrite.** The module runs *before* each CLI's existing loop and does
|
||
not replace it. Valid argv therefore reaches the existing parser byte-for-byte unchanged, so
|
||
no frozen snapshot can move — the byte-stability argument is structural, not empirical. The
|
||
three CLIs that carried a bespoke `else if (a.startsWith('--')) fail(…)` branch had it removed
|
||
once the gate made it unreachable, along with the now-redundant `&& args[i + 1]` guards.
|
||
|
||
Exit code is **3** by the exit-code contract: a malformed argument means the scanner never got
|
||
to do its job, which is categorically different from 0/1/2 — verdicts about a configuration
|
||
that *was* examined.
|
||
|
||
---
|
||
|
||
### rollback-cli — the recovery path gets a runnable entry (R1+R2, #74)
|
||
|
||
`scanners/rollback-cli.mjs` is the entry to `rollback-engine.mjs` and, via
|
||
`--create`, to `lib/backup.mjs`. It closes the two KRITISK rows of the Q3
|
||
severity table as one chunk, because neither half holds alone.
|
||
|
||
**R1 — the restore was model prose over a code engine.** Measured at the head of
|
||
the chunk: 16 files under `scanners/` carried a `process.argv` entry;
|
||
`rollback-engine.mjs` was not one of them, though it had verified every file's
|
||
checksum *before and after* each write since M-BUG-22 and reported
|
||
`createdNotRemoved` since M-BUG-25. `commands/rollback.md` §Implementation
|
||
showed an ESM `import` block a command template cannot execute and offered
|
||
ad-hoc `cp` underneath as the runnable alternative, then pre-rendered
|
||
"`(checksum verified)`" three times in the success output. `cp` establishes no
|
||
checksum, so the verification was a property of the template, not of the run —
|
||
on the one surface that runs when the user is already in trouble.
|
||
|
||
**R2 — the backup format had two authors, one of them prose.** The fix pipeline
|
||
backed up through `createBackup`; the implement pipeline hand-built its own —
|
||
`mkdir`, `cp`, a `date +%Y%m%d_%H%M%S` id, and a manifest typed out in the
|
||
template. `parseManifest` grew a second branch for that format because the seam
|
||
had already failed silently once, and the fixture pinning it was **hand-written**
|
||
rather than derived from the template's own text: the #63 shape on the data
|
||
side. Rename a key in the template and `parseManifest` returns zero files while
|
||
`rollback` reports success.
|
||
|
||
**Why one chunk.** Fix only R1 and the new CLI still parses a prose format. Fix
|
||
only R2 and the format is clean with no runnable entry behind it.
|
||
|
||
**Exit contract.** 0 done · 1 outstanding (a restore the scope gate will not
|
||
perform without `--approve-scope`, nothing written; or a backup that covered
|
||
fewer targets than it was given) · 2 at least one file failed · 3 the CLI could
|
||
not do its job. A gated restore is **1, not 3**: "this write leaves your project"
|
||
is a verdict about a write that *was* examined, and it rides in the payload,
|
||
where a command running under `2>/dev/null` can act on it. That is F3's class,
|
||
avoided rather than repeated.
|
||
|
||
**`--created` records, it does not copy.** No backup can hold a file that does
|
||
not exist yet. Those paths go into the manifest so `rollback` can list what it is
|
||
leaving in place. `serializeManifest` emits the bare `created:` key; the pre-R2
|
||
implement format used `created: <timestamp>` with a VALUE, meaning the backup id,
|
||
and `parseManifest` tells them apart on exactly that — which is why the two never
|
||
collide in one file.
|
||
|
||
**The implement-format branch stays.** Nothing writes that shape any more, but
|
||
every backup implement made before this chunk is on disk in it and must remain
|
||
restorable — the same reasoning that keeps `getLegacyBackupDir()` readable. The
|
||
hand-written fixture in `tests/scanners/rollback-paths.test.mjs` therefore
|
||
changed meaning rather than becoming obsolete: it is now a golden sample of
|
||
historical bytes, which is a legitimate thing to write by hand, instead of a
|
||
stand-in for a template's own text, which is not.
|
||
|
||
**What replaced "(checksum verified)".** `tests/commands/backup-restore-contract.test.mjs`
|
||
runs the CLI and checks every field `rollback.md` renders against the real
|
||
payload. A renamed payload key now fails a test instead of turning into a
|
||
confident sentence. Seen red against its own defect by renaming `{status}`.
|
||
|
||
**A guard hole found by mutation.** The first version of the implement-side
|
||
assertion matched `--create` as a substring, and the same invocation carries
|
||
`--created` — so replacing the `--create` call with `--list` left the guard
|
||
green. It matches `--create(?![a-z])` now. Separately, mutating
|
||
`if (!requireValidArgs(...)) return;` into a bare call showed that
|
||
`requireValidArgs` sets exit 3 *by itself*: a CLI can report "I could not parse
|
||
my arguments" and still run the restore underneath. `rollback-cli.test.mjs`
|
||
asserts on the bytes for that case, not on the exit code.
|